diff --git a/CHANGELOG.md b/CHANGELOG.md index b42ffb99..1df93190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. * Switched headline font to "Bree Serif" * Enhanced waymarkedtrails.org integration: Select and copy trails to your map +* Added indoor layer (vector data from [indoorequal](https://indoorequal.org/)) ## 2026-04 @@ -16,7 +17,6 @@ All notable changes to this project will be documented in this file. * Allow to color code routes by steepness + surface * Option to convert gpx tracks into routes * New layer type 'raster' with waymarkedtrails.org examples -* Switched headline font to "Bree Serif" ## 2026-03 diff --git a/app/assets/stylesheets/controls.css b/app/assets/stylesheets/controls.css index 33f78e45..7d387642 100644 --- a/app/assets/stylesheets/controls.css +++ b/app/assets/stylesheets/controls.css @@ -15,6 +15,45 @@ border-top: 1px solid #0000001c; } +.indoor-level-control { + position: absolute; + bottom: 3rem; + right: 0.5rem; + z-index: 1; + display: flex; + flex-direction: column; + background: white; + border-radius: 4px; + box-shadow: 0 0 0 2px rgb(0 0 0 / 10%); +} + +.indoor-level-control button { + height: 2rem; + width: 2rem; + border: none; + background: white; + cursor: pointer; + font-size: 1rem; + text-align: center; +} + +.indoor-level-control button.active { + background-color: rgb(0 0 0/15%) !important; + color: var(--color-ctrl-active) !important; +} + +.indoor-level-control button:first-child { + border-radius: 4px 4px 0 0; +} + +.indoor-level-control button:last-child { + border-radius: 0 0 4px 4px; +} + +.indoor-level-control button:only-child { + border-radius: 4px; +} + .maplibregl-ctrl button:disabled { opacity: 0.2; } @@ -43,6 +82,11 @@ color: var(--color-ctrl-active) !important; } + .indoor-level-control button:hover { + background-color: rgb(0 0 0/15%) !important; + color: var(--color-ctrl-active) !important; + } + /* Need to overwrite the mapbox draw icons with hover color */ .mapbox-gl-draw_point:hover { /* stylelint-disable-line */ background-image: url('data:image/svg+xml;utf8,%3Csvg xmlns="http://www.w3.org/2000/svg" width="20" height="20">%3Cpath style="fill: %232091b0;" d="m10 2c-3.3 0-6 2.7-6 6s6 9 6 9 6-5.7 6-9-2.7-6-6-6zm0 2c2.1 0 3.8 1.7 3.8 3.8 0 1.5-1.8 3.9-2.9 5.2h-1.7c-1.1-1.4-2.9-3.8-2.9-5.2-.1-2.1 1.6-3.8 3.7-3.8z"/>%3C/svg>'); diff --git a/app/javascript/controllers/map/layers_controller.js b/app/javascript/controllers/map/layers_controller.js index 540fa0c3..e8b73bb7 100644 --- a/app/javascript/controllers/map/layers_controller.js +++ b/app/javascript/controllers/map/layers_controller.js @@ -345,6 +345,10 @@ export default class extends Controller { this.createLayer('basemap', 'Basemap layer') } + createIndoorLayer(_event) { + this.createLayer('indoor', 'Indoor map') + } + createLayer(type, name, query=null, geojson=null) { let layerId = functions.featureId() // must match server attribute order, for proper comparison in map_channel diff --git a/app/javascript/maplibre/controls/shared.js b/app/javascript/maplibre/controls/shared.js index f6fba7b3..0c9934d2 100644 --- a/app/javascript/maplibre/controls/shared.js +++ b/app/javascript/maplibre/controls/shared.js @@ -204,16 +204,16 @@ export function initLayersModal () { head.textContent = layerName } - // Don't show feature count for raster layers - if (layer.type !== 'raster') { + // Don't show feature count for raster and indoor layers + if (layer.type !== 'raster' && layer.type !== 'indoor') { const featureCount = document.createElement('span') featureCount.classList.add('small') featureCount.textContent = '(' + features.length + ')' head.parentNode.insertBefore(featureCount, head.nextSibling) } - // Make raster layers non-expandable - if (layer.type === 'raster') { + // Make raster and indoor layers non-expandable + if (layer.type === 'raster' || layer.type === 'indoor') { const toggleLink = layerElement.querySelector('.link[data-action*="toggleLayerList"]') if (toggleLink) { toggleLink.style.cursor = 'default' @@ -303,7 +303,7 @@ export function initLayersModal () { } dom.initTooltips(layerElement) - if (features.length === 0 && layer.type !== 'raster') { + if (features.length === 0 && layer.type !== 'raster' && layer.type !== 'indoor') { const newNode = document.createElement('i') newNode.classList.add('ms-3') newNode.textContent = 'No elements in this layer' diff --git a/app/javascript/maplibre/feature.js b/app/javascript/maplibre/feature.js index bbdac941..691ad5d9 100644 --- a/app/javascript/maplibre/feature.js +++ b/app/javascript/maplibre/feature.js @@ -16,6 +16,7 @@ window.marked = marked export let highlightedFeatureId export let highlightedFeatureSource +export let highlightedSourceLayer = null export let stickyFeatureHighlight = false let elevationChart @@ -252,9 +253,14 @@ export function featureImage(feature) { export function resetHighlightedFeature () { if (highlightedFeatureId && map.getSource(highlightedFeatureSource)) { - map.setFeatureState({ source: highlightedFeatureSource, id: highlightedFeatureId }, { active: false }) + const stateParams = { source: highlightedFeatureSource, id: highlightedFeatureId } + if (highlightedSourceLayer) { + stateParams.sourceLayer = highlightedSourceLayer + } + map.setFeatureState(stateParams, { active: false }) highlightedFeatureSource = null highlightedFeatureId = null + highlightedSourceLayer = null // drop feature param from url const url = new URL(window.location.href) if (url.searchParams.get('f')) { @@ -268,7 +274,8 @@ export function resetHighlightedFeature () { f.e('#feature-details-modal', e => { e.classList.remove('show') }) } -export function highlightFeature (feature, sticky = false, source) { +// For highlighting features from vector layers, we need to track their sourceLayer. +export function highlightFeature (feature, sticky = false, source, sourceLayer = null) { // Only reset if there's a different feature currently highlighted if (highlightedFeatureId === feature.id) { return } if (highlightedFeatureId && highlightedFeatureId !== feature.id) { resetHighlightedFeature() } @@ -278,6 +285,7 @@ export function highlightFeature (feature, sticky = false, source) { stickyFeatureHighlight = sticky highlightedFeatureId = feature?.id highlightedFeatureSource = source + highlightedSourceLayer = sourceLayer // load feature from source, the style only returns the dimensions on screen const sourceFeature = layers .filter(l => Array.isArray(l.geojson?.features)) @@ -285,14 +293,20 @@ export function highlightFeature (feature, sticky = false, source) { .find(f => f.id === feature.id) showFeatureDetails(sourceFeature || feature) - if (sourceFeature) { - // A feature's state is not part of the GeoJSON or vector tile data but can get used in styles - map.setFeatureState({ source, id: feature.id }, { active: true }) - // set url to feature - if (sticky) { - const newPath = `${window.location.pathname}?f=${feature.id}${window.location.hash}` - window.history.pushState({}, '', newPath) + + // Set feature state for both GeoJSON and vector tile features + if (feature?.id != null) { + const stateParams = { source, id: feature.id } + if (sourceLayer) { + stateParams.sourceLayer = sourceLayer } + map.setFeatureState(stateParams, { active: true }) + } + + // URL persistence only for GeoJSON features (vector tile IDs are not stable across sessions) + if (sourceFeature && sticky) { + const newPath = `${window.location.pathname}?f=${feature.id}${window.location.hash}` + window.history.pushState({}, '', newPath) } } diff --git a/app/javascript/maplibre/layers/factory.js b/app/javascript/maplibre/layers/factory.js index 4c52cbfc..3a2c0ca1 100644 --- a/app/javascript/maplibre/layers/factory.js +++ b/app/javascript/maplibre/layers/factory.js @@ -1,5 +1,6 @@ import { BasemapLayer } from 'maplibre/layers/basemap' import { GeoJSONLayer } from 'maplibre/layers/geojson' +import { IndoorLayer } from 'maplibre/layers/indoor/indoor' import { Layer } from 'maplibre/layers/layer' import { OverpassLayer } from 'maplibre/layers/overpass/overpass' import { RasterLayer } from 'maplibre/layers/raster/raster' @@ -10,7 +11,8 @@ const layerTypes = { overpass: OverpassLayer, wikipedia: WikipediaLayer, basemap: BasemapLayer, - raster: RasterLayer + raster: RasterLayer, + indoor: IndoorLayer } export function createLayerInstance(data) { diff --git a/app/javascript/maplibre/layers/indoor/control.js b/app/javascript/maplibre/layers/indoor/control.js new file mode 100644 index 00000000..4ba0e40e --- /dev/null +++ b/app/javascript/maplibre/layers/indoor/control.js @@ -0,0 +1,141 @@ +import { initTooltips } from 'helpers/dom' + +/** + * Level control UI for indoor maps + * Displays a vertical stack of buttons for switching between floor levels + */ +export class IndoorLevelControl { + constructor(layerId, onLevelChange) { + this.layerId = layerId + this.onLevelChange = onLevelChange + this.element = null + this.currentLevel = null + this.levels = [] + } + + /** + * Creates and shows the level control + */ + create() { + if (this.element) return + + // Check if a control for this layer already exists + const existingControl = document.querySelector(`.indoor-level-control[data-layer-id="${this.layerId}"]`) + if (existingControl) { + this.element = existingControl + return + } + + this.element = document.createElement('div') + this.element.className = 'indoor-level-control' + this.element.setAttribute('data-layer-id', this.layerId) + + const mapContainer = document.querySelector('#maplibre-map') + if (mapContainer) { + mapContainer.appendChild(this.element) + } + } + + /** + * Disposes all tooltips on buttons in this control + */ + disposeTooltips() { + if (!this.element || typeof bootstrap === 'undefined') return + + this.element.querySelectorAll('button').forEach(button => { + const tooltip = bootstrap.Tooltip.getInstance(button) + if (tooltip) { + try { + tooltip.dispose() + } catch { + // Tooltip might be mid-animation when dispose is called + } + } + }) + } + + /** + * Updates the control with the given levels + * @param {string[]} levels - Array of level strings, sorted descending + * @param {string} currentLevel - The currently active level + */ + update(levels, currentLevel) { + if (!this.element) { + this.create() + } + + const levelsChanged = JSON.stringify(levels) !== JSON.stringify(this.levels) + + if (levelsChanged) { + this.levels = levels + this.currentLevel = currentLevel + this.disposeTooltips() + this.element.innerHTML = '' + + levels.forEach(level => { + const button = document.createElement('button') + button.textContent = level + button.title = `Level ${level}` + button.setAttribute('data-level', level) + button.setAttribute('data-toggle', 'tooltip') + button.setAttribute('data-bs-trigger', 'hover') + + if (level === currentLevel) { + button.classList.add('active') + } + + button.addEventListener('click', () => { + if (this.onLevelChange) { + this.onLevelChange(level) + } + }) + + this.element.appendChild(button) + }) + + initTooltips(this.element) + } else if (this.currentLevel !== currentLevel) { + this.currentLevel = currentLevel + this.element.querySelectorAll('button').forEach(button => { + const level = button.getAttribute('data-level') + if (level === currentLevel) { + button.classList.add('active') + } else { + button.classList.remove('active') + } + }) + } + } + + /** + * Removes the control from the DOM + */ + remove() { + if (this.element && this.element.parentNode) { + this.disposeTooltips() + this.element.parentNode.removeChild(this.element) + } + this.element = null + this.levels = [] + this.currentLevel = null + } + + /** + * Shows the control + */ + show() { + if (this.element) { + this.element.style.display = 'flex' + } + } + + /** + * Hides the control + */ + hide() { + if (this.element) { + this.disposeTooltips() + this.element.style.display = 'none' + } + } +} diff --git a/app/javascript/maplibre/layers/indoor/indoor.js b/app/javascript/maplibre/layers/indoor/indoor.js new file mode 100644 index 00000000..266ae2a4 --- /dev/null +++ b/app/javascript/maplibre/layers/indoor/indoor.js @@ -0,0 +1,265 @@ +import { debounce } from 'helpers/functions' +import { highlightFeature, resetHighlightedFeature } from 'maplibre/feature' +import { IndoorLevelControl } from 'maplibre/layers/indoor/control' +import { addIndoorLayers, getIndoorLayerIds, indoorFillColor } from 'maplibre/layers/indoor/styles' +import { Layer } from 'maplibre/layers/layer' +import { map, removeStyleLayers, updateBuildingOpacity } from 'maplibre/map' + +export class IndoorLayer extends Layer { + constructor(layer) { + super(layer) + this.currentLevel = '0' + this.levels = [] + this.levelControl = null + this.idleHandler = null + this.initialTimeout = null + } + + get show() { + return this.layer.show + } + + set show(value) { + this.layer.show = value + if (this.levelControl) { + value ? this.levelControl.show() : this.levelControl.hide() + } + if (value) { + this.setupLevelDetection() + } else { + this.removeLevelDetection() + } + } + + createSource() { + const apiKey = window.gon?.map_keys?.indoorequal + if (!apiKey) { + console.warn('Indoor Equal API key not found in window.gon.map_keys.indoorequal') + return + } + + if (map.getSource(this.sourceId)) { + console.log('Indoor layer: source ' + this.sourceId + ' already exists, skipping add') + return + } + + console.log('Indoor layer: creating source with API key') + map.addSource(this.sourceId, { + type: 'vector', + tiles: [`https://tiles.indoorequal.org/tiles/{z}/{x}/{y}.pbf?key=${encodeURIComponent(apiKey)}`], + minzoom: 0, + maxzoom: 20, + promoteId: { area: 'id', transportation: 'id' }, + attribution: '© Indoor Equal' + }) + } + + initialize() { + console.log('Indoor layer: initializing with level', this.currentLevel) + removeStyleLayers(this.sourceId) + this.removeLevelDetection() + this.removeLevelControl() + + if (!map.getSource(this.sourceId)) { + console.warn('Indoor layer: source not available, skipping layer initialization') + this.layer.show = false + return Promise.resolve() + } + + const levelFilter = ['==', ['to-string', ['get', 'level']], this.currentLevel] + addIndoorLayers(this.sourceId, levelFilter) + + this.updateFillPaint() + this.setupLevelDetection() + this.setupEventHandlers() + + return Promise.resolve() + } + + loadData() { + return Promise.resolve() + } + + render() { + // No-op - vector tiles render automatically + } + + setupEventHandlers() { + this.removeEventHandlers() + + this.clickHandler = (e) => { + if (window.gon.map_mode !== 'rw') return + + const feature = e.features?.[0] + if (!feature) return + + console.log('Indoor feature clicked:', feature) + console.log('Feature id:', feature.id, 'Source:', this.sourceId) + + feature.properties.label = feature.properties.name || feature.properties.class + feature.properties.desc = indoorDescription(feature.properties) + + const sourceLayer = feature.layer['source-layer'] || feature.sourceLayer + console.log('Source layer:', sourceLayer) + highlightFeature(feature, false, this.sourceId, sourceLayer) + } + + map.on('click', this.getStyleLayerIds(), this.clickHandler) + } + + setLevel(level) { + if (this.currentLevel === level) return + + resetHighlightedFeature() + this.currentLevel = level + const levelFilter = ['==', ['to-string', ['get', 'level']], level] + + const layerIds = getIndoorLayerIds(this.sourceId) + + layerIds.forEach(layerId => { + if (map.getLayer(layerId)) { + map.setFilter(layerId, levelFilter) + } + }) + + this.updateFillPaint() + this.updateLevelControlUI() + } + + updateFillPaint() { + const fillLayerId = `indoor-area-fill_${this.sourceId}` + if (!map.getLayer(fillLayerId)) return + + if (parseFloat(this.currentLevel) >= 1) { + map.setPaintProperty(fillLayerId, 'fill-color', [ + 'case', + ['boolean', ['feature-state', 'active'], false], '#b3d9ff', + 'gray' + ]) + map.setPaintProperty(fillLayerId, 'fill-opacity', 0.7) + } else { + map.setPaintProperty(fillLayerId, 'fill-color', [ + 'case', + ['boolean', ['feature-state', 'active'], false], '#b3d9ff', + indoorFillColor + ]) + map.setPaintProperty(fillLayerId, 'fill-opacity', 0.9) + } + } + + setupLevelDetection() { + this.removeLevelDetection() + + if (this.show === false) return + if (!map.getSource(this.sourceId)) return + + this.idleHandler = () => { + debounce(() => { + // Query source features directly to get ALL levels, not just currently filtered ones + const levelSet = new Set() + + try { + const features = map.querySourceFeatures(this.sourceId, { + sourceLayer: 'area' + }) + // console.log(`${features.length} indoor features in current view`) + + features.forEach(feature => { + const level = feature.properties?.level + if (level !== undefined && level !== null) { + levelSet.add(String(level)) + } + }) + } catch (e) { + // Source might not be loaded yet + console.log('Indoor layer: source not ready for querying', e.message) + return + } + + const newLevels = Array.from(levelSet).sort((a, b) => parseFloat(b) - parseFloat(a)) + + if (JSON.stringify(newLevels) !== JSON.stringify(this.levels)) { + this.levels = newLevels + // console.log('Indoor layer: detected levels', newLevels) + this.updateLevelControl() + } else if (this.levels.length > 0 && !this.levelControl) { + // Recreate control if it was removed (e.g., layer was hidden then shown) + this.updateLevelControl() + } + }, `indoor-level-${this.id}`, 500) + } + + map.on('idle', this.idleHandler) + this.initialTimeout = setTimeout(() => this.idleHandler(), 500) + } + + removeLevelDetection() { + if (this.initialTimeout) { + clearTimeout(this.initialTimeout) + this.initialTimeout = null + } + if (this.idleHandler) { + map.off('idle', this.idleHandler) + this.idleHandler = null + } + } + + updateLevelControl() { + if (this.levels.length > 0) { + if (!this.levelControl) { + this.createLevelControl() + } + this.updateLevelControlUI() + } else { + this.removeLevelControl() + } + updateBuildingOpacity() + } + + createLevelControl() { + this.levelControl = new IndoorLevelControl(this.id, (level) => { + this.setLevel(level) + }) + this.levelControl.create() + } + + updateLevelControlUI() { + if (!this.levelControl) return + this.levelControl.update(this.levels, this.currentLevel) + } + + removeLevelControl() { + if (this.levelControl) { + this.levelControl.remove() + this.levelControl = null + } + } + + cleanup() { + this.removeLevelDetection() + this.removeLevelControl() + super.cleanup() + } +} + +function indoorDescription(props) { + const skipKeys = ['name', 'label', 'desc'] + + let desc = '\n
\n' + desc += '| | |\n' + desc += '| ------------- | ------------- |\n' + + const keys = Object.keys(props).filter(key => !skipKeys.includes(key)) + keys.forEach(key => { + desc += `| **${key}** | ${props[key]} |\n` + }) + + desc += '\n
\n' + + if (props['id']) { + desc += '\n![osm link](/icons/osm-icon-small.png)' + desc += '[See node in OpenStreetMap](https://www.openstreetmap.org/' + props['id'] + ')' + } + + return desc +} diff --git a/app/javascript/maplibre/layers/indoor/styles.js b/app/javascript/maplibre/layers/indoor/styles.js new file mode 100644 index 00000000..b2951947 --- /dev/null +++ b/app/javascript/maplibre/layers/indoor/styles.js @@ -0,0 +1,107 @@ +import { map } from 'maplibre/map' + +export const indoorFillColor = [ + 'match', + ['get', 'class'], + 'room', '#fdbe87', + 'corridor', '#f4c97f', + 'wall', '#c89968', + 'platform', '#f7d794', + 'column', '#b8875e', + 'area', '#f2d9a8', + 'level', '#e5c88d', + '#f2d9a8' +] + +/** + * Adds indoor map style layers for a given source + * Only level plans right now, no POI points + * @param {string} sourceId - The source ID to use for the layers + * @param {Array} levelFilter - MapLibre filter expression array for the current level + */ +export function addIndoorLayers(sourceId, levelFilter) { + map.addLayer({ + id: `indoor-area-fill_${sourceId}`, + type: 'fill', + source: sourceId, + 'source-layer': 'area', + minzoom: 16, + filter: levelFilter, + paint: { + 'fill-color': [ + 'case', + ['boolean', ['feature-state', 'active'], false], '#b3d9ff', + indoorFillColor + ], + 'fill-opacity': 0.9 + } + }) + + map.addLayer({ + id: `indoor-area-extrusion_${sourceId}`, + type: 'fill-extrusion', + source: sourceId, + 'source-layer': 'area', + minzoom: 16, + filter: levelFilter, + paint: { + 'fill-extrusion-color': [ + 'case', + ['boolean', ['feature-state', 'active'], false], '#b3d9ff', + indoorFillColor + ], + 'fill-extrusion-height': ['+', ['*', ['to-number', ['get', 'level']], 5], 5], + 'fill-extrusion-base': ['*', ['to-number', ['get', 'level']], 5], + 'fill-extrusion-opacity': 0.8 + } + }) + + map.addLayer({ + id: `indoor-area-line_${sourceId}`, + type: 'line', + source: sourceId, + 'source-layer': 'area', + minzoom: 16, + filter: levelFilter, + paint: { + 'line-color': [ + 'case', + ['boolean', ['feature-state', 'active'], false], '#000', + '#888' + ], + 'line-width': [ + 'case', + ['boolean', ['feature-state', 'active'], false], 3, + 1 + ] + } + }) + + map.addLayer({ + id: `indoor-transportation_${sourceId}`, + type: 'line', + source: sourceId, + 'source-layer': 'transportation', + minzoom: 16, + filter: levelFilter, + paint: { + 'line-color': '#999', + 'line-width': 2, + 'line-dasharray': [2, 2] + } + }) +} + +/** + * Returns the list of indoor layer IDs for a given source + * @param {string} sourceId - The source ID + * @returns {string[]} Array of layer IDs + */ +export function getIndoorLayerIds(sourceId) { + return [ + `indoor-area-fill_${sourceId}`, + `indoor-area-extrusion_${sourceId}`, + `indoor-area-line_${sourceId}`, + `indoor-transportation_${sourceId}` + ] +} diff --git a/app/javascript/maplibre/map.js b/app/javascript/maplibre/map.js index 628fbd7f..2549c4b8 100644 --- a/app/javascript/maplibre/map.js +++ b/app/javascript/maplibre/map.js @@ -522,6 +522,17 @@ export function setBackgroundMapLayer (mapName = mapProperties.base_map, force = return false } +export function updateBuildingOpacity () { + if (!map || !map.isStyleLoaded()) return + + const opacity = layers?.some(l => l.type === 'indoor' && l.levelControl) ? 0.4 : 0.6 + if (map.getLayer('building-3d')) { + map.setPaintProperty('building-3d', 'fill-extrusion-opacity', opacity) + } else if (map.getLayer('Building 3D')) { + map.setPaintProperty('Building 3D', 'fill-extrusion-opacity', opacity) + } +} + // re-sort layers to overlay geojson layers with labels & extrusion objects // workflows to consider: first map load, basemap update, socket reconnect // sorting (bottom to top): @@ -536,40 +547,38 @@ export function setBackgroundMapLayer (mapName = mapProperties.base_map, force = // here on top of the basemap setStyle in setBackgroundMapLayer left interaction // handlers attached to a stale canvas after a reconnect/background restore. export function sortLayers () { - const layers = map.getStyle().layers + const styleLayers = map.getStyle().layers - // increase opacity of 3D houses - if (map.getLayer('Building 3D')) { - map.setPaintProperty('Building 3D', 'fill-extrusion-opacity', 0.8) - } + updateBuildingOpacity() // Each entry is a layer group; groups are listed bottom-to-top. mapSymbols // excludes user symbol/label layers since the original mutating logic pulled // those out before computing mapSymbols. const groups = [ - layers.filter(e => e.id.startsWith('raster-layer_')), // raster overlays below all geojson layers - layers.filter(e => e.id.startsWith('polygon-layer_geojson-source') && !e.id.includes('extrusion') && !e.id.includes('shadow')), - layers.filter(e => e.id.startsWith('polygon-layer-outline_geojson-source')), - layers.filter(e => e.id.includes('-flat')), // keep flat layers behind houses - layers.filter(e => e.id.startsWith('line-layer-outline_geojson-source')), - layers.filter(e => e.id.startsWith('line-layer_geojson-source') && !e.id.includes('outline')), - layers.filter(e => e.id.includes('route-extras-source') && !e.id.startsWith('route-extras-labels')), - layers.filter(e => e.paint && e.paint['fill-extrusion-height'] && e.id.startsWith('polygon-layer-extrusion')), - layers.filter(e => e.paint && e.paint['fill-extrusion-height'] && !e.id.startsWith('polygon-layer-extrusion')), - layers.filter(e => e.id.startsWith('maplibre-gl-directions')), - layers.filter(e => e.type === 'symbol' && + styleLayers.filter(e => e.id.startsWith('raster-layer_')), // raster overlays below all geojson layers + styleLayers.filter(e => e.id.startsWith('polygon-layer_geojson-source') && !e.id.includes('extrusion') && !e.id.includes('shadow')), + styleLayers.filter(e => e.id.startsWith('polygon-layer-outline_geojson-source')), + styleLayers.filter(e => e.id.includes('-flat')), // keep flat layers behind houses + styleLayers.filter(e => e.id.startsWith('line-layer-outline_geojson-source')), + styleLayers.filter(e => e.id.startsWith('line-layer_geojson-source') && !e.id.includes('outline')), + styleLayers.filter(e => e.id.includes('route-extras-source') && !e.id.startsWith('route-extras-labels')), + styleLayers.filter(e => e.paint && e.paint['fill-extrusion-height'] && e.id.startsWith('polygon-layer-extrusion')), + styleLayers.filter(e => e.id.startsWith('indoor-area-extrusion_')), + styleLayers.filter(e => e.paint && e.paint['fill-extrusion-height'] && !e.id.startsWith('polygon-layer-extrusion') && !e.id.startsWith('indoor-area-extrusion_')), + styleLayers.filter(e => e.id.startsWith('maplibre-gl-directions')), + styleLayers.filter(e => e.type === 'symbol' && !e.id.startsWith('symbols-layer') && !e.id.startsWith('symbols-border-layer') && !e.id.startsWith('text-layer') && !e.id.startsWith('cluster_labels')), - layers.filter(e => e.id.startsWith('points-layer') || e.id.startsWith('cluster_points')), - layers.filter(e => e.id.startsWith('heatmap-layer')), - layers.filter(e => e.id.startsWith('gl-draw-')), - layers.filter(e => e.id.startsWith('km-marker') && !e.id.startsWith('km-marker-end')), - layers.filter(e => e.id.startsWith('route-extras-labels')), - layers.filter(e => e.id.startsWith('km-marker-end')), - layers.filter(e => e.id.startsWith('symbols-layer') || e.id.startsWith('symbols-border-layer')), - layers.filter(e => e.id.startsWith('text-layer') || e.id.startsWith('cluster_labels')), - layers.filter(e => e.id.startsWith('line-layer-hit_geojson-source')), - layers.filter(e => e.id.startsWith('points-hit-layer_geojson-source')) + styleLayers.filter(e => e.id.startsWith('points-layer') || e.id.startsWith('cluster_points')), + styleLayers.filter(e => e.id.startsWith('heatmap-layer')), + styleLayers.filter(e => e.id.startsWith('gl-draw-')), + styleLayers.filter(e => e.id.startsWith('km-marker') && !e.id.startsWith('km-marker-end')), + styleLayers.filter(e => e.id.startsWith('route-extras-labels')), + styleLayers.filter(e => e.id.startsWith('km-marker-end')), + styleLayers.filter(e => e.id.startsWith('symbols-layer') || e.id.startsWith('symbols-border-layer')), + styleLayers.filter(e => e.id.startsWith('text-layer') || e.id.startsWith('cluster_labels')), + styleLayers.filter(e => e.id.startsWith('line-layer-hit_geojson-source')), + styleLayers.filter(e => e.id.startsWith('points-hit-layer_geojson-source')) ] // moveLayer(id) with no second arg moves the layer to the top. Iterating @@ -577,6 +586,7 @@ export function sortLayers () { groups.forEach(group => { group.forEach(layer => { if (map.getLayer(layer.id)) map.moveLayer(layer.id) }) }) + // console.log('Sorted layers: ', map.getStyle().layers) } export function updateMapName (name) { diff --git a/app/models/map.rb b/app/models/map.rb index 43041db7..acc1ff85 100644 --- a/app/models/map.rb +++ b/app/models/map.rb @@ -148,7 +148,8 @@ def self.provider_keys { mapbox: ENV["MAPBOX_KEY"], maptiler: ENV["MAPTILER_KEY"], openrouteservice: ENV["OPENROUTESERVICE_KEY"], - thunderforest: ENV["THUNDERFOREST_KEY"] } + thunderforest: ENV["THUNDERFOREST_KEY"], + indoorequal: ENV["INDOOREQUAL_KEY"] } end def to_json diff --git a/app/views/maps/modals/_layers.haml b/app/views/maps/modals/_layers.haml index 2c39fd26..b9662193 100644 --- a/app/views/maps/modals/_layers.haml +++ b/app/views/maps/modals/_layers.haml @@ -93,6 +93,14 @@ } %i.bi.bi-wikipedia.me-2 Wikipedia articles + - if ENV['INDOOREQUAL_KEY'].present? + %li + %button.dropdown-item{ + type: "button", + data: { action: "click->map--layers#createIndoorLayer" } + } + %i.bi.bi-building.me-2 + OpenStreetMap indoor %li %hr.dropdown-divider %li