diff --git a/app/channels/map_channel.rb b/app/channels/map_channel.rb index 92d8ad76d..8621c2001 100644 --- a/app/channels/map_channel.rb +++ b/app/channels/map_channel.rb @@ -95,7 +95,7 @@ def map_atts(data) end def layer_atts(data) - data.slice("id", "type", "name", "query", "heatmap", "cluster") + data.slice("id", "type", "name", "query", "heatmap", "cluster", "show") end # load map with write access diff --git a/app/javascript/channels/map_channel.js b/app/javascript/channels/map_channel.js index 8cd7e4cdb..ab8a10139 100644 --- a/app/javascript/channels/map_channel.js +++ b/app/javascript/channels/map_channel.js @@ -1,9 +1,14 @@ import consumer from 'channels/consumer' +import { initializeLayerStyles, layers, loadLayerDefinitions } from 'maplibre/layers/layers' import { - upsert, destroyFeature, setBackgroundMapLayer, mapProperties, - initializeMaplibreProperties, map, - reloadMapProperties } from 'maplibre/map' -import { layers, initializeLayerStyles, loadLayerDefinitions } from 'maplibre/layers/layers' + destroyFeature, + initializeMaplibreProperties, map, + mapProperties, + reloadMapProperties, + setBackgroundMapLayer, + setLayerVisibility, + upsert +} from 'maplibre/map' export let mapChannel @@ -99,9 +104,13 @@ export function initializeSocket () { // Remove geojson key before comparison const { ['geojson']: _, ...layerDef } = layers[index] if (JSON.stringify(layerDef) !== JSON.stringify(data.layer)) { + // preserve geojson data when updating layer definition + const geojson = layers[index].geojson layers[index] = data.layer + if (geojson) { layers[index].geojson = geojson } console.log('Layer updated on server, reloading layer styles', data.layer) initializeLayerStyles(data.layer.id) + setLayerVisibility(data.layer.type + '-source-' + data.layer.id, data.layer.show !== false) } } else { layers.push(data.layer) diff --git a/app/javascript/controllers/map/layers_controller.js b/app/javascript/controllers/map/layers_controller.js index f9d922c8e..4b245748a 100644 --- a/app/javascript/controllers/map/layers_controller.js +++ b/app/javascript/controllers/map/layers_controller.js @@ -1,6 +1,6 @@ import { Controller } from '@hotwired/stimulus' import { mapChannel } from 'channels/map_channel' -import { map, upsert, mapProperties, removeGeoJSONSource } from 'maplibre/map' +import { map, upsert, mapProperties, removeGeoJSONSource, setLayerVisibility } from 'maplibre/map' import { initLayersModal } from 'maplibre/controls/shared' import { uploadImageToFeature, confirmImageLocation } from 'maplibre/feature' import { status } from 'helpers/status' @@ -200,6 +200,39 @@ export default class extends Controller { list.classList.toggle('hidden') } + toggleLayerVisibility (event) { + event.preventDefault() + dom.closeTooltips() + const layerElement = event.target.closest('.layer-item') + const layerId = layerElement.getAttribute('data-layer-id') + const layer = layers.find(l => l.id === layerId) + const wasVisible = layer.show !== false + layer.show = !wasVisible + + setLayerVisibility(layer.type + '-source-' + layerId, layer.show) + + // update UI + const icon = layerElement.querySelector('button.layer-visibility i') + if (layer.show) { + icon.classList.replace('bi-eye-slash', 'bi-eye') + layerElement.classList.remove('opacity-50') + } else { + icon.classList.replace('bi-eye', 'bi-eye-slash') + layerElement.classList.add('opacity-50') + } + + // when showing: initialize styles (and load data for overpass/wikipedia if needed) + if (layer.show) { + initializeLayerStyles(layerId) + } + + // sync to server only in rw mode + if (window.gon.map_mode === "rw") { + const { geojson: _geojson, ...sendLayer } = layer + mapChannel.send_message('update_layer', sendLayer) + } + } + createWikipediaLayer() { this.createLayer('wikipedia', 'Wikipedia', '') } @@ -221,7 +254,7 @@ export default class extends Controller { createLayer(type, name, query) { let layerId = functions.featureId() // must match server attribute order, for proper comparison in map_channel - let layer = { "id": layerId, "type": type, "name": name, "heatmap": false, "cluster": true} + let layer = { "id": layerId, "type": type, "name": name, "heatmap": false, "cluster": true, "show": true} if (type == 'overpass') { layer["query"] = query // TODO: move cluster + heatmap to layer checkboxes diff --git a/app/javascript/helpers/dom.js b/app/javascript/helpers/dom.js index 50d4822e7..bb7473ccf 100644 --- a/app/javascript/helpers/dom.js +++ b/app/javascript/helpers/dom.js @@ -57,11 +57,11 @@ export function initTooltips (root = document) { } } -export function closeTooltips () { - functions.e('[data-toggle="tooltip"]', e => { - const tooltip = bootstrap.Tooltip.getInstance(e); - if (tooltip) tooltip.dispose() - }) +export function closeTooltips (root = document) { + root.querySelectorAll('[data-toggle="tooltip"]').forEach(e => { + const tooltip = bootstrap.Tooltip.getInstance(e) + if (tooltip) tooltip.dispose() + }) } export function scrollToId(elementId) { diff --git a/app/javascript/maplibre/controls/shared.js b/app/javascript/maplibre/controls/shared.js index 28669bf49..810e3c4ca 100644 --- a/app/javascript/maplibre/controls/shared.js +++ b/app/javascript/maplibre/controls/shared.js @@ -175,6 +175,7 @@ export function initSettingsModal () { // create the list of layers + features export function initLayersModal () { functions.e('#layers', e => { + dom.closeTooltips(e) e.innerHTML = '' const template = document.querySelector('#layer-item-template') layers.forEach(layer => { @@ -200,6 +201,13 @@ export function initLayersModal () { featureCount.textContent = '(' + features.length + ')' head.parentNode.insertBefore(featureCount, head.nextSibling) e.appendChild(layerElement) + // visibility toggle for all layers + const visBtn = layerElement.querySelector('button.layer-visibility') + visBtn.classList.remove('hidden') + if (layer.show === false) { + visBtn.querySelector('i').classList.replace('bi-eye', 'bi-eye-slash') + layerElement.classList.add('opacity-50') + } if (layer.type !== 'geojson') { layerElement.querySelector('button.layer-refresh').classList.remove('hidden') layerElement.querySelector('button.layer-delete').classList.remove('hidden') diff --git a/app/javascript/maplibre/layers/geojson.js b/app/javascript/maplibre/layers/geojson.js index 17a876328..d3c30b1d2 100644 --- a/app/javascript/maplibre/layers/geojson.js +++ b/app/javascript/maplibre/layers/geojson.js @@ -4,12 +4,12 @@ import { lineString } from "@turf/helpers" import { length } from "@turf/length" import { draw, select } from 'maplibre/edit' import { getFeature, getFeatures, layers } from 'maplibre/layers/layers' -import { map, mapProperties } from 'maplibre/map' +import { map, mapProperties, removeStyleLayers } from 'maplibre/map' import { defaultLineWidth, featureColor, initializeClusterStyles, initializeViewStyles, labelFont, setSource, styles } from 'maplibre/styles/styles' export function initializeGeoJSONLayers(id = null) { // console.log('Initializing geojson layers') - let initLayers = layers.filter(l => l.type === 'geojson') + let initLayers = layers.filter(l => l.type === 'geojson' && l.show !== false) if (id) { initLayers = initLayers.filter(l => l.id === id) } initLayers.forEach((layer) => { @@ -188,6 +188,7 @@ export function kmMarkerStyles (_id) { } export function initializeKmMarkerStyles(id) { + removeStyleLayers('km-marker-source-' + id) kmMarkerStyles(id).forEach(style => { style = setSource (style, 'km-marker-source-' + id) map.addLayer(style) diff --git a/app/javascript/maplibre/layers/layers.js b/app/javascript/maplibre/layers/layers.js index 8866d0752..d7e32b9c3 100644 --- a/app/javascript/maplibre/layers/layers.js +++ b/app/javascript/maplibre/layers/layers.js @@ -37,7 +37,7 @@ export function initializeLayerSources(id = null) { initLayers.forEach((layer) => { // drop cluster when heatmap is set const cluster = !!layer.cluster && !layer.heatmap - console.log('Adding source for layer', layer.type, layer.id, cluster) + console.log('Adding source for layer', layer) addGeoJSONSource(layer.type + '-source-' + layer.id, cluster) // add one source for km markers per geojson layer if (layer.type === 'geojson') { @@ -66,6 +66,10 @@ export async function initializeLayerStyles(id = null) { // triggered by layer reload in the layers modal export function loadLayerData(id) { let layer = layers.find(l => l.id === id) + if (layer.show === false) { + console.log("Skipped loading data for not shown layer", layer) + return Promise.resolve() + } // geojson layers are loaded in loadLayerDefinitions if (layer.type === 'wikipedia') { return loadWikipediaLayer(layer.id) diff --git a/app/javascript/maplibre/layers/overpass.js b/app/javascript/maplibre/layers/overpass.js index 7fb25c2fa..b07bc8167 100644 --- a/app/javascript/maplibre/layers/overpass.js +++ b/app/javascript/maplibre/layers/overpass.js @@ -7,7 +7,7 @@ import { map } from 'maplibre/map' import { initializeClusterStyles, initializeViewStyles } from 'maplibre/styles/styles' export function initializeOverpassLayers(id = null) { - let initLayers = layers.filter(l => l.type === 'overpass') + let initLayers = layers.filter(l => l.type === 'overpass' && l.show !== false) if (id) { initLayers = initLayers.filter(l => l.id === id) } return initLayers.map((layer) => { const clustered = !layer.query.includes("heatmap=true") && diff --git a/app/javascript/maplibre/layers/wikipedia.js b/app/javascript/maplibre/layers/wikipedia.js index 7ee48e07c..7d88a2776 100644 --- a/app/javascript/maplibre/layers/wikipedia.js +++ b/app/javascript/maplibre/layers/wikipedia.js @@ -7,7 +7,7 @@ import { initializeClusterStyles, initializeViewStyles } from 'maplibre/styles/s export function initializeWikipediaLayers(id = null) { // console.log('Initializing Wikipedia layers') - let initLayers = layers.filter(l => l.type === 'wikipedia') + let initLayers = layers.filter(l => l.type === 'wikipedia' && l.show !== false) if (id) { initLayers = initLayers.filter(l => l.id === id) } return initLayers.map((layer) => { diff --git a/app/javascript/maplibre/map.js b/app/javascript/maplibre/map.js index 18da6cc7c..ed6bd128c 100644 --- a/app/javascript/maplibre/map.js +++ b/app/javascript/maplibre/map.js @@ -216,6 +216,21 @@ export function removeStyleLayers(sourceName) { } } +export function setLayerVisibility(sourceName, visible) { + if (map.getStyle && map.getStyle().layers) { + const sources = [sourceName] + // geojson layers have a companion km-marker source + if (sourceName.startsWith('geojson-source-')) { + sources.push(sourceName.replace('geojson-source-', 'km-marker-source-')) + } + map.getStyle().layers + .filter(l => sources.includes(l.source)) + .forEach(l => { + if (map.getLayer(l.id)) map.setLayoutProperty(l.id, 'visibility', visible ? 'visible' : 'none') + }) + } +} + export function removeGeoJSONSource(sourceName) { removeStyleLayers(sourceName) if (map.getSource(sourceName)) { diff --git a/app/models/layer.rb b/app/models/layer.rb index 73ebf51aa..2d9e35724 100644 --- a/app/models/layer.rb +++ b/app/models/layer.rb @@ -14,13 +14,14 @@ class Layer field :query field :heatmap, type: Boolean field :cluster, type: Boolean + field :show, type: Boolean, default: true field :features_count, type: Integer, default: 0 after_save :broadcast_update, if: -> { map.present? } after_destroy :broadcast_destroy, if: -> { map.present? } def to_summary_json - json = { id: id, type: type, name: name, heatmap: !!heatmap, cluster: !!cluster } + json = { id: id, type: type, name: name, heatmap: !!heatmap, cluster: !!cluster, show: show != false } json[:query] = query if type == "overpass" json end @@ -44,7 +45,7 @@ def clone_with_features end def broadcast_update - if (%w[name query heatmap cluster] & previous_changes.keys).any? + if (%w[name query heatmap cluster show] & previous_changes.keys).any? # broadcast to private + public channel [ map.private_id, map.public_id ].each do |map_id| ActionCable.server.broadcast("map_channel_#{map_id}", diff --git a/app/views/maps/modals/_layers.haml b/app/views/maps/modals/_layers.haml index 505bef295..b97aea6d9 100644 --- a/app/views/maps/modals/_layers.haml +++ b/app/views/maps/modals/_layers.haml @@ -69,12 +69,14 @@ %i.bi.bi-caret-right-fill %span.mapforge-font.layer-name.me-2 Layer name %span.text-nowrap - %button.hidden.ms-2.btn.btn-secondary.btn-layer-actions.layer-refresh{ data: { action: "click->map--layers#refreshLayer", "toggle": 'tooltip', 'bs-custom-class': 'maplibregl-ctrl-tooltip' }, title: "Refresh layer for current view" } + %button.hidden.ms-2.btn.btn-secondary.btn-layer-actions.layer-visibility{ data: { action: "click->map--layers#toggleLayerVisibility", "toggle": 'tooltip', 'bs-custom-class': 'maplibregl-ctrl-tooltip' }, title: "Toggle visibility" } + %i.bi.bi-eye + %button.hidden.btn.btn-secondary.btn-layer-actions.layer-refresh{ data: { action: "click->map--layers#refreshLayer", "toggle": 'tooltip', 'bs-custom-class': 'maplibregl-ctrl-tooltip' }, title: "Refresh layer for current view" } %i.bi.bi-arrow-clockwise.reload-icon %button.hidden.btn.btn-secondary.btn-layer-actions.layer-edit{ data: { action: "click->map--layers#toggleEdit", "toggle": 'tooltip', 'bs-custom-class': 'maplibregl-ctrl-tooltip' }, title: "Edit query" } %i.bi.bi-pencil-square %button.hidden.btn.btn-orange.btn-layer-actions.layer-delete{ data: { action: "click->map--layers#deleteLayer", "toggle": 'tooltip', 'bs-custom-class': 'maplibregl-ctrl-tooltip' }, title: "Delete Layer" } - %i.bi.bi-trash + %i.bi.bi-trash .layer-content.hidden diff --git a/spec/features/map_layers_spec.rb b/spec/features/map_layers_spec.rb index 5922de4e9..a3ea4b70c 100644 --- a/spec/features/map_layers_spec.rb +++ b/spec/features/map_layers_spec.rb @@ -129,6 +129,49 @@ end end + context "layer visibility" do + before do + find(".maplibregl-ctrl-layers").click + end + + it "toggles layer visibility" do + find("button.layer-visibility").click + expect(page).to have_css(".layer-item.opacity-50") + expect(page).to have_css("button.layer-visibility i.bi-eye-slash") + wait_for { map.layers.first.reload.show }.to be false + end + + it "shows hidden layer" do + map.layers.first.update!(show: false) + visit map.private_map_path + expect_map_loaded + find(".maplibregl-ctrl-layers").click + expect(page).to have_css("button.layer-visibility i.bi-eye-slash") + find("button.layer-visibility").click + expect(page).to have_css("button.layer-visibility i.bi-eye") + expect(page).not_to have_css(".layer-item.opacity-50") + wait_for { map.layers.first.reload.show }.to be true + end + end + + context "layer visibility in readonly mode" do + it "does not sync visibility change to server" do + find(".maplibregl-ctrl-layers").click + find("button.layer-visibility").click + wait_for { map.layers.first.reload.show }.to be false + + visit map.public_map_path + expect_map_loaded + find(".maplibregl-ctrl-layers").click + expect(page).to have_css("button.layer-visibility i.bi-eye-slash") + # toggle in ro mode + find("button.layer-visibility").click + expect(page).to have_css("button.layer-visibility i.bi-eye") + # server state unchanged + expect(map.layers.first.reload.show).to be false + end + end + context "add new layer" do before do find(".maplibregl-ctrl-layers").click diff --git a/spec/models/layer_spec.rb b/spec/models/layer_spec.rb index 5eda823dc..3d2848d67 100644 --- a/spec/models/layer_spec.rb +++ b/spec/models/layer_spec.rb @@ -11,4 +11,45 @@ expect(clone.id).not_to eq subject.id end end + + describe "#show" do + it "defaults to true" do + layer = create(:layer) + expect(layer.show).to be true + end + + it "can be set to false" do + layer = create(:layer, show: false) + expect(layer.show).to be false + end + end + + describe "#to_summary_json" do + it "includes show: true by default" do + layer = create(:layer) + expect(layer.to_summary_json[:show]).to be true + end + + it "includes show: false when hidden" do + layer = create(:layer, show: false) + expect(layer.to_summary_json[:show]).to be false + end + end + + describe "#broadcast_update" do + let!(:map) { create(:map) } + let!(:layer) { map.layers.first } + + it "broadcasts when show changes" do + allow(ActionCable.server).to receive(:broadcast) + layer.update!(show: false) + expect(ActionCable.server).to have_received(:broadcast).twice + end + + it "does not broadcast when show is unchanged" do + allow(ActionCable.server).to receive(:broadcast) + layer.update!(features_count: 5) + expect(ActionCable.server).not_to have_received(:broadcast) + end + end end