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/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..a4c2ba1f --- /dev/null +++ b/app/javascript/maplibre/layers/indoor/control.js @@ -0,0 +1,119 @@ +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 + } + + /** + * 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) { + tooltip.dispose() + } + }) + } + + /** + * 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() + } + + 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) + } + + /** + * Removes the control from the DOM + */ + remove() { + if (this.element && this.element.parentNode) { + this.disposeTooltips() + this.element.parentNode.removeChild(this.element) + } + this.element = 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..1ac9b088 --- /dev/null +++ b/app/javascript/maplibre/layers/indoor/indoor.js @@ -0,0 +1,193 @@ +import { debounce } from 'helpers/functions' +import { IndoorLevelControl } from 'maplibre/layers/indoor/control' +import { addIndoorLayers, getIndoorLayerIds } from 'maplibre/layers/indoor/styles' +import { Layer } from 'maplibre/layers/layer' +import { map, removeStyleLayers } 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, + 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.setupLevelDetection() + + return Promise.resolve() + } + + loadData() { + return Promise.resolve() + } + + render() { + // No-op - vector tiles render automatically + } + + setupEventHandlers() { + // No-op - indoor features are not selectable like GeoJSON features + } + + setLevel(level) { + if (this.currentLevel === level) return + + 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.updateLevelControlUI() + } + + 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() + } + }, `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() + } + } + + 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() + } +} diff --git a/app/javascript/maplibre/layers/indoor/styles.js b/app/javascript/maplibre/layers/indoor/styles.js new file mode 100644 index 00000000..7af4868a --- /dev/null +++ b/app/javascript/maplibre/layers/indoor/styles.js @@ -0,0 +1,91 @@ +import { map } from 'maplibre/map' + +/** + * 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': [ + 'match', + ['get', 'class'], + 'room', '#fdfcfa', + 'corridor', '#fefefe', + 'platform', '#e8edff', + 'wall', '#d5d5d5', + '#f0f0f0' + ], + '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': '#fdbe87', + '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': '#000', + 'line-width': [ + 'match', + ['get', 'class'], + 'wall', 3, + 2 + ] + } + }) + + 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..7ca0a3e2 100644 --- a/app/javascript/maplibre/map.js +++ b/app/javascript/maplibre/map.js @@ -539,8 +539,11 @@ export function sortLayers () { const layers = map.getStyle().layers // increase opacity of 3D houses - if (map.getLayer('Building 3D')) { - map.setPaintProperty('Building 3D', 'fill-extrusion-opacity', 0.8) + // Handle both 'building-3d' (openfreemapLiberty) and 'Building 3D' (MapTiler) + if (map.getLayer('building-3d')) { + map.setPaintProperty('building-3d', 'fill-extrusion-opacity', 0.6) + } else if (map.getLayer('Building 3D')) { + map.setPaintProperty('Building 3D', 'fill-extrusion-opacity', 0.6) } // Each entry is a layer group; groups are listed bottom-to-top. mapSymbols @@ -555,7 +558,8 @@ export function sortLayers () { 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('indoor-area-extrusion_')), + layers.filter(e => e.paint && e.paint['fill-extrusion-height'] && !e.id.startsWith('polygon-layer-extrusion') && !e.id.startsWith('indoor-area-extrusion_')), layers.filter(e => e.id.startsWith('maplibre-gl-directions')), layers.filter(e => e.type === 'symbol' && !e.id.startsWith('symbols-layer') && !e.id.startsWith('symbols-border-layer') && @@ -577,6 +581,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