diff --git a/app/assets/stylesheets/docs.css b/app/assets/stylesheets/docs.css index 88371328..5d1d07d1 100644 --- a/app/assets/stylesheets/docs.css +++ b/app/assets/stylesheets/docs.css @@ -21,7 +21,7 @@ nav.fixed-top~#docs { padding-bottom: 0.4rem; width: fit-content; border-bottom: 0.15rem solid transparent; - border-image: linear-gradient(to right, var(--color-dark-moss-green), var(--color-light-sand)) 1; + border-image: linear-gradient(to right, var(--color-light-sand), var(--color-dark-moss-green)) 1; } h2, h3, h4 { diff --git a/app/javascript/channels/map_channel.js b/app/javascript/channels/map_channel.js index ab8a1013..a8d731d0 100644 --- a/app/javascript/channels/map_channel.js +++ b/app/javascript/channels/map_channel.js @@ -1,5 +1,6 @@ import consumer from 'channels/consumer' -import { initializeLayerStyles, layers, loadLayerDefinitions } from 'maplibre/layers/layers' +import { createLayerInstance } from 'maplibre/layers/factory' +import { initializeLayerSources, initializeLayerStyles, layers, loadLayerDefinitions } from 'maplibre/layers/layers' import { destroyFeature, initializeMaplibreProperties, map, @@ -101,25 +102,24 @@ export function initializeSocket () { case 'update_layer': const index = layers.findIndex(l => l.id === data.layer.id) if (index > -1) { - // Remove geojson key before comparison - const { ['geojson']: _, ...layerDef } = layers[index] + const layerDef = layers[index].toJSON() 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) + layers[index].update(data.layer) initializeLayerStyles(data.layer.id) - setLayerVisibility(data.layer.type + '-source-' + data.layer.id, data.layer.show !== false) + setLayerVisibility(layers[index].sourceId, data.layer.show !== false) } } else { - layers.push(data.layer) + const newLayer = createLayerInstance(data.layer) + layers.push(newLayer) + initializeLayerSources(data.layer.id) initializeLayerStyles(data.layer.id) } break case 'delete_layer': const delIndex = layers.findIndex(l => l.id === data.layer.id) if (delIndex > -1) { + layers[delIndex].cleanup() layers.splice(delIndex, 1) // trigger a full map redraw setBackgroundMapLayer(mapProperties.base_map, true) diff --git a/app/javascript/controllers/feature/edit_controller.js b/app/javascript/controllers/feature/edit_controller.js index 7bf40b43..924f25f0 100644 --- a/app/javascript/controllers/feature/edit_controller.js +++ b/app/javascript/controllers/feature/edit_controller.js @@ -6,8 +6,7 @@ import { status } from 'helpers/status' import { flyToFeature } from 'maplibre/animations' import { draw, handleDelete } from 'maplibre/edit' import { confirmImageLocation, featureIcon, featureImage, uploadImageToFeature } from 'maplibre/feature' -import { renderGeoJSONLayer } from 'maplibre/layers/geojson' -import { getFeature, getLayer } from 'maplibre/layers/layers' +import { getFeature, getLayer, renderLayer } from 'maplibre/layers/layers' import { featureColor, featureOutlineColor } from 'maplibre/styles/styles' import { addUndoState } from 'maplibre/undo' @@ -41,7 +40,7 @@ export default class extends Controller { document.querySelector('#feature-edit-raw .error').innerHTML = '' try { feature.properties = JSON.parse(document.querySelector('#feature-edit-raw textarea').value) - renderGeoJSONLayer(this.layerIdValue, true) + renderLayer(this.layerIdValue, true) mapChannel.send_message('update_feature', feature) } catch (error) { console.error('Error updating feature:', error.message) @@ -56,7 +55,7 @@ export default class extends Controller { feature.properties.title = title if (document.querySelector('#feature-show-title-on-map')?.checked) { feature.properties.label = title - renderGeoJSONLayer(this.layerIdValue, false) + renderLayer(this.layerIdValue, false) } document.querySelector('#feature-title').textContent = title functions.debounce(() => { this.saveFeature() }, 'title') @@ -70,7 +69,7 @@ export default class extends Controller { } else { delete feature.properties.label } - renderGeoJSONLayer(this.layerIdValue, false) + renderLayer(this.layerIdValue, false) functions.debounce(() => { this.saveFeature() }, 'show-title-on-map', 1000) } @@ -86,7 +85,7 @@ export default class extends Controller { } feature.properties[propertyName] = value draw.setFeatureProperty(this.featureIdValue, propertyName, value) - renderGeoJSONLayer(this.layerIdValue, true) + renderLayer(this.layerIdValue, true) } // called as preview on slider change @@ -134,7 +133,7 @@ export default class extends Controller { document.querySelector('#stroke-color').removeAttribute('disabled') } feature.properties.stroke = color - renderGeoJSONLayer(this.layerIdValue, true) + renderLayer(this.layerIdValue, true) } updateFillColor () { @@ -142,7 +141,7 @@ export default class extends Controller { const color = document.querySelector('#fill-color').value if (feature.geometry.type === 'Polygon' || feature.geometry.type === 'MultiPolygon') { feature.properties.fill = color } if (feature.geometry.type === 'Point') { feature.properties['marker-color'] = color } - renderGeoJSONLayer(this.layerIdValue, true) + renderLayer(this.layerIdValue, true) } updateFillColorTransparent () { @@ -158,7 +157,7 @@ export default class extends Controller { } if (feature.geometry.type === 'Polygon' || feature.geometry.type === 'MultiPolygon') { feature.properties.fill = color } if (feature.geometry.type === 'Point') { feature.properties['marker-color'] = color } - renderGeoJSONLayer(this.layerIdValue, true) + renderLayer(this.layerIdValue, true) } updateShowKmMarkers () { @@ -170,7 +169,7 @@ export default class extends Controller { delete feature.properties['show-km-markers'] delete feature.properties['stroke-image-url'] } - renderGeoJSONLayer(this.layerIdValue, true) + renderLayer(this.layerIdValue, true) } updateMarkerSymbol () { @@ -183,7 +182,7 @@ export default class extends Controller { // draw layer feature properties aren't getting updated by draw.set() draw.setFeatureProperty(this.featureIdValue, 'marker-symbol', symbol) functions.e('.feature-symbol', e => { e.innerHTML = featureIcon(feature) }) - renderGeoJSONLayer(this.layerIdValue, true) + renderLayer(this.layerIdValue, true) } async updateMarkerImage () { @@ -205,7 +204,7 @@ export default class extends Controller { feature.geometry.coordinates = imageLocation flyToFeature(feature) } - renderGeoJSONLayer(this.layerIdValue, true) + renderLayer(this.layerIdValue, true) this.saveFeature() }) } diff --git a/app/javascript/controllers/map/context_menu_controller.js b/app/javascript/controllers/map/context_menu_controller.js index a5e255bb..4f22b370 100644 --- a/app/javascript/controllers/map/context_menu_controller.js +++ b/app/javascript/controllers/map/context_menu_controller.js @@ -1,12 +1,11 @@ import { Controller } from '@hotwired/stimulus' import { mapChannel } from 'channels/map_channel' -import { status } from 'helpers/status' import * as functions from 'helpers/functions' +import { status } from 'helpers/status' import { hideContextMenu } from 'maplibre/controls/context_menu' -import { getFeature } from 'maplibre/layers/layers' +import { getFeature, renderLayers } from 'maplibre/layers/layers' import { addFeature } from 'maplibre/map' import { addUndoState } from 'maplibre/undo' -import { renderGeoJSONLayers } from 'maplibre/layers/geojson' export default class extends Controller { @@ -18,7 +17,7 @@ export default class extends Controller { addUndoState('Feature update', feature) if (feature.geometry.type === 'LineString') { feature.geometry.coordinates.splice(vertexIndex, 1) } if (feature.geometry.type === 'Polygon') { feature.geometry.coordinates[0].splice(vertexIndex, 1) } - renderGeoJSONLayers(true) + renderLayers('geojson', true) mapChannel.send_message('update_feature', { ...feature }) status('Point deleted') hideContextMenu() @@ -27,7 +26,7 @@ export default class extends Controller { cutLine(event) { const target = event.currentTarget const feature = getFeature(target.dataset.featureId, 'geojson') - + const vertexIndex = parseInt(target.dataset.index, 10) const coords = feature.geometry.coordinates const firstCoords = coords.slice(0, vertexIndex + 1) @@ -36,7 +35,7 @@ export default class extends Controller { // Keep original feature, shorten it to the first segment addUndoState('Feature update', feature) feature.geometry.coordinates = firstCoords - renderGeoJSONLayers(true) + renderLayers('geojson', true) mapChannel.send_message('update_feature', { ...feature }) const secondFeature = { @@ -52,4 +51,12 @@ export default class extends Controller { status('Line cut into 2 segments') hideContextMenu() } + + addToGeojsonLayer(event) { + const target = event.currentTarget + const feature = getFeature(target.dataset.featureId, 'basemap') + addFeature(feature) + addUndoState('Feature added', feature) + mapChannel.send_message('new_feature', feature) + } } \ No newline at end of file diff --git a/app/javascript/controllers/map/layers_controller.js b/app/javascript/controllers/map/layers_controller.js index a9adcb22..5cd7ccc5 100644 --- a/app/javascript/controllers/map/layers_controller.js +++ b/app/javascript/controllers/map/layers_controller.js @@ -6,9 +6,8 @@ import { status } from 'helpers/status' import { flyToFeature } from 'maplibre/animations' import { initLayersModal } from 'maplibre/controls/shared' import { confirmImageLocation, uploadImageToFeature } from 'maplibre/feature' -import { renderGeoJSONLayer } from 'maplibre/layers/geojson' -import { initializeLayerSources, initializeLayerStyles, layers, loadAllLayerData, loadLayerData } from 'maplibre/layers/layers' -import { initializeOverpassLayers } from 'maplibre/layers/overpass' +import { createLayerInstance } from 'maplibre/layers/factory' +import { initializeLayerSources, initializeLayerStyles, layers, loadAllLayerData, loadLayerData, renderLayer } from 'maplibre/layers/layers' import { queries } from 'maplibre/layers/queries' import { map, mapProperties, removeGeoJSONSource, setLayerVisibility, upsert } from 'maplibre/map' @@ -111,7 +110,7 @@ export default class extends Controller { uploadImageToFeature(file, feature).then( () => { upsert(feature) // redraw first geojson layer - renderGeoJSONLayer(layers.find(l => l.type === 'geojson').id) + renderLayer(layers.find(l => l.type === 'geojson').id) mapChannel.send_message('new_feature', { ...feature }) status('Added image') flyToFeature(feature) @@ -159,10 +158,9 @@ export default class extends Controller { layer["cluster"] = clustered layer["heatmap"] = layer.query.includes("heatmap=true") event.target.closest('.layer-item').querySelector('.layer-name').innerHTML = layer.name - const { geojson: _geojson, ...sendLayer } = layer - mapChannel.send_message('update_layer', sendLayer) + mapChannel.send_message('update_layer', layer.toJSON()) event.target.closest('.layer-item').querySelector('.reload-icon').classList.add('layer-refresh-animate') - initializeOverpassLayers(layerId) + layer.initialize().then(() => { initLayersModal() }) } refreshLayer (event) { @@ -170,11 +168,9 @@ export default class extends Controller { const layerId = event.target.closest('.layer-item').getAttribute('data-layer-id') functions.e('#layer-reload', e => { e.classList.add('hidden') }) functions.e('#layer-loading', e => { e.classList.remove('hidden') }) - event.target.closest('.layer-item').querySelector('.reload-icon').classList.add('layer-refresh-animate') loadLayerData(layerId).then( () => { initLayersModal() functions.e('#layer-loading', e => { e.classList.add('hidden') }) - functions.e(`#layer-list-${layerId} .reload-icon`, e => { e.classList.remove('layer-refresh-animate') }) }) } @@ -209,7 +205,7 @@ export default class extends Controller { const wasVisible = layer.show !== false layer.show = !wasVisible - setLayerVisibility(layer.type + '-source-' + layerId, layer.show) + setLayerVisibility(layer.sourceId, layer.show) // update UI (both desktop and mobile visibility buttons) layerElement.querySelectorAll('button.layer-visibility i, button.layer-visibility-mobile i').forEach(icon => { @@ -240,13 +236,12 @@ export default class extends Controller { // sync to server only in rw mode if (window.gon.map_mode === "rw") { - const { geojson: _geojson, ...sendLayer } = layer - mapChannel.send_message('update_layer', sendLayer) + mapChannel.send_message('update_layer', layer.toJSON()) } } createWikipediaLayer() { - this.createLayer('wikipedia', 'Wikipedia', '') + this.createLayer('wikipedia', 'Wikipedia') } createSelectedOverpassLayer(event) { @@ -263,25 +258,29 @@ export default class extends Controller { } } - createLayer(type, name, query) { + createBaseMapLayer(_event) { + this.createLayer('basemap', 'Basemap layer') + } + + createLayer(type, name, query=null) { 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, "show": true} + let layerData = { "id": layerId, "type": type, "name": name, "heatmap": false, "cluster": true, "show": true} if (type == 'overpass') { - layer["query"] = query + layerData["query"] = query // TODO: move cluster + heatmap to layer checkboxes - const clustered = !layer.query.includes("heatmap=true") && - !layer.query.includes("cluster=false") && - !layer.query.includes("geom") // clustering breaks lines & geometries - layer["cluster"] = clustered - layer["heatmap"] = layer.query.includes("heatmap=true") + const clustered = !layerData.query.includes("heatmap=true") && + !layerData.query.includes("cluster=false") && + !layerData.query.includes("geom") // clustering breaks lines & geometries + layerData["cluster"] = clustered + layerData["heatmap"] = layerData.query.includes("heatmap=true") } + let layer = createLayerInstance(layerData) layers.push(layer) + initLayersModal() initializeLayerSources(layerId) initializeLayerStyles(layerId) - mapChannel.send_message('new_layer', layer) - initLayersModal() - document.querySelector('#layer-list-' + layerId + ' .reload-icon').classList.add('layer-refresh-animate') + mapChannel.send_message('new_layer', layerData) return layerId } @@ -291,12 +290,11 @@ export default class extends Controller { dom.closeTooltips() const layerElement = event.target.closest('.layer-item') const layerId = layerElement.getAttribute('data-layer-id') - const layerType = layerElement.getAttribute('data-layer-type') const layer = layers.find(f => f.id === layerId) - const { geojson: _geojson, ...sendLayer } = layer + layer.cleanup() layers.splice(layers.indexOf(layer), 1) - removeGeoJSONSource(layerType + '-source-' + layerId) - mapChannel.send_message('delete_layer', sendLayer) + removeGeoJSONSource(layer.sourceId) + mapChannel.send_message('delete_layer', layer.toJSON()) initLayersModal() } } diff --git a/app/javascript/controllers/map_controller.js b/app/javascript/controllers/map_controller.js index c715649f..29e386bb 100644 --- a/app/javascript/controllers/map_controller.js +++ b/app/javascript/controllers/map_controller.js @@ -1,13 +1,21 @@ import { Controller } from '@hotwired/stimulus' import * as functions from 'helpers/functions' -import { initializeMap, setBackgroundMapLayer, initializeViewMode, +import { initializeMap, setBackgroundMapLayer, initializeViewMode, initializeStaticMode, addFeature } from 'maplibre/map' import { initializeEditMode } from 'maplibre/edit' import { initializeSocket, mapChannel } from 'channels/map_channel' -import { addUndoState } from 'maplibre/undo' +import { addUndoState, clearUndoHistory } from 'maplibre/undo' +import { resetInitializationState } from 'maplibre/layers/layers' +import { clearImageState, resetLabelFont } from 'maplibre/styles/styles' export default class extends Controller { async connect () { + // Clear module-level state from previous map + resetInitializationState() + clearImageState() + clearUndoHistory() + resetLabelFont() + functions.e('#map-header nav', e => { e.style.display = 'none' }) await initializeMap('maplibre-map') // static mode is used for screenshots @@ -20,6 +28,27 @@ export default class extends Controller { setBackgroundMapLayer() } + disconnect() { + // Clean up when navigating away from the map + console.log('Map controller disconnecting, cleaning up...') + + // Remove the map instance + if (window.map) { + try { + window.map.remove() + window.map = null + } catch (e) { + console.warn('Error removing map instance:', e) + } + } + + // Clear module-level state + resetInitializationState() + clearImageState() + clearUndoHistory() + resetLabelFont() + } + // paste feature from clipboard async paste(_event) { diff --git a/app/javascript/maplibre/animations.js b/app/javascript/maplibre/animations.js index bd5cee2a..08c8aade 100644 --- a/app/javascript/maplibre/animations.js +++ b/app/javascript/maplibre/animations.js @@ -7,8 +7,7 @@ import * as functions from 'helpers/functions' import { status } from 'helpers/status' import { resetControls } from 'maplibre/controls/shared' import { highlightFeature } from 'maplibre/feature' -import { renderGeoJSONLayers } from 'maplibre/layers/geojson' -import { getFeatureSource } from 'maplibre/layers/layers' +import { getFeatureSource, renderLayers } from 'maplibre/layers/layers' import { map, mapProperties } from 'maplibre/map' export class AnimationManager { @@ -51,7 +50,7 @@ export class AnimatePointAnimation extends AnimationManager { start[1] + (end[1] - start[1]) * progress ] feature.geometry.coordinates = newCoordinates - renderGeoJSONLayers(false) + renderLayers('geojson', false) if (progress < 1) { this.animationId = requestAnimationFrame(animate) } } this.animationId = requestAnimationFrame(animate) @@ -94,7 +93,7 @@ export class AnimateLineAnimation extends AnimationManager { // console.log("Frame #" + _frame + ", distance: " + distance + ", coord: " + coordinate) line.geometry.coordinates.push(coordinate) - renderGeoJSONLayers(false) + renderLayers('geojson', false) // Update camera position if (follow) { map.jumpTo({ center: coordinate }) } @@ -125,7 +124,7 @@ export class AnimatePolygonAnimation extends AnimationManager { const progress = counter / steps polygon.properties['fill-extrusion-height'] = progress * height // console.log('New height: ' + polygon.properties['fill-extrusion-height']) - renderGeoJSONLayers(false) + renderLayers('geojson', false) counter++ @@ -137,7 +136,7 @@ export class AnimatePolygonAnimation extends AnimationManager { } polygon.properties['fill-extrusion-height'] = 0 - renderGeoJSONLayers(true) + renderLayers('geojson', true) this.animationId = requestAnimationFrame(animate) } } diff --git a/app/javascript/maplibre/edit.js b/app/javascript/maplibre/edit.js index d19b00ed..f22161e1 100644 --- a/app/javascript/maplibre/edit.js +++ b/app/javascript/maplibre/edit.js @@ -6,8 +6,7 @@ import { status } from 'helpers/status'; import { disableEditControls, enableEditControls, initializeEditControls } from 'maplibre/controls/edit'; import { initializeDefaultControls, resetControls } from 'maplibre/controls/shared'; import { highlightFeature } from 'maplibre/feature'; -import { renderGeoJSONLayers } from 'maplibre/layers/geojson'; -import { getFeature, hasFeatures, layers } from 'maplibre/layers/layers'; +import { getFeature, hasFeatures, initializeLayers, layers, renderLayers } from 'maplibre/layers/layers'; import { addFeature, destroyFeature, map, mapProperties } from 'maplibre/map'; import { getRouteElevation, getRouteUpdate } from 'maplibre/routing/openrouteservice'; import { initDirections, resetDirections } from 'maplibre/routing/osrm'; @@ -75,15 +74,14 @@ export async function initializeEditMode () { initializeEditControls() initializeDefaultControls() - // Show map settings modal on untouched map + // Show map settings modal on untouched map and handle URL feature selection map.once('load', async function (_e) { - if (!layers) { await functions.waitForEvent(map, 'layers.load') } + // Safe to call even if already triggered by style.load — returns the cached promise, no double loading + await initializeLayers() if (!mapProperties.name && !hasFeatures('geojson') && !layers?.filter(l => l.type !== 'geojson').length) { functions.e('.maplibregl-ctrl-map', e => { e.click() }) } - }) - map.on('geojson.load', function (_e) { const urlFeatureId = new URLSearchParams(window.location.search).get('f') const feature = getFeature(urlFeatureId, 'geojson') if (feature) { map.fire('draw.selectionchange', {features: [feature]}) } @@ -256,7 +254,7 @@ function handleCreate (e) { addUndoState('Feature added', feature) // redraw if the painted feature was changed in this method if (mode === 'directions_car' || mode === 'directions_bike' || mode === 'directions_foot' || mode === 'draw_paint_mode') { - renderGeoJSONLayers(false) + renderLayers('geojson', false) } mapChannel.send_message('new_feature', feature) if (feature.geometry.type === 'LineString') { updateElevation(feature) } @@ -297,7 +295,7 @@ async function handleUpdate (e) { status('Feature ' + feature.id + ' changed') geojsonFeature.geometry = feature.geometry - renderGeoJSONLayers(false) + renderLayers('geojson', false) if (feature.geometry.type === 'LineString') { // gets also triggered on failure diff --git a/app/javascript/maplibre/layers/basemap.js b/app/javascript/maplibre/layers/basemap.js index 08bf73a6..93508ee5 100644 --- a/app/javascript/maplibre/layers/basemap.js +++ b/app/javascript/maplibre/layers/basemap.js @@ -1,59 +1,120 @@ -import * as functions from 'helpers/functions'; +import * as functions from 'helpers/functions' +import { hideContextMenu } from 'maplibre/controls/context_menu' import { highlightedFeatureId, stickyFeatureHighlight -} from 'maplibre/feature'; -import { layers } from 'maplibre/layers/layers'; -import { overpassDescription } from 'maplibre/layers/overpass'; -import { addGeoJSONSource, map, mapProperties } from 'maplibre/map'; -import { basemaps } from 'maplibre/styles/basemaps'; -import { initializeViewStyles } from 'maplibre/styles/styles'; - -export function initializeBaseMapLayers() { - if (!basemaps()[mapProperties.base_map].sourceName) { return } - - let layerId = functions.featureId() - // must match server attribute order, for proper comparison in map_channel - let layer = { "id": layerId, "type": 'basemap', "name": 'basemap', geojson: { type: 'FeatureCollection', features: [] } } - layers.push(layer) - - const basemapSource = basemaps()[mapProperties.base_map].sourceName - const highlightSource = "basemap_" + basemapSource + "_highlight" - const mapLayers = map.getStyle().layers - - addGeoJSONSource(highlightSource) - initializeViewStyles(highlightSource) - - map.on('mousemove', (e) => { - if (stickyFeatureHighlight && highlightedFeatureId) { return } - if (document.querySelector('.show > .map-modal')) { return } - - const queryLayerIds = mapLayers.filter(layer => layer.source === basemapSource).map(layer => layer.id) - const features = map.queryRenderedFeatures(e.point, { layers: queryLayerIds}) - - if (features.length) { - //console.log('Features hovered', features) - - const feature = features[0] - - // ✅ Geometry ist bereits in WGS84 (lng/lat) - const geojsonFeature = { - type: 'Feature', - geometry: feature.geometry, - properties: feature.properties +} from 'maplibre/feature' +import { Layer } from 'maplibre/layers/layer' +import { overpassDescription } from 'maplibre/layers/overpass' +import { addGeoJSONSource, map, mapProperties } from 'maplibre/map' +import { basemaps } from 'maplibre/styles/basemaps' +import { initializeViewStyles } from 'maplibre/styles/styles' + +export class BasemapLayer extends Layer { + constructor(layer) { + super(layer) + this.contextMenuHandler = null + } + + createSource() { + addGeoJSONSource(this.sourceId) + } + + /** + * Initialize basemap layer for feature highlighting on hover. + * Note: Basemap layer is special - it manually calls createSource() because it's not + * in the standard layers array during normal initialization flow. + */ + initialize() { + if (!basemaps()[mapProperties.base_map].sourceName) { return Promise.resolve() } + + this.createSource() + initializeViewStyles(this.sourceId) + this.setupEventHandlers() + + return Promise.resolve() + } + + /** + * Override to disable click handler and provide custom mousemove for basemap layers. + */ + setupEventHandlers() { + this.removeEventHandlers() + this.setupClickHandler() + this.setupMouseMoveHandler() + + this.contextMenuHandler = (e) => { + e.preventDefault() + + const basemapSource = this.sourceId + const mapLayers = map.getStyle().layers + const queryLayerIds = mapLayers.filter(layer => layer.source === basemapSource).map(layer => layer.id) + const features = map.queryRenderedFeatures(e.point, { layers: queryLayerIds }) + + if (features.length) { + functions.e('#map-context-menu', el => { + el.classList.remove('hidden') + + const copyButton = document.createElement('div') + copyButton.classList.add('context-menu-item') + copyButton.innerText = 'Copy to my layer' + copyButton.dataset.action = 'click->map--context-menu#addToGeojsonLayer' + copyButton.dataset.featureId = features[0].id + el.appendChild(copyButton) + }) } - geojsonFeature.id = geojsonFeature.properties.id = functions.featureId() - geojsonFeature.properties.desc = overpassDescription(geojsonFeature.properties) - - console.log('GeoJSON:', geojsonFeature) - - layer.geojson.features = [geojsonFeature] - map.getSource(highlightSource).setData(layer.geojson, false) } + map.on('contextmenu', this.contextMenuHandler) + } + + /** + * Removes event handlers including custom contextmenu handler. + */ + removeEventHandlers() { + super.removeEventHandlers() + if (this.contextMenuHandler) { + map.off('contextmenu', this.contextMenuHandler) + this.contextMenuHandler = null + } + } + + /** + * Custom mousemove handler for basemap layer - queries basemap source layers. + */ + setupMouseMoveHandler() { + this.mouseMoveHandler = (e) => { + if (stickyFeatureHighlight && highlightedFeatureId) { return } + if (document.querySelector('.show > .map-modal')) { return } + + const basemapSource = basemaps()[mapProperties.base_map].sourceName + const mapLayers = map.getStyle().layers + const queryLayerIds = mapLayers.filter(layer => layer.source === basemapSource).map(layer => layer.id) + const features = map.queryRenderedFeatures(e.point, { layers: queryLayerIds}) + + if (features.length) { + const feature = features[0] + + // exit early when moving over same feature + if (JSON.stringify(feature.geometry) === JSON.stringify(this?.selectedFeature?.geometry)) { return } + this.selectedFeature = feature + hideContextMenu() + + console.log('Selected features: ', features) + + const geojsonFeature = { + type: 'Feature', + geometry: feature.geometry, + properties: feature.properties + } + geojsonFeature.id = geojsonFeature.properties.id = functions.featureId() + geojsonFeature.properties.desc = overpassDescription(geojsonFeature.properties) + + this.layer.geojson.features = [geojsonFeature] + map.getSource(this.sourceId).setData(this.layer.geojson, false) + } + } - - - }) - -} \ No newline at end of file + map.on('mousemove', this.mouseMoveHandler) + } +} diff --git a/app/javascript/maplibre/layers/factory.js b/app/javascript/maplibre/layers/factory.js new file mode 100644 index 00000000..89f005ec --- /dev/null +++ b/app/javascript/maplibre/layers/factory.js @@ -0,0 +1,17 @@ +import { BasemapLayer } from 'maplibre/layers/basemap' +import { GeoJSONLayer } from 'maplibre/layers/geojson' +import { Layer } from 'maplibre/layers/layer' +import { OverpassLayer } from 'maplibre/layers/overpass' +import { WikipediaLayer } from 'maplibre/layers/wikipedia' + +const layerTypes = { + geojson: GeoJSONLayer, + overpass: OverpassLayer, + wikipedia: WikipediaLayer, + basemap: BasemapLayer +} + +export function createLayerInstance(data) { + const LayerClass = layerTypes[data.type] || Layer + return new LayerClass(data) +} diff --git a/app/javascript/maplibre/layers/geojson.js b/app/javascript/maplibre/layers/geojson.js index d3c30b1d..adc5a9c7 100644 --- a/app/javascript/maplibre/layers/geojson.js +++ b/app/javascript/maplibre/layers/geojson.js @@ -3,52 +3,125 @@ import { buffer } from "@turf/buffer" 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, removeStyleLayers } from 'maplibre/map' +import { getFeature } from 'maplibre/layers/layers' +import { Layer } from 'maplibre/layers/layer' +import { addGeoJSONSource, 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' && l.show !== false) - if (id) { initLayers = initLayers.filter(l => l.id === id) } +export class GeoJSONLayer extends Layer { + get kmMarkerSourceId() { + return `km-marker-source-${this.id}` + } - initLayers.forEach((layer) => { - initializeViewStyles('geojson-source-' + layer.id, !!layer.heatmap) - if (!!layer.cluster) { initializeClusterStyles('geojson-source-' + layer.id, null) } + createSource() { + super.createSource() + addGeoJSONSource(this.kmMarkerSourceId, false) + } - initializeKmMarkerStyles(layer.id) - renderGeoJSONLayer(layer.id) - }) + initialize() { + initializeViewStyles(this.sourceId, !!this.layer.heatmap) + if (this.layer.cluster) { initializeClusterStyles(this.sourceId, null) } + this.initializeKmMarkerStyles() + this.setupEventHandlers() + this.render() + return Promise.resolve() + } - map.fire('geojson.load', { detail: { message: 'geojson source + styles loaded' } }) -} + render(resetDraw = true) { + console.log("Redraw: Setting source data for geojson layer", this.layer) + this.ensureFeaturePropertyIds() + this.renderKmMarkers() + const extrusionLines = this.renderExtrusionLines() + const geojson = { type: 'FeatureCollection', features: this.layer.geojson.features.concat(extrusionLines) } + map.getSource(this.sourceId).setData(geojson, false) + this.resetDrawFeatures(resetDraw) + } -export function renderGeoJSONLayers(resetDraw = true) { - layers.filter(l => l.type === 'geojson').forEach((layer) => { - renderGeoJSONLayer(layer.id, resetDraw) - }) -} + renderKmMarkers() { + let kmMarkerFeatures = [] + this.layer.geojson.features.filter(feature => (feature.geometry.type === 'LineString' && + feature.properties['show-km-markers'] && + feature.geometry.coordinates.length >= 2)).forEach((f, index) => { + + const line = lineString(f.geometry.coordinates) + const distance = length(line, { units: 'kilometers' }) + let interval = 1 + for (let i = 0; i < Math.ceil(distance) + interval; i += interval) { + const point = along(line, i, { units: 'kilometers' }) + point.properties['marker-color'] = f.properties['stroke'] || featureColor + point.properties['marker-size'] = 11 + point.properties['marker-opacity'] = 1 + point.properties['km'] = i + + if (i >= Math.ceil(distance)) { + point.properties['marker-size'] = 14 + point.properties['km'] = Math.round(distance) + if (Math.ceil(distance) < 100) { + point.properties['km'] = Math.round(distance * 10) / 10 + } + point.properties['km-marker-numbers-end'] = 1 + point.properties['sort-key'] = 2 + index + } + kmMarkerFeatures.push(point) + } + }) + + const markerFeatures = { type: 'FeatureCollection', features: kmMarkerFeatures } + map.getSource(this.kmMarkerSourceId).setData(markerFeatures) + } + + initializeKmMarkerStyles() { + removeStyleLayers(this.kmMarkerSourceId) + this.kmMarkerStyles().forEach(style => { + style = setSource(style, this.kmMarkerSourceId) + map.addLayer(style) + }) + } + + kmMarkerStyles() { + let styleLayers = [] + + styleLayers.push(makePointsLayer(2, 11)) + styleLayers.push(makeNumbersLayer(2, 11)) + styleLayers.push(makePointsLayer(5, 10, 11)) + styleLayers.push(makeNumbersLayer(5, 10, 11)) + styleLayers.push(makePointsLayer(10, 9, 10)) + styleLayers.push(makeNumbersLayer(10, 9, 10)) + styleLayers.push(makePointsLayer(25, 8, 9)) + styleLayers.push(makeNumbersLayer(25, 8, 9)) + styleLayers.push(makePointsLayer(50, 7, 8)) + styleLayers.push(makeNumbersLayer(50, 7, 8)) + styleLayers.push(makePointsLayer(100, 5, 7)) + styleLayers.push(makeNumbersLayer(100, 5, 7)) + + const base = { ...styles()['points-layer'] } + styleLayers.push({ + ...base, + id: `km-marker-points-end`, + filter: ["==", ["get", "km-marker-numbers-end"], 1] + }) + styleLayers.push({ + id: `km-marker-numbers-end`, + type: 'symbol', + filter: ["==", ["get", "km-marker-numbers-end"], 1], + layout: { + 'text-allow-overlap': true, + 'text-field': ['get', 'km'], + 'text-size': 12, + 'text-font': labelFont, + 'text-justify': 'center', + 'text-anchor': 'center' + }, + paint: { + 'text-color': '#ffffff' + } + }) -export function renderGeoJSONLayer(id, resetDraw = true) { - let layer = layers.find(l => l.id === id) - console.log("Redraw: Setting source data for geojson layer", layer) - - // this + `promoteId: 'id'` is a workaround for the maplibre limitation: - // https://github.com/mapbox/mapbox-gl-js/issues/2716 - // because to highlight a feature we need the id, - // and in the style layers it only accepts mumeric ids in the id field initially - // TODO: only needed once, not each render - layer.geojson.features.forEach((feature) => { feature.properties.id = feature.id }) - renderKmMarkersLayer(id) - // - For LineStrings with a 'fill-extrusion-height', add a polygon to render extrusion - let extrusionLines = renderExtrusionLines() - let geojson = { type: 'FeatureCollection', features: layer.geojson.features.concat(extrusionLines) } - - map.getSource(layer.type + '-source-' + layer.id).setData(geojson, false) - - // draw has its own style layers based on editStyles - if (draw) { - if (resetDraw) { + return styleLayers + } + + resetDrawFeatures(resetDraw) { + if (draw && resetDraw) { // This has a performance drawback over draw.set(), but some feature // properties don't get updated otherwise // API: https://github.com/mapbox/mapbox-gl-draw/blob/main/docs/API.md @@ -59,52 +132,34 @@ export function renderGeoJSONLayer(id, resetDraw = true) { let feature = getFeature(featureId, "geojson") if (feature) { draw.add(feature) - // if we're in edit mode, re-select feature select(feature) } }) } } -} - -export function renderKmMarkersLayer(id) { - let layer = layers.find(l => l.id === id) - - let kmMarkerFeatures = [] - layer.geojson.features.filter(feature => (feature.geometry.type === 'LineString' && - feature.properties['show-km-markers'] && - feature.geometry.coordinates.length >= 2)).forEach((f, index) => { - - const line = lineString(f.geometry.coordinates) - const distance = length(line, { units: 'kilometers' }) - // Create markers at useful intervals - let interval = 1 - for (let i = 0; i < Math.ceil(distance) + interval; i += interval) { - // Get point at current kilometer - const point = along(line, i, { units: 'kilometers' }) - point.properties['marker-color'] = f.properties['stroke'] || featureColor - point.properties['marker-size'] = 11 - point.properties['marker-opacity'] = 1 - point.properties['km'] = i - - if (i >= Math.ceil(distance)) { - point.properties['marker-size'] = 14 - point.properties['km'] = Math.round(distance) - if (Math.ceil(distance) < 100) { - point.properties['km'] = Math.round(distance * 10) / 10 - } - point.properties['km-marker-numbers-end'] = 1 - point.properties['sort-key'] = 2 + index - } - kmMarkerFeatures.push(point) - } - }) - let markerFeatures = { - type: 'FeatureCollection', - features: kmMarkerFeatures + renderExtrusionLines() { + if (mapProperties.terrain) { return [] } + + let extrusionLines = this.layer.geojson.features.filter(feature => ( + feature.geometry.type === 'LineString' && + feature.properties['fill-extrusion-height'] && + feature.geometry.coordinates.length !== 1 + )) + + return extrusionLines.map(feature => { + const width = feature.properties['fill-extrusion-width'] || feature.properties['stroke-width'] || defaultLineWidth + const extrusionLine = buffer(feature, width, { units: 'meters' }) + extrusionLine.properties = { ...feature.properties } + if (!extrusionLine.properties['fill-extrusion-color'] && feature.properties.stroke) { + extrusionLine.properties['fill-extrusion-color'] = feature.properties.stroke } - map.getSource('km-marker-source-' + id).setData(markerFeatures) + extrusionLine.properties['stroke-width'] = 0 + extrusionLine.properties['stroke-opacity'] = 0 + extrusionLine.properties['fill-opacity'] = 0 + return extrusionLine + }) + } } function makePointsLayer(divisor, minzoom, maxzoom = 24) { @@ -139,84 +194,3 @@ function makeNumbersLayer(divisor, minzoom, maxzoom=24) { } } -export function kmMarkerStyles (_id) { - let layers = [] - const base = { ...styles()['points-layer'] } - - layers.push(makePointsLayer(2, 11)) - layers.push(makeNumbersLayer(2, 11)) - - layers.push(makePointsLayer(5, 10, 11)) - layers.push(makeNumbersLayer(5, 10, 11)) - - layers.push(makePointsLayer(10, 9, 10)) - layers.push(makeNumbersLayer(10, 9, 10)) - - layers.push(makePointsLayer(25, 8, 9)) - layers.push(makeNumbersLayer(25, 8, 9)) - - layers.push(makePointsLayer(50, 7, 8)) - layers.push(makeNumbersLayer(50, 7, 8)) - - layers.push(makePointsLayer(100, 5, 7)) - layers.push(makeNumbersLayer(100, 5, 7)) - - // end point has different style - layers.push({ - ...base, - id: `km-marker-points-end`, - filter: ["==", ["get", "km-marker-numbers-end"], 1] - }) - layers.push({ - id: `km-marker-numbers-end`, - type: 'symbol', - filter: ["==", ["get", "km-marker-numbers-end"], 1], - layout: { - 'text-allow-overlap': true, - 'text-field': ['get', 'km'], - 'text-size': 12, - 'text-font': labelFont, - 'text-justify': 'center', - 'text-anchor': 'center' - }, - paint: { - 'text-color': '#ffffff' - } - }) - - return layers -} - -export function initializeKmMarkerStyles(id) { - removeStyleLayers('km-marker-source-' + id) - kmMarkerStyles(id).forEach(style => { - style = setSource (style, 'km-marker-source-' + id) - map.addLayer(style) - }) -} - -function renderExtrusionLines() { - // Disable extrusionlines on 3D terrain, it does not work - if (mapProperties.terrain) { return [] } - - let extrusionLines = getFeatures('geojson').filter(feature => ( - feature.geometry.type === 'LineString' && - feature.properties['fill-extrusion-height'] && - feature.geometry.coordinates.length !== 1 // don't break line animation - )) - - extrusionLines = extrusionLines.map(feature => { - const width = feature.properties['fill-extrusion-width'] || feature.properties['stroke-width'] || defaultLineWidth - const extrusionLine = buffer(feature, width, { units: 'meters' }) - // clone properties hash, else we're writing into the original feature's properties - extrusionLine.properties = { ...feature.properties } - if (!extrusionLine.properties['fill-extrusion-color'] && feature.properties.stroke) { - extrusionLine.properties['fill-extrusion-color'] = feature.properties.stroke - } - extrusionLine.properties['stroke-width'] = 0 - extrusionLine.properties['stroke-opacity'] = 0 - extrusionLine.properties['fill-opacity'] = 0 - return extrusionLine - }) - return extrusionLines -} \ No newline at end of file diff --git a/app/javascript/maplibre/layers/layer.js b/app/javascript/maplibre/layers/layer.js new file mode 100644 index 00000000..31200b28 --- /dev/null +++ b/app/javascript/maplibre/layers/layer.js @@ -0,0 +1,267 @@ +import * as functions from 'helpers/functions' +import { flyToFeature } from 'maplibre/animations' +import { draw } from 'maplibre/edit' +import { + highlightFeature, + highlightedFeatureId, + highlightedFeatureSource, + resetHighlightedFeature, + stickyFeatureHighlight +} from 'maplibre/feature' +import { getFeature } from 'maplibre/layers/layers' +import { addGeoJSONSource, frontFeature, map } from 'maplibre/map' + +/** + * Base class for map layers. Subclass to create new layer types. + * + * Required overrides: + * - initialize(): Promise - Apply styles and load data. Return loadData() promise or Promise.resolve() + * - loadData(): Promise - Fetch data, set this.layer.geojson, call this.render() + * + * Optional overrides: + * - createSource(): void - Override if you need custom source setup beyond standard GeoJSON source + * - render(resetDraw): void - Override for custom rendering logic (e.g., km markers, extrusion lines) + * - get sourceId(): string - Override for custom source naming convention + * - setupEventHandlers(): void - Override to customize click/mousemove behavior or disable handlers + * - cleanup(): void - Override to add custom cleanup, but call super.cleanup() + */ +export class Layer { + constructor(layer) { + this.layer = layer + this.clickHandler = null + this.mouseMoveHandler = null + } + + get id() { + return this.layer.id + } + + get type() { + return this.layer.type + } + + get name() { + return this.layer.name + } + + set name(value) { + this.layer.name = value + } + + get query() { + return this.layer.query + } + + set query(value) { + this.layer.query = value + } + + get show() { + return this.layer.show + } + + set show(value) { + this.layer.show = value + } + + get cluster() { + return this.layer.cluster + } + + set cluster(value) { + this.layer.cluster = value + } + + get heatmap() { + return this.layer.heatmap + } + + set heatmap(value) { + this.layer.heatmap = value + } + + get geojson() { + // Initialize geojson defensively to prevent undefined.features errors + if (!this.layer.geojson) { + this.layer.geojson = { type: 'FeatureCollection', features: [] } + } + return this.layer.geojson + } + + set geojson(value) { + this.layer.geojson = value + } + + get sourceId() { + return `${this.type}-source-${this.id}` + } + + /** + * Creates the MapLibre source for this layer. + * Called once during initialization; visibility toggles reuse the source. + */ + createSource() { + const cluster = !!this.layer.cluster && !this.layer.heatmap + addGeoJSONSource(this.sourceId, cluster) + } + + /** + * Applies styles and loads data for this layer. + * May be called multiple times (e.g., when toggling visibility). + * @returns {Promise} Promise that resolves when layer is ready + */ + initialize() { + return Promise.resolve() + } + + /** + * Fetches data for this layer and renders it. + * @returns {Promise} Promise that resolves when data is loaded + */ + loadData() { + return Promise.resolve(this.layer?.geojson) + } + + /** + * Renders layer data to the map. + * @param {boolean} [resetDraw=true] - Whether to reset draw features (for GeoJSON layers) + */ + render() { + this.ensureFeaturePropertyIds() + map.getSource(this.sourceId).setData(this.layer.geojson, false) + } + + ensureFeaturePropertyIds() { + this.layer?.geojson?.features?.forEach((feature) => { + feature.properties = feature.properties || {} + feature.properties.id = feature.id + }) + } + + /** + * Sets up event handlers for feature interaction (click, hover). + * Called during layer initialization. Override to customize or disable handlers. + */ + setupEventHandlers() { + this.removeEventHandlers() + this.setupClickHandler() + this.setupMouseMoveHandler() + } + + /** + * Sets up click handler for feature selection and onclick actions. + * Override to customize click behavior. + */ + setupClickHandler() { + this.clickHandler = (e) => { + if (draw && draw.getMode() !== 'simple_select') { return } + if (window.gon.map_mode === 'static') { return } + + console.log('Features clicked', e.features) + let feature = e.features.find(f => !f.properties?.cluster) + if (!feature) { return } + + if (window.gon.map_mode === 'ro' || e.originalEvent.shiftKey) { + feature = e.features.find(f => f.properties?.onclick !== false) + if (!feature) { return } + + if (feature.properties?.onclick === 'link' && feature.properties?.['onclick-target']) { + window.location.href = feature.properties?.['onclick-target'] + return + } + if (feature.properties?.onclick === 'feature' && feature.properties?.['onclick-target']) { + const targetId = feature.properties?.['onclick-target'] + const targetFeature = getFeature(targetId) + if (targetFeature) { + flyToFeature(targetFeature) + } else { + console.error('Target feature with id ' + targetId + ' not found') + } + return + } + } + frontFeature(feature) + highlightFeature(feature, true, this.sourceId) + } + + map.on('click', this.getStyleLayerIds(), this.clickHandler) + } + + /** + * Sets up mousemove handler for hover highlighting (read-only mode only). + * Override to customize hover behavior. + */ + setupMouseMoveHandler() { + if (window.gon.map_mode === 'ro' && !functions.isTouchDevice()) { + this.mouseMoveHandler = (e) => { + if (stickyFeatureHighlight && highlightedFeatureId) { return } + if (document.querySelector('.show > .map-modal')) { return } + if (!map.getSource(this.sourceId)) { return } + + const features = map.queryRenderedFeatures(e.point, { layers: this.getStyleLayerIds() }) + let feature = features.find(f => !f.properties?.cluster && f.properties?.onclick !== false) + + if (feature?.id) { + if (feature.id === highlightedFeatureId) { return } + frontFeature(feature) + highlightFeature(feature, false, this.sourceId) + } else if (highlightedFeatureSource === this.sourceId) { + resetHighlightedFeature() + } + } + + map.on('mousemove', this.mouseMoveHandler) + } + } + + /** + * Returns array of MapLibre layer IDs for this source. + * Override if you have custom layer naming. + */ + getStyleLayerIds() { + // Get all layer IDs that belong to this source + const sourceSuffix = '_' + this.sourceId + return map.getStyle().layers + .filter(layer => layer.id.endsWith(sourceSuffix)) + .map(layer => layer.id) + } + + /** + * Removes event handlers for this layer. + * Called before re-initialization and during cleanup. + */ + removeEventHandlers() { + if (this.clickHandler) { + map.off('click', this.getStyleLayerIds(), this.clickHandler) + this.clickHandler = null + } + if (this.mouseMoveHandler) { + map.off('mousemove', this.mouseMoveHandler) + this.mouseMoveHandler = null + } + } + + /** + * Cleans up layer resources (event handlers, etc.). + * Override to add custom cleanup, but call super.cleanup(). + */ + cleanup() { + this.removeEventHandlers() + } + + // overwrite all layer properties with data, + // keep geojson if data does not include it + update(data) { + const geojson = this.layer.geojson + Object.assign(this.layer, data) + if (geojson && !data.geojson) { + this.layer.geojson = geojson + } + } + + // JSON form of layer without geojson data to compare + toJSON() { + const { geojson: _geojson, ...rest } = this.layer + return rest + } +} diff --git a/app/javascript/maplibre/layers/layers.js b/app/javascript/maplibre/layers/layers.js index d7e32b9c..0aa64f82 100644 --- a/app/javascript/maplibre/layers/layers.js +++ b/app/javascript/maplibre/layers/layers.js @@ -1,13 +1,52 @@ import * as functions from 'helpers/functions' -import { initializeGeoJSONLayers } from 'maplibre/layers/geojson' -import { initializeOverpassLayers, loadOverpassLayer } from 'maplibre/layers/overpass' -import { initializeWikipediaLayers, loadWikipediaLayer } from 'maplibre/layers/wikipedia' -import { addGeoJSONSource, map, sortLayers } from 'maplibre/map' +import { createLayerInstance } from 'maplibre/layers/factory' +import { map, sortLayers } from 'maplibre/map' -export let layers // [{ id:, type: "overpass"||"geojson", name:, query:, geojson: { type: 'FeatureCollection', features: [] } }] -window._layers = layers +export let layers // Layer instances: GeoJSONLayer, OverpassLayer, WikipediaLayer, BasemapLayer -// Loads initial layer definitions from server +// Cached promise to ensure initializeLayers only runs once +let initializePromise = null + +/** + * Resets the initialization state when navigating to a new map. + * This allows layers to be re-initialized from scratch. + */ +export function resetInitializationState() { + initializePromise = null + layers = null +} + +/** + * Loads layer definitions from server and initializes them. + * Combines loadLayerDefinitions(), initializeLayerSources(), and initializeLayerStyles() + * into a single async operation. Returns Promise that resolves when all visible layers are ready. + * Idempotent - safe to call multiple times, will only load once. + */ +export async function initializeLayers() { + // Return cached promise if already initializing/initialized + if (initializePromise) { + return initializePromise + } + + // Cache the promise so multiple calls don't re-initialize + initializePromise = (async () => { + await loadLayerDefinitions() + initializeLayerSources() + await initializeLayerStyles() + + // Set test helper attribute for backward compatibility + functions.e('#maplibre-map', e => { e.setAttribute('data-geojson-loaded', true) }) + + return layers + })() + + return initializePromise +} + +/** + * Loads layer definitions from server. + * Prefer using initializeLayers() for full initialization. + */ export function loadLayerDefinitions() { layers = null const host = new URL(window.location.href).origin @@ -21,42 +60,53 @@ export function loadLayerDefinitions() { console.log('Loaded map layer definitions from server: ', data.layers) // make sure we're still showing the map the request came from if (window.gon.map_properties.public_id !== data.properties.public_id) { return } - layers = data.layers - map.fire('layers.load', { detail: { message: 'Map layer data loaded from server' } }) + layers = data.layers.map(l => createLayerInstance(l)) + window._layers = layers + map.fire('layers.load', { detail: { message: `Map data (${layers.length} layers) loaded from server` } }) }) .catch(error => { console.error('Failed to fetch map layers:', error) }) } -// initialize layers: create source +/** + * Creates MapLibre sources for layers. + * Sources are created for ALL layers (including hidden ones) because: + * 1. Source creation is cheap (synchronous, no data loading) + * 2. Enables efficient layer visibility toggling (reuse existing sources) + * 3. Avoids recreating sources when showing/hiding layers + * + * @param {string|null} id - Optional layer ID to initialize only that layer's source + */ export function initializeLayerSources(id = null) { let initLayers = layers if (id) { initLayers = initLayers.filter(l => l.id === id) } initLayers.forEach((layer) => { - // drop cluster when heatmap is set - const cluster = !!layer.cluster && !layer.heatmap - 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') { - addGeoJSONSource('km-marker-source-' + layer.id, false) - } + // console.log(`Adding source for ${layer.type} layer`, layer) + layer.createSource() }) } -// initialize layers: apply styles and load data +/** + * Applies styles and loads data for visible layers only. + * This is expensive (async API calls for Overpass/Wikipedia) so hidden layers are skipped. + * When toggling visibility, this is called without initializeLayerSources() to reuse sources. + * + * @param {string|null} id - Optional layer ID to initialize only that layer's styles + * @returns {Promise} Promise that resolves when all layer styles are loaded + */ export async function initializeLayerStyles(id = null) { functions.e('#layer-reload', e => { e.classList.add('hidden') }) functions.e('#layer-loading', e => { e.classList.remove('hidden') }) - initializeGeoJSONLayers(id) - // initializeBaseMapLayers() - let overpassPromises = initializeOverpassLayers(id) - let wikipediaPromises = initializeWikipediaLayers(id) + let initLayers = layers.filter(l => l.show !== false) + if (id) { initLayers = initLayers.filter(l => l.id === id) } - await Promise.all(overpassPromises.concat(wikipediaPromises)).then(_results => { + const promises = initLayers.map(layer => layer.initialize()) + + await Promise.all(promises).then(_results => { + map.fire('geojson.load', { detail: { message: 'geojson source + styles loaded' } }) // re-sort layers after style changes sortLayers() functions.e('#layer-loading', e => { e.classList.add('hidden') }) @@ -70,12 +120,14 @@ export function loadLayerData(id) { 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) - } else if (layer.type === 'overpass') { - return loadOverpassLayer(layer.id) - } + + functions.e(`#layer-list-${id} .reload-icon`, e => { e.classList.add('layer-refresh-animate') }) + functions.e('#layer-loading', e => { e.classList.remove('hidden') }) + + return layer.loadData().then((result) => { + functions.e(`#layer-list-${id} .reload-icon`, e => { e.classList.remove('layer-refresh-animate') }) + return result + }) } // triggered by layer reload in the UI @@ -105,7 +157,7 @@ export function hasFeatures(type = 'geojson') { export function getFeatureSource(featureId) { const layer = getLayer(featureId) if (layer) { - return layer.type + '-source-' + layer.id + return layer.sourceId } return null } @@ -118,4 +170,15 @@ export function getLayer(featureId) { } } return null -} \ No newline at end of file +} + +// Convenience functions for consumers + +export function renderLayer(id, ...args) { + const layer = layers.find(l => l.id === id) + if (layer) { layer.render(...args) } +} + +export function renderLayers(type, ...args) { + layers.filter(l => l.type === type).forEach(l => l.render(...args)) +} diff --git a/app/javascript/maplibre/layers/overpass.js b/app/javascript/maplibre/layers/overpass.js index b07bc816..1ef9013c 100644 --- a/app/javascript/maplibre/layers/overpass.js +++ b/app/javascript/maplibre/layers/overpass.js @@ -1,96 +1,128 @@ import * as functions from 'helpers/functions' import { status } from 'helpers/status' -import { initLayersModal } from 'maplibre/controls/shared' -import { layers } from 'maplibre/layers/layers' +import { Layer } from 'maplibre/layers/layer' import { applyOverpassQueryStyle } from 'maplibre/layers/queries' 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' && l.show !== false) - if (id) { initLayers = initLayers.filter(l => l.id === id) } - return initLayers.map((layer) => { - const clustered = !layer.query.includes("heatmap=true") && - !layer.query.includes("cluster=false") && - !layer.query.includes("geom") // clustering breaks lines & geometries - initializeViewStyles('overpass-source-' + layer.id, layer.heatmap) +export class OverpassLayer extends Layer { + initialize() { + if (!this.layer.query) { return Promise.resolve() } + + initializeViewStyles(this.sourceId, this.layer.heatmap) + const clustered = !this.layer.query.includes("heatmap=true") && + !this.layer.query.includes("cluster=false") && + !this.layer.query.includes("geom") // clustering breaks lines & geometries if (clustered) { - const clusterIcon = getCommentValue(layer.query, 'cluster-symbol') || getCommentValue(layer.query, 'cluster-image-url') || - getCommentValue(layer.query, 'marker-symbol') || getCommentValue(layer.query, 'marker-image-url') - initializeClusterStyles('overpass-source-' + layer.id, clusterIcon) + const clusterIcon = getCommentValue(this.layer.query, 'cluster-symbol') || getCommentValue(this.layer.query, 'cluster-image-url') || + getCommentValue(this.layer.query, 'marker-symbol') || getCommentValue(this.layer.query, 'marker-image-url') + initializeClusterStyles(this.sourceId, clusterIcon) } - // layer with id comes from the layers modal, reload modal - return loadOverpassLayer(layer.id).then(() => { if (id) { initLayersModal() } }) - }) -} + this.setupEventHandlers() + return this.loadData() + } -export function renderOverpassLayer(id) { - let layer = layers.find(l => l.id === id) - console.log("Redraw: Setting source data for overpass layer", layer) - - // TODO: only needed once, not each render - // this + `promoteId: 'id'` is a workaround for the maplibre limitation: - // https://github.com/mapbox/mapbox-gl-js/issues/2716 - // because to highlight a feature we need the id, - // and in the style layers it only accepts mumeric ids in the id field initially - layer.geojson.features.forEach((feature) => { feature.properties.id = feature.id }) - map.getSource(layer.type + '-source-' + layer.id).setData(layer.geojson, false) -} + loadData() { + if (!this.layer.query) { return Promise.resolve() } + let query = this.layer.query -export function loadOverpassLayer(id) { - const layer = layers.find(f => f.id === id) - if (!layer?.query) { return Promise.resolve() } - let query = layer.query - - const beforeSemicolon = query.split(';')[0] - // query already comes with a settings block - if (/\[bbox|\[timeout|\[out/.test(beforeSemicolon)) { - if (!query.includes("[bbox")) { query = "[bbox:{{bbox}}]" + query } - if (!query.includes("[timeout")) { query = "[timeout:25]" + query } - if (!query.includes("[out")) { query = "[out:json]" + query } - } else { - query = "[out:json][timeout:25][bbox:{{bbox}}];\n" + query - } - query = replaceBboxWithMapRectangle(query) - console.log('Loading overpass layer', layer) - - return fetch("https://overpass-api.de/api/interpreter", - { - method: "POST", - headers: { "Content-Type": "text/plain" }, - // The body contains the query, Note: newlines (\n) break - body: query + const beforeSemicolon = query.split(';')[0] + // query already comes with a settings block + if (/\[bbox|\[timeout|\[out/.test(beforeSemicolon)) { + if (!query.includes("[bbox")) { query = "[bbox:{{bbox}}]" + query } + if (!query.includes("[timeout")) { query = "[timeout:25]" + query } + if (!query.includes("[out")) { query = "[out:json]" + query } + } else { + query = "[out:json][timeout:25][bbox:{{bbox}}];\n" + query + } + query = replaceBboxWithMapRectangle(query) + console.log('Loading overpass layer', this.layer) + + return fetch("https://overpass-api.de/api/interpreter", + { + method: "POST", + headers: { "Content-Type": "text/plain" }, + // The body contains the query, Note: newlines (\n) break + body: query + }) + // overpass xml to geojson: https://github.com/tyrasd/osmtogeojson + .then( response => { + if (!response.ok) { + throw new Error(`HTTP status: ${response.status}`) + } + return response.json() + } ) + .then( data => { + let geojson = osmtogeojson(data) + geojson = applyOverpassStyle(geojson, query) + this.layer.geojson = applyOverpassQueryStyle(geojson, this.layer.name) + this.render() + functions.e('#maplibre-map', e => { e.setAttribute('data-overpass-loaded', true) }) + }) + .catch(error => { + console.error('Failed to fetch overpass for ' + this.id, this.layer.query, error.message) + status('Failed to load layer ' + this.layer.name, 'error') + // Set empty geojson so layer can still render + this.layer.geojson = { type: 'FeatureCollection', features: [] } + this.render() }) - // overpass xml to geojson: https://github.com/tyrasd/osmtogeojson - .then( response => { - if (!response.ok) { - throw new Error(`HTTP status: ${response.status}`) + } +} + +// Standalone utility exports + +export function overpassDescription(props) { + let desc = '' + if (props["description"]) { desc += props["description"] + '\n\n' } + if (props["notes"]) { desc += props["notes"] + '\n' } + if (props["website"]) { desc += props["website"] + '\n' } + if (props["url"]) { desc += props["url"] + '\n' } + + desc += `\n**OSM tags:** +
+ | | | + | ------------- | ------------- |\n` + const keys = Object.keys(props).filter(key => !['description', 'notes', 'website', 'url', 'id', 'label'].includes(key)) + keys.forEach(key => { + // direct links + if (key == 'wikipedia') { + desc += `| **${key}** | [${props[key]}](${wikiLink(props[key])})` + } else if (key == 'wikidata') { + desc += `| **${key}** | [${props[key]}](https://www.wikidata.org/wiki/${props[key]})` + } else if (key == 'wikimedia_commons') { + desc += `| **${key}** | [${props[key]}](https://commons.wikimedia.org/wiki/${encodeURIComponent(props[key])})` + } else { + desc += `| **${key}** | ${props[key]} ` } - return response.json() - } ) - .then( data => { - // console.log('Received from overpass-api.de', data) - let geojson = osmtogeojson(data) - // console.log('osmtogeojson', geojson) - geojson = applyOverpassStyle(geojson, query) - layer.geojson = applyOverpassQueryStyle(geojson, layer.name) - renderOverpassLayer(layer.id) - functions.e('#maplibre-map', e => { e.setAttribute('data-overpass-loaded', true) }) - }) - .catch(error => { - console.error('Failed to fetch overpass for ' + layer.id, layer.query, error.message) - status('Failed to load layer ' + layer.name, 'error') + // link to osm taginfo where it makes sense + if (!['wikipedia', 'email', 'name', 'phone', 'wikidata', 'wikimedia_commons'].includes(key)) { + desc += `[![Taginfo](/icons/osm-icon-smaller.png)](https://taginfo.openstreetmap.org/tags/${key}=${encodeURIComponent(props[key])})` + } + desc += `|\n` }) + desc += '\n' + '
\n' + + desc += '\n' + '![osm link](/icons/osm-icon-small.png)' + desc += '[See node in OpenStreetMap](https://www.openstreetmap.org/' + props['id'] + ')' + + return desc +} + +// Private helpers + +function getCommentValue(query, key) { + // Match lines like: // key=value (with possible spaces) + const regex = new RegExp(`^\\s*\\/\\/\\s*${key}\\s*=\\s*(.+)$`, "m") + const match = query.match(regex) + + return match ? match[1].trim() : null } function replaceBboxWithMapRectangle(query) { - // Get the current map bounds (returns a LngLatBounds object) const bounds = map.getBounds() const sw = bounds.getSouthWest() const ne = bounds.getNorthEast() - // Create a bbox array: [minLat, minLon, maxLat, maxLon] const bbox = [sw.lat, sw.lng, ne.lat, ne.lng] - // Replace all occurrences of {{bbox}} with the rectangle string return query.replace(/\{\{bbox\}\}/g, bbox.join(",")) } @@ -134,52 +166,7 @@ function applyOverpassStyle(geojson, query) { return geojson } -export function overpassDescription(props) { - let desc = '' - if (props["description"]) { desc += props["description"] + '\n\n' } - if (props["notes"]) { desc += props["notes"] + '\n' } - if (props["website"]) { desc += props["website"] + '\n' } - if (props["url"]) { desc += props["url"] + '\n' } - - desc += `\n**OSM tags:** -
- | | | - | ------------- | ------------- |\n` - const keys = Object.keys(props).filter(key => !['description', 'notes', 'website', 'url', 'id', 'label'].includes(key)) - keys.forEach(key => { - // direct links - if (key == 'wikipedia') { - desc += `| **${key}** | [${props[key]}](${wikiLink(props[key])})` - } else if (key == 'wikidata') { - desc += `| **${key}** | [${props[key]}](https://www.wikidata.org/wiki/${props[key]})` - } else if (key == 'wikimedia_commons') { - desc += `| **${key}** | [${props[key]}](https://commons.wikimedia.org/wiki/${encodeURIComponent(props[key])})` - } else { - desc += `| **${key}** | ${props[key]} ` - } - // link to osm taginfo where it makes sense - if (!['wikipedia', 'email', 'name', 'phone', 'wikidata', 'wikimedia_commons'].includes(key)) { - desc += `[![Taginfo](/icons/osm-icon-smaller.png)](https://taginfo.openstreetmap.org/tags/${key}=${encodeURIComponent(props[key])})` - } - desc += `|\n` - }) - desc += '\n' + '
\n' - - desc += '\n' + '![osm link](/icons/osm-icon-small.png)' - desc += '[See node in OpenStreetMap](https://www.openstreetmap.org/' + props['id'] + ')' - - return desc -} - -function getCommentValue(query, key) { - // Match lines like: // key=value (with possible spaces) - const regex = new RegExp(`^\\s*\\/\\/\\s*${key}\\s*=\\s*(.+)$`, "m") - const match = query.match(regex) - - return match ? match[1].trim() : null -} - function wikiLink(str) { const [lang, title] = str.split(':') return `https://${lang}.wikipedia.org/wiki/${encodeURIComponent(title)}` -} \ No newline at end of file +} diff --git a/app/javascript/maplibre/layers/wikipedia.js b/app/javascript/maplibre/layers/wikipedia.js index 7d88a277..7be1c11f 100644 --- a/app/javascript/maplibre/layers/wikipedia.js +++ b/app/javascript/maplibre/layers/wikipedia.js @@ -1,57 +1,41 @@ import * as functions from 'helpers/functions' import { status } from 'helpers/status' -import { initLayersModal } from 'maplibre/controls/shared' -import { layers } from 'maplibre/layers/layers' +import { Layer } from 'maplibre/layers/layer' import { map } from 'maplibre/map' import { initializeClusterStyles, initializeViewStyles } from 'maplibre/styles/styles' -export function initializeWikipediaLayers(id = null) { - // console.log('Initializing Wikipedia layers') - let initLayers = layers.filter(l => l.type === 'wikipedia' && l.show !== false) - if (id) { initLayers = initLayers.filter(l => l.id === id) } - - return initLayers.map((layer) => { - initializeViewStyles('wikipedia-source-' + layer.id) - initializeClusterStyles('wikipedia-source-' + layer.id, "/icons/wikipedia.png") - - return loadWikipediaLayer(layer.id).then(() => { if (id) { initLayersModal() } }) - }) -} - -export function loadWikipediaLayer(id) { - const layer = layers.find(f => f.id === id) - // query API docs: https://en.wikipedia.org/w/api.php?action=help&modules=query - // Cannot include article previews in geo search - const url = "https://de.wikipedia.org/w/api.php?origin=*&action=query&format=json&list=geosearch&gslimit=200&gsradius=" - + "10000&gscoord=" + map.getCenter().lat + "%7C" + map.getCenter().lng - - return fetch(url) - .then(response => { - if (!response.ok) { throw new Error('Network response was not ok') } - return response.json() - }) - .then(data => { - if (data.error) { throw new Error('API error: ' + data.error.info ) } - layer.geojson = wikipediatoGeoJSON(data) - renderWikipediaLayer(layer.id) - }) - .catch(error => { - console.error('Failed to fetch wikipedia for ' + layer.id, error) - status('Failed to load layer ' + layer.name, 'error') - }) -} +export class WikipediaLayer extends Layer { + initialize() { + initializeViewStyles(this.sourceId) + initializeClusterStyles(this.sourceId, "/icons/wikipedia.png") + this.setupEventHandlers() + return this.loadData() + } -export function renderWikipediaLayer(id) { - let layer = layers.find(l => l.id === id) - console.log("Redraw: Setting source data for wikipedia layer", layer) + loadData() { + // query API docs: https://en.wikipedia.org/w/api.php?action=help&modules=query + // Cannot include article previews in geo search + const url = "https://de.wikipedia.org/w/api.php?origin=*&action=query&format=json&list=geosearch&gslimit=200&gsradius=" + + "10000&gscoord=" + map.getCenter().lat + "%7C" + map.getCenter().lng - // TODO: only needed once, not each render - // this + `promoteId: 'id'` is a workaround for the maplibre limitation: - // https://github.com/mapbox/mapbox-gl-js/issues/2716 - // because to highlight a feature we need the id, - // and in the style layers it only accepts mumeric ids in the id field initially - layer.geojson.features.forEach((feature) => { feature.properties.id = feature.id }) - map.getSource(layer.type + '-source-' + layer.id).setData(layer.geojson, false) + return fetch(url) + .then(response => { + if (!response.ok) { throw new Error('Network response was not ok') } + return response.json() + }) + .then(data => { + if (data.error) { throw new Error('API error: ' + data.error.info ) } + this.layer.geojson = wikipediatoGeoJSON(data) + this.render() + }) + .catch(error => { + console.error('Failed to fetch wikipedia for ' + this.id, error) + status('Failed to load layer ' + this.layer.name, 'error') + // Set empty geojson so layer can still render + this.layer.geojson = { type: 'FeatureCollection', features: [] } + this.render() + }) + } } export async function wikipediaFeatureDescription(feature) { @@ -71,7 +55,6 @@ export async function wikipediaFeatureDescription(feature) { return desc } - function wikipediatoGeoJSON(data) { let geoJSON = { "type": "FeatureCollection", diff --git a/app/javascript/maplibre/map.js b/app/javascript/maplibre/map.js index ed6bd128..50cad6a6 100644 --- a/app/javascript/maplibre/map.js +++ b/app/javascript/maplibre/map.js @@ -10,10 +10,7 @@ import { initCtrlTooltips, initializeDefaultControls, initSettingsModal, resetCo import { initializeViewControls } from 'maplibre/controls/view'; import { resetEditMode } from 'maplibre/edit'; import { highlightFeature, resetHighlightedFeature } from 'maplibre/feature'; -import { renderGeoJSONLayer, renderGeoJSONLayers } from 'maplibre/layers/geojson'; -import { getFeature, initializeLayerSources, initializeLayerStyles, layers, loadLayerDefinitions } from 'maplibre/layers/layers'; -import { renderOverpassLayer } from 'maplibre/layers/overpass'; -import { renderWikipediaLayer } from 'maplibre/layers/wikipedia'; +import { getFeature, initializeLayers, initializeLayerSources, initializeLayerStyles, layers, renderLayers } from 'maplibre/layers/layers'; import { basemaps, defaultFont, demSource, elevationSource } from 'maplibre/styles/basemaps'; import { loadImage, setStyleDefaultFont } from 'maplibre/styles/styles'; @@ -28,13 +25,20 @@ let backgroundHillshade let backgroundGlobe let backgroundContours -// workflow of event based map loading: -// page calls: initializeMap(), [initializeSocket()], -// initializeViewMode() or initializeEditMode() or initializeStaticMode() -// setBackgroundMapLayer() -> 'style.load' event -// 'style.load' (once) -> initializeDefaultControls() -// 'style.load' -> initializeStyles() -// loadLayerDefinitions() -> 'layers.load' +// Workflow of map loading: +// +// initializeMap() +// └─> setBackgroundMapLayer() +// └─> 'style.load' event +// ├─> initializeDefaultControls() (once) +// └─> initializeStyles() +// ├─> First load: await initializeLayers() (idempotent, returns cached promise if called again) +// │ └─> loadLayerDefinitions() → initializeLayerSources() → await initializeLayerStyles() → sets data-geojson-loaded +// └─> Basemap change: initializeLayerSources() + await initializeLayerStyles() +// +// map 'load' event +// └─> await initializeLayers() (if needed for URL feature - returns cached promise if already loaded) +// └─> Handle URL feature selection (highlight/animate/center) export function initializeMaplibreProperties () { const lastProperties = JSON.parse(JSON.stringify(mapProperties || {})) @@ -76,7 +80,6 @@ export async function initializeMap (divId = 'maplibre-map') { // style: {} // style/map is getting loaded by 'setBackgroundMapLayer' }) - loadLayerDefinitions() if (!functions.isTestEnvironment()) { map.setZoom(map.getZoom() - 1) } // will zoom in on map:load // for console debugging @@ -87,11 +90,7 @@ export async function initializeMap (divId = 'maplibre-map') { map.on('styleimagemissing', loadImage) - map.on('geojson.load', (_e) => { - functions.e('#maplibre-map', e => { e.setAttribute('data-geojson-loaded', true) }) - }) - - // NOTE: map 'load' can happen before 'layers.load'/'geojson.load' when loading features is slow + // NOTE: map 'load' can happen before layers are loaded when loading features is slow map.once('load', async function (_e) { // trigger map fade-in dom.animateElement('.map', 'fade-in', 250) @@ -105,6 +104,10 @@ export async function initializeMap (divId = 'maplibre-map') { functions.e('#preloader', e => { e.classList.add('hidden') }) functions.e('.map', e => { e.setAttribute('data-map-loaded', true) }) + // Wait for layers to be loaded before accessing features + // Safe to call even if already triggered by style.load — returns the cached promise, no double loading + await initializeLayers() + const urlFeatureId = new URLSearchParams(window.location.search).get('f') let feature if (urlFeatureId && (feature = getFeature(urlFeatureId))) { @@ -386,7 +389,7 @@ export function addFeature (feature) { feature.properties.id = feature.id // Adding new features to the first geojson layer layers.find(l => l.type === 'geojson').geojson.features.push(feature) - renderGeoJSONLayers(false) + renderLayers('geojson', false) status('Added feature') } @@ -402,14 +405,14 @@ function updateFeature (feature, updatedFeature) { feature.geometry = updatedFeature.geometry feature.properties = updatedFeature.properties status('Updated feature ' + updatedFeature.id) - renderGeoJSONLayers() + renderLayers('geojson') } export function destroyFeature (featureId) { if (getFeature(featureId)) { status('Deleting feature ' + featureId) layers.forEach(l => l.geojson.features = l.geojson.features.filter(f => f.id !== featureId)) - renderGeoJSONLayers() + renderLayers('geojson') resetHighlightedFeature() } } @@ -418,15 +421,15 @@ export function destroyFeature (featureId) { async function initializeStyles() { console.log('Initializing sources and layer styles after basemap load/change') - // in case layer data is not yet loaded, wait for it + // First load: initialize layers (loads definitions, creates sources, loads styles/data) + // Subsequent calls: re-initialize sources and styles (basemap change removes all sources/layers) if (!layers) { - console.log('Waiting for layers to load before initializing styles...') - await functions.waitForEvent(map, 'layers.load') + await initializeLayers() + } else { + initializeLayerSources() + await initializeLayerStyles() } - initializeLayerSources() - initializeLayerStyles() - demSource.setupMaplibre(maplibregl) if (mapProperties.terrain) { addTerrain() } if (mapProperties.hillshade) { addHillshade() } @@ -519,10 +522,7 @@ export function frontFeature(frontFeature) { if (idx !== -1) { const [feature] = features.splice(idx, 1) // Remove it features.push(feature) // Add to end - // TODO: refactor to call this dynamically in the right way - if (layer.type === 'geojson') { renderGeoJSONLayer(layer.id) } - if (layer.type === 'overpass') { renderOverpassLayer(layer.id) } - if (layer.type === 'wikipedia') { renderWikipediaLayer(layer.id) } + layer.render() break // done, exit loop } diff --git a/app/javascript/maplibre/styles/basemaps.js b/app/javascript/maplibre/styles/basemaps.js index e72fdfb7..44a2f549 100644 --- a/app/javascript/maplibre/styles/basemaps.js +++ b/app/javascript/maplibre/styles/basemaps.js @@ -154,7 +154,8 @@ export function basemaps () { satelliteStreets: { style: host + '/layers/satellite_with_streets.json' }, // basemap.de - basemapWorld: { style: 'https://sgx.geodatenzentrum.de/gdz_basemapworld_vektor/styles/bm_web_wld_col.json', font: 'Noto Sans Regular' }, + basemapWorld: { + style: 'https://sgx.geodatenzentrum.de/gdz_basemapworld_vektor/styles/bm_web_wld_col.json', font: 'Noto Sans Regular', sourceName: 'smarttiles_de' }, // openfreemap.org openfreemapPositron: { style: 'https://tiles.openfreemap.org/styles/positron', font: 'Noto Sans Regular' }, @@ -164,7 +165,7 @@ export function basemaps () { // https://github.com/versatiles-org/versatiles-style // fonts: https://github.com/versatiles-org/versatiles-fonts versatilesColorful: { style: 'https://tiles.versatiles.org/assets/styles/colorful/style.json', sourceName: 'versatiles-shortbread', font: 'noto_sans_regular' }, - versatilesGraybeard: { style: 'https://tiles.versatiles.org/assets/styles/graybeard/style.json', font: 'noto_sans_regular' }, + versatilesGraybeard: { style: 'https://tiles.versatiles.org/assets/styles/graybeard/style.json', sourceName: 'versatiles-shortbread', font: 'noto_sans_regular' }, versatilesNeutrino: { style: 'https://tiles.versatiles.org/assets/styles/neutrino/style.json', font: 'noto_sans_regular' }, versatilesEclipse: { style: 'https://tiles.versatiles.org/assets/styles/eclipse/style.json', font: 'noto_sans_regular' }, diff --git a/app/javascript/maplibre/styles/styles.js b/app/javascript/maplibre/styles/styles.js index d57841ef..855907be 100644 --- a/app/javascript/maplibre/styles/styles.js +++ b/app/javascript/maplibre/styles/styles.js @@ -1,15 +1,5 @@ -import * as functions from 'helpers/functions' -import { flyToFeature } from 'maplibre/animations' -import { draw } from 'maplibre/edit' -import { - highlightFeature, - highlightedFeatureId, - highlightedFeatureSource, - resetHighlightedFeature, - stickyFeatureHighlight -} from 'maplibre/feature' -import { getFeature } from 'maplibre/layers/layers' -import { frontFeature, map, removeStyleLayers } from 'maplibre/map' +import { map, removeStyleLayers } from 'maplibre/map' +import { defaultFont } from 'maplibre/styles/basemaps' export const viewStyleNames = [ 'polygon-layer', @@ -30,76 +20,16 @@ export const viewStyleNames = [ 'polygon-layer-extrusion' ] -export function setStyleDefaultFont (font) { labelFont = [font] } +export function setStyleDefaultFont(font) { labelFont = [font] } export function initializeViewStyles (sourceName, heatmap=false) { - console.log('Initializing view styles for source ' + sourceName) + // console.log('Initializing view styles for source ' + sourceName) removeStyleLayers(sourceName) viewStyleNames.forEach(styleName => { map.addLayer(setSource(styles()[styleName], sourceName)) }) if (heatmap) { map.addLayer(setSource(styles()['heatmap-layer'], sourceName)) } // console.log('View styles added for source ' + sourceName) - - // click is needed to select on mobile and for sticky highlight - map.on('click', styleNames(sourceName), function (e) { - if (draw && draw.getMode() !== 'simple_select') { return } - if (window.gon.map_mode === 'static') { return } - - console.log('Features clicked', e.features) - let feature = e.features.find(f => !f.properties?.cluster) - if (!feature) { return } - - if (window.gon.map_mode === 'ro' || e.originalEvent.shiftKey) { - feature = e.features.find(f => f.properties?.onclick !== false) - if (!feature) { return } - - if (feature.properties?.onclick === 'link' && feature.properties?.['onclick-target']) { - window.location.href = feature.properties?.['onclick-target'] - return - } - if (feature.properties?.onclick === 'feature' && feature.properties?.['onclick-target']) { - const targetId = feature.properties?.['onclick-target'] - const targetFeature = getFeature(targetId) - if (targetFeature) { - flyToFeature(targetFeature) - } else { - console.error('Target feature with id ' + targetId + ' not found') - } - return - } - } - frontFeature(feature) - highlightFeature(feature, true, sourceName) - }) - - // highlight features on hover (only in ro mode) - if (window.gon.map_mode === 'ro' && !functions.isTouchDevice()) { - map.on('mousemove', (e) => { - if (stickyFeatureHighlight && highlightedFeatureId) { return } - if (document.querySelector('.show > .map-modal')) { return } - if (!map.getSource(sourceName)) { return } // can happen when source is removed - - const features = map.queryRenderedFeatures(e.point, { layers: styleNames(sourceName) }) - // console.log('Features hovered', features) - let feature = features.find(f => !f.properties?.cluster && f.properties?.onclick !== false) - - if (feature?.id) { - if (feature.id === highlightedFeatureId) { return } - frontFeature(feature) - highlightFeature(feature, false, sourceName) - } else if (highlightedFeatureSource === sourceName) { - resetHighlightedFeature() - } - }) - } - - map.on('contextmenu', (e) => { - e.preventDefault() - // console.log(styleNames(sourceName)) - // const features = map.queryRenderedFeatures(e.point) - //console.log('ro context:', features) - }) } export function initializeClusterStyles(sourceName, icon) { @@ -120,6 +50,15 @@ export function initializeClusterStyles(sourceName, icon) { // loading images from 'marker-image-url' attributes // avoid loading the same image by each web worker const imageState = {} // 'loading' | 'loaded' | 'error' + +// Clear image state when navigating to a new map +export function clearImageState() { + Object.keys(imageState).forEach(key => delete imageState[key]) +} + +// Reset labelFont to default when clearing state +export function resetLabelFont() { labelFont = [defaultFont] } + export async function loadImage (e) { // Skip if already loading, loaded, or failed if (imageState[e.id]) { @@ -319,7 +258,7 @@ const labelSize = [ ] // default font is set in basemap def basemaps[backgroundMapLayer]['font'] -export let labelFont // array +export let labelFont = [defaultFont] // array - initialize with default to prevent null errors // Shared configuration for symbols layers function symbolsLayerStyles(mode) { @@ -393,9 +332,7 @@ function textLayerStyles(mode) { 'format', ['coalesce', ['get', 'label'], ['get', 'room']], { - 'text-font': [ - 'case', - ['has', 'label-font'], + 'text-font': ['coalesce', ['get', 'label-font'], ['literal', labelFont] ] @@ -403,12 +340,12 @@ function textLayerStyles(mode) { ], 'text-size': labelSize, 'text-font': labelFont, - 'text-letter-spacing': ['get', 'label-letter-spacing'], + 'text-letter-spacing': ['coalesce', ['get', 'label-letter-spacing'], 0], 'text-anchor': 'top', // text under point // TODO: set this to 0 for polygons, needs 'geometry-type' implementation: https://github.com/maplibre/maplibre-style-spec/discussions/536 'text-offset': labelOffset, 'text-justify': ['get', 'label-justify'], - 'text-max-width': ['get', 'label-max-width'], + 'text-max-width': ['coalesce', ['get', 'label-max-width'], 10], 'text-line-height': 1.6, // no dynamic value possible // TODO: sort keys on text are ascending, on symbols descending??? 'symbol-sort-key': ['-', 1000, sortKey] @@ -491,8 +428,8 @@ export function styles () { minZoomFilter], paint: { 'fill-extrusion-color': styleProp(['fill-extrusion-color', 'user_fill-extrusion-color', 'fill', 'user_fill'], featureColor), - 'fill-extrusion-height': ['to-number', styleProp(['fill-extrusion-height', 'user_fill-extrusion-height'])], - 'fill-extrusion-base': ['to-number', styleProp(['fill-extrusion-base', 'user_fill-extrusion-base'])], + 'fill-extrusion-height': ['to-number', styleProp(['fill-extrusion-height', 'user_fill-extrusion-height'], 0)], + 'fill-extrusion-base': ['to-number', styleProp(['fill-extrusion-base', 'user_fill-extrusion-base'], 0)], // opacity does not support data expressions, it's a constant per layer 'fill-extrusion-opacity': 0.9 } @@ -551,7 +488,7 @@ export function styles () { layout: { 'line-join': 'round', 'line-cap': 'round', - 'line-sort-key': ['to-number', ['get', 'sort-key']] + 'line-sort-key': sortKey }, paint: { 'line-color': lineColor, @@ -826,8 +763,3 @@ export function clusterStyles(icon) { export function setSource (style, sourceName) { return { ...style, source: sourceName, id: style.id + '_' + sourceName } } - -// Adding sourceName suffix to style names because style layer ids must be unique on map -function styleNames (sourceName) { - return viewStyleNames.map(styleName => styleName + '_' + sourceName) -} diff --git a/app/javascript/maplibre/undo.js b/app/javascript/maplibre/undo.js index adeac61e..8eedbf42 100644 --- a/app/javascript/maplibre/undo.js +++ b/app/javascript/maplibre/undo.js @@ -1,14 +1,30 @@ import { status } from 'helpers/status' import { select, selectedFeature } from 'maplibre/edit' import { showFeatureDetails } from 'maplibre/feature' -import { renderGeoJSONLayers } from 'maplibre/layers/geojson' -import { getFeature } from 'maplibre/layers/layers' +import { getFeature, renderLayers } from 'maplibre/layers/layers' import { addFeature, destroyFeature } from 'maplibre/map' import { resetDirections } from 'maplibre/routing/osrm' let undoStack = [] let redoStack = [] +/** + * Clears undo/redo history when navigating to a new map + */ +export function clearUndoHistory() { + undoStack = [] + redoStack = [] + // Try to hide buttons if they exist + try { + const undoBtn = document.querySelector('button.maplibregl-ctrl-undo') + const redoBtn = document.querySelector('button.maplibregl-ctrl-redo') + if (undoBtn) undoBtn.classList.add('hidden') + if (redoBtn) redoBtn.classList.add('hidden') + } catch { + // Buttons may not exist yet + } +} + export function addUndoState(type, state, clearRedo = true) { // Deep clone to avoid mutation undoStack.push({ type: type, state: JSON.parse(JSON.stringify(state)) }) @@ -54,7 +70,7 @@ export function undo() { if (!handler) { console.warn('Cannot undo ', prevState); return } handler(prevState) status('Undo: ' + prevState.type) - renderGeoJSONLayers(true) + renderLayers('geojson', true) keepSelection() if (undoStack.length === 0) { hideUndoButton() } } @@ -68,7 +84,7 @@ export function redo() { if (!handler) { console.warn('Cannot redo ', nextState); return } handler(nextState) status('Redo: ' + nextState.type) - renderGeoJSONLayers(true) + renderLayers('geojson', true) keepSelection() if (redoStack.length === 0) { hideRedoButton() } } diff --git a/app/models/layer.rb b/app/models/layer.rb index 2d9e3572..3de06756 100644 --- a/app/models/layer.rb +++ b/app/models/layer.rb @@ -6,6 +6,7 @@ class Layer belongs_to :map, optional: true, touch: true has_many :features, dependent: :destroy + default_scope { reorder(created_at: :asc) } scope :geojson, -> { where(type: "geojson") } scope :overpass, -> { where(type: "overpass") } diff --git a/app/models/map.rb b/app/models/map.rb index abefa358..3b65d866 100644 --- a/app/models/map.rb +++ b/app/models/map.rb @@ -8,7 +8,7 @@ class Map belongs_to :user, optional: true, counter_cache: true # implicit_order_column is not supported by mongoid - default_scope { sorted(:created_at, :desc) } + default_scope { sorted(:created_at, :asc) } scope :listed, -> { where(view_permission: "listed") } scope :ulogger, -> { where(:_id.lt => BSON::ObjectId("000000000000002147483647")) } scope :demo, -> { where(demo: true) } @@ -19,7 +19,7 @@ class Map } scope :sorted, ->(col, dir) { col = "created_at" unless col.present? - dir = %w[asc desc].include?(dir) ? dir : "asc" + dir = %w[asc desc].include?(dir) ? dir.to_s : "asc" reorder(col.to_sym => dir.to_sym) } diff --git a/app/views/maps/modals/_layers.haml b/app/views/maps/modals/_layers.haml index 5ac9f93d..67e763c3 100644 --- a/app/views/maps/modals/_layers.haml +++ b/app/views/maps/modals/_layers.haml @@ -60,6 +60,14 @@ data: { action: "click->map--layers#createSelectedOverpassLayer", query_name: query_name } } = query_name + %li + %hr.dropdown-divider + %li + %button.dropdown-item{ + type: "button", + data: { action: "click->map--layers#createBaseMapLayer" } + } + Basemap Info (experimental) - # used by controls/shared.js .hidden diff --git a/spec/factories/layers.rb b/spec/factories/layers.rb index 824175bc..dc6df9c4 100644 --- a/spec/factories/layers.rb +++ b/spec/factories/layers.rb @@ -13,5 +13,9 @@ type { "overpass" } query { "nwr[highway=bus_stop];out skel;" } end + + trait :wikipedia do + type { "wikipedia" } + end end end diff --git a/spec/features/map_layers_spec.rb b/spec/features/map_layers_spec.rb index c8388fa3..d3ceb2f3 100644 --- a/spec/features/map_layers_spec.rb +++ b/spec/features/map_layers_spec.rb @@ -234,4 +234,74 @@ wait_for { map.layers.find { |m| m.name == "Custom query" } }.to be_present end end + + context "overpass layer visibility" do + before do + map.layers << layer + visit map.private_map_path + expect_map_loaded + expect_overpass_loaded + end + + let(:layer) { create(:layer, :overpass, name: "opass") } + + it "toggles overpass layer visibility via websocket" do + layer.update!(show: false) + expect_layer_visibility(layer.id, false, 'overpass') + layer.update!(show: true) + expect_layer_visibility(layer.id, true, 'overpass') + end + end + + context "layer update via websocket" do + it "updates layer name via server-side change" do + layer = map.layers.first + layer.update!(name: "Renamed Layer") + # Re-open modal to see updated layer name + find(".maplibregl-ctrl-layers").click + expect(page).to have_text("Renamed Layer") + end + end + + context "wikipedia layer" do + before do + wikipedia_file = File.read(Rails.root.join("spec", "fixtures", "files", "wikipedia.json")) + CapybaraMock.stub_request( + :get, /de\.wikipedia\.org\/w\/api\.php/ + ).to_return( + headers: { "Access-Control-Allow-Origin" => "*" }, + status: 200, + body: wikipedia_file + ) + visit map.private_map_path + expect_map_loaded + end + + it "can add wikipedia layer" do + find(".maplibregl-ctrl-layers").click + click_button "Add layer" + find("li", text: "Wikipedia articles").click + wait_for { map.layers.find { |m| m.name == "Wikipedia" } }.to be_present + end + end + + context "basemap change preserves layers" do + before do + stub_const("Map::BASE_MAPS", [ "test", "test2" ] + Map::BASE_MAPS) + feature + visit map.private_map_path + expect_map_loaded + end + + let(:feature) { create(:feature, :point, title: "Basemap Test Feature", layer: map.layers.first) } + + it "features remain visible after basemap change" do + layer_id = map.layers.first.id + expect_layer_visibility(layer_id, true) + + map.update(base_map: "test2") + expect(page).to have_text(/Loaded base map test2|Map properties updated|Map view updated/) + expect_layer_visibility(layer_id, true) + end + end end diff --git a/spec/fixtures/files/wikipedia.json b/spec/fixtures/files/wikipedia.json new file mode 100644 index 00000000..4de29f40 --- /dev/null +++ b/spec/fixtures/files/wikipedia.json @@ -0,0 +1,16 @@ +{ + "batchcomplete": "", + "query": { + "geosearch": [ + { + "pageid": 12345, + "ns": 0, + "title": "Nürnberger Burg", + "lat": 49.458, + "lon": 11.076, + "dist": 100, + "primary": "" + } + ] + } +} diff --git a/spec/support/map_helpers.rb b/spec/support/map_helpers.rb index ba130934..8d8230f7 100644 --- a/spec/support/map_helpers.rb +++ b/spec/support/map_helpers.rb @@ -12,15 +12,15 @@ def expect_overpass_loaded expect(page).to have_css("#maplibre-map[data-overpass-loaded='true']", wait: 30) end -def layer_visibility(layer_id) +def layer_visibility(layer_id, type = 'geojson') visible = page.evaluate_script(<<~JS) map.getStyle().layers - .filter(l => l.source === 'geojson-source-#{layer_id}') + .filter(l => l.source === '#{type}-source-#{layer_id}') .every(l => map.getLayoutProperty(l.id, 'visibility') !== 'none') JS visible end -def expect_layer_visibility(layer_id, visible) - wait_for { layer_visibility(layer_id) }.to be visible +def expect_layer_visibility(layer_id, visible, type = 'geojson') + wait_for { layer_visibility(layer_id, type) }.to be visible end