From 4d77cc31daf8fe522cab91be6aa5c20b79ef1aeb Mon Sep 17 00:00:00 2001 From: Thomas Schmidt Date: Sat, 23 May 2026 23:12:22 +0200 Subject: [PATCH 1/6] Add indoorequal layer --- app/assets/stylesheets/controls.css | 44 +++++ .../controllers/map/layers_controller.js | 4 + app/javascript/maplibre/controls/shared.js | 10 +- app/javascript/maplibre/edit.js | 2 +- app/javascript/maplibre/layers/factory.js | 4 +- .../maplibre/layers/indoor/control.js | 112 ++++++++++++ .../maplibre/layers/indoor/indoor.js | 165 ++++++++++++++++++ .../maplibre/layers/indoor/styles.js | 96 ++++++++++ app/models/map.rb | 3 +- app/views/maps/modals/_layers.haml | 8 + 10 files changed, 440 insertions(+), 8 deletions(-) create mode 100644 app/javascript/maplibre/layers/indoor/control.js create mode 100644 app/javascript/maplibre/layers/indoor/indoor.js create mode 100644 app/javascript/maplibre/layers/indoor/styles.js 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/edit.js b/app/javascript/maplibre/edit.js index cdf95809..32e2be29 100644 --- a/app/javascript/maplibre/edit.js +++ b/app/javascript/maplibre/edit.js @@ -83,7 +83,7 @@ export async function initializeEditMode () { }, styles: editStyles(), clickBuffer: 5, - touchBuffer: 25, // default 25 + touchBuffer: 20, // default 25 // user properties are available, prefixed with 'user_' userProperties: true, modes 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..bb5d1b7e --- /dev/null +++ b/app/javascript/maplibre/layers/indoor/control.js @@ -0,0 +1,112 @@ +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 + + 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..469d42a6 --- /dev/null +++ b/app/javascript/maplibre/layers/indoor/indoor.js @@ -0,0 +1,165 @@ +import { Layer } from 'maplibre/layers/layer' +import { map, removeStyleLayers } from 'maplibre/map' +import { IndoorLevelControl } from 'maplibre/layers/indoor/control' +import { addIndoorLayers, getIndoorLayerIds } from 'maplibre/layers/indoor/styles' + +export class IndoorLayer extends Layer { + constructor(layer) { + super(layer) + this.currentLevel = '0' + this.levels = [] + this.levelControl = null + this.moveEndHandler = null + } + + 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=${apiKey}`], + minzoom: 0, + maxzoom: 20, + attribution: '© Indoor Equal' + }) + } + + initialize() { + console.log('Indoor layer: initializing with level', this.currentLevel) + removeStyleLayers(this.sourceId) + this.removeLevelControl() + + const levelFilter = ['==', ['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 = ['==', ['get', 'level'], level] + + const layerIds = getIndoorLayerIds(this.sourceId) + + layerIds.forEach(layerId => { + if (map.getLayer(layerId)) { + map.setFilter(layerId, levelFilter) + } + }) + + this.updateLevelControlUI() + } + + setupLevelDetection() { + this.moveEndHandler = () => { + if (!this.show) return + if (!map.getSource(this.sourceId)) return + + // Query source features directly to get ALL levels, not just currently filtered ones + const sourceLayers = ['area'] + const levelSet = new Set() + + sourceLayers.forEach(sourceLayer => { + try { + const features = map.querySourceFeatures(this.sourceId, { + sourceLayer: sourceLayer + }) + + 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) + } + }) + + const newLevels = Array.from(levelSet).sort((a, b) => { + const numA = parseFloat(a) + const numB = parseFloat(b) + return numB - numA + }) + + if (JSON.stringify(newLevels) !== JSON.stringify(this.levels)) { + this.levels = newLevels + console.log('Indoor layer: detected levels', newLevels) + this.updateLevelControl() + } + } + + map.on('moveend', this.moveEndHandler) + map.on('idle', this.moveEndHandler) + map.on('sourcedata', this.moveEndHandler) + setTimeout(() => this.moveEndHandler(), 1000) + } + + 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() { + if (this.moveEndHandler) { + map.off('moveend', this.moveEndHandler) + map.off('idle', this.moveEndHandler) + map.off('sourcedata', this.moveEndHandler) + this.moveEndHandler = null + } + 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..4160529f --- /dev/null +++ b/app/javascript/maplibre/layers/indoor/styles.js @@ -0,0 +1,96 @@ +import { map } from 'maplibre/map' + +/** + * Adds indoor map style layers for a given source + * Only leval plans right now, no POI points + * @param {string} sourceId - The source ID to use for the layers + * @param {string} levelFilter - MapLibre filter expression for the current level + */ +export function addIndoorLayers(sourceId, levelFilter) { + map.addLayer({ + id: `indoor-area-fill_${sourceId}`, + type: 'fill', + source: sourceId, + 'source-layer': 'area', + minzoom: 17, + 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-line_${sourceId}`, + type: 'line', + source: sourceId, + 'source-layer': 'area', + minzoom: 17, + 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: 17, + filter: levelFilter, + paint: { + 'line-color': '#999', + 'line-width': 2, + 'line-dasharray': [2, 2] + } + }) + + map.addLayer({ + id: `indoor-area-label_${sourceId}`, + type: 'symbol', + source: sourceId, + 'source-layer': 'area_name', + minzoom: 18, + filter: levelFilter, + layout: { + 'text-field': ['coalesce', ['get', 'name'], ['get', 'ref']], + 'text-font': ['Noto Sans Regular'], + 'text-size': 10, + 'text-max-width': 10 + }, + paint: { + 'text-color': '#666', + 'text-halo-color': '#fff', + 'text-halo-width': 1 + } + }) +} + +/** + * 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-line_${sourceId}`, + `indoor-transportation_${sourceId}`, + `indoor-area-label_${sourceId}` + ] +} 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 From e1897deabe60dd28456fb384ce826d7338ef9df9 Mon Sep 17 00:00:00 2001 From: Thomas Schmidt Date: Sun, 24 May 2026 11:45:09 +0200 Subject: [PATCH 2/6] improve event handling --- CHANGELOG.md | 2 +- .../maplibre/layers/indoor/indoor.js | 83 +++++++++++-------- 2 files changed, 48 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b42ffb99..72f95b71 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 +* Add 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/javascript/maplibre/layers/indoor/indoor.js b/app/javascript/maplibre/layers/indoor/indoor.js index 469d42a6..36c95d4a 100644 --- a/app/javascript/maplibre/layers/indoor/indoor.js +++ b/app/javascript/maplibre/layers/indoor/indoor.js @@ -9,7 +9,19 @@ export class IndoorLayer extends Layer { this.currentLevel = '0' this.levels = [] this.levelControl = null - this.moveEndHandler = 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() + } } createSource() { @@ -37,6 +49,7 @@ export class IndoorLayer extends Layer { initialize() { console.log('Indoor layer: initializing with level', this.currentLevel) removeStyleLayers(this.sourceId) + this.removeLevelDetection() this.removeLevelControl() const levelFilter = ['==', ['get', 'level'], this.currentLevel] @@ -77,37 +90,31 @@ export class IndoorLayer extends Layer { } setupLevelDetection() { - this.moveEndHandler = () => { + this.idleHandler = () => { if (!this.show) return if (!map.getSource(this.sourceId)) return // Query source features directly to get ALL levels, not just currently filtered ones - const sourceLayers = ['area'] const levelSet = new Set() - sourceLayers.forEach(sourceLayer => { - try { - const features = map.querySourceFeatures(this.sourceId, { - sourceLayer: sourceLayer - }) - - 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) - } - }) - - const newLevels = Array.from(levelSet).sort((a, b) => { - const numA = parseFloat(a) - const numB = parseFloat(b) - return numB - numA - }) + try { + const features = map.querySourceFeatures(this.sourceId, { + sourceLayer: 'area' + }) + + 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 @@ -116,10 +123,19 @@ export class IndoorLayer extends Layer { } } - map.on('moveend', this.moveEndHandler) - map.on('idle', this.moveEndHandler) - map.on('sourcedata', this.moveEndHandler) - setTimeout(() => this.moveEndHandler(), 1000) + map.on('idle', this.idleHandler) + this.initialTimeout = setTimeout(() => this.idleHandler(), 1000) + } + + removeLevelDetection() { + if (this.initialTimeout) { + clearTimeout(this.initialTimeout) + this.initialTimeout = null + } + if (this.idleHandler) { + map.off('idle', this.idleHandler) + this.idleHandler = null + } } updateLevelControl() { @@ -153,12 +169,7 @@ export class IndoorLayer extends Layer { } cleanup() { - if (this.moveEndHandler) { - map.off('moveend', this.moveEndHandler) - map.off('idle', this.moveEndHandler) - map.off('sourcedata', this.moveEndHandler) - this.moveEndHandler = null - } + this.removeLevelDetection() this.removeLevelControl() super.cleanup() } From 72a70f04339c81cd9d9eb4fa3e2aec3e385bcaf1 Mon Sep 17 00:00:00 2001 From: Thomas Schmidt Date: Sun, 24 May 2026 15:58:20 +0200 Subject: [PATCH 3/6] handle edge cases --- .../maplibre/layers/indoor/indoor.js | 18 +++++++++++++++--- .../maplibre/layers/indoor/styles.js | 2 +- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/app/javascript/maplibre/layers/indoor/indoor.js b/app/javascript/maplibre/layers/indoor/indoor.js index 36c95d4a..e4ba953d 100644 --- a/app/javascript/maplibre/layers/indoor/indoor.js +++ b/app/javascript/maplibre/layers/indoor/indoor.js @@ -22,6 +22,11 @@ export class IndoorLayer extends Layer { if (this.levelControl) { value ? this.levelControl.show() : this.levelControl.hide() } + if (value) { + this.setupLevelDetection() + } else { + this.removeLevelDetection() + } } createSource() { @@ -52,6 +57,12 @@ export class IndoorLayer extends Layer { 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 = ['==', ['get', 'level'], this.currentLevel] addIndoorLayers(this.sourceId, levelFilter) @@ -90,10 +101,11 @@ export class IndoorLayer extends Layer { } setupLevelDetection() { - this.idleHandler = () => { - if (!this.show) return - if (!map.getSource(this.sourceId)) return + this.removeLevelDetection() + if (!map.getSource(this.sourceId)) return + + this.idleHandler = () => { // Query source features directly to get ALL levels, not just currently filtered ones const levelSet = new Set() diff --git a/app/javascript/maplibre/layers/indoor/styles.js b/app/javascript/maplibre/layers/indoor/styles.js index 4160529f..2a045157 100644 --- a/app/javascript/maplibre/layers/indoor/styles.js +++ b/app/javascript/maplibre/layers/indoor/styles.js @@ -2,7 +2,7 @@ import { map } from 'maplibre/map' /** * Adds indoor map style layers for a given source - * Only leval plans right now, no POI points + * Only level plans right now, no POI points * @param {string} sourceId - The source ID to use for the layers * @param {string} levelFilter - MapLibre filter expression for the current level */ From b812cb32d494f546a746c817983ed5ab61ce7df0 Mon Sep 17 00:00:00 2001 From: Thomas Schmidt Date: Sun, 24 May 2026 23:59:23 +0200 Subject: [PATCH 4/6] extrusion styles for indoor layers lvel 0+ --- .../maplibre/layers/indoor/styles.js | 42 +++++++++---------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/app/javascript/maplibre/layers/indoor/styles.js b/app/javascript/maplibre/layers/indoor/styles.js index 2a045157..db7baba6 100644 --- a/app/javascript/maplibre/layers/indoor/styles.js +++ b/app/javascript/maplibre/layers/indoor/styles.js @@ -12,7 +12,7 @@ export function addIndoorLayers(sourceId, levelFilter) { type: 'fill', source: sourceId, 'source-layer': 'area', - minzoom: 17, + minzoom: 16, filter: levelFilter, paint: { 'fill-color': [ @@ -28,12 +28,27 @@ export function addIndoorLayers(sourceId, levelFilter) { } }) + 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: 17, + minzoom: 16, filter: levelFilter, paint: { 'line-color': '#000', @@ -51,7 +66,7 @@ export function addIndoorLayers(sourceId, levelFilter) { type: 'line', source: sourceId, 'source-layer': 'transportation', - minzoom: 17, + minzoom: 16, filter: levelFilter, paint: { 'line-color': '#999', @@ -59,26 +74,6 @@ export function addIndoorLayers(sourceId, levelFilter) { 'line-dasharray': [2, 2] } }) - - map.addLayer({ - id: `indoor-area-label_${sourceId}`, - type: 'symbol', - source: sourceId, - 'source-layer': 'area_name', - minzoom: 18, - filter: levelFilter, - layout: { - 'text-field': ['coalesce', ['get', 'name'], ['get', 'ref']], - 'text-font': ['Noto Sans Regular'], - 'text-size': 10, - 'text-max-width': 10 - }, - paint: { - 'text-color': '#666', - 'text-halo-color': '#fff', - 'text-halo-width': 1 - } - }) } /** @@ -89,6 +84,7 @@ export function addIndoorLayers(sourceId, levelFilter) { export function getIndoorLayerIds(sourceId) { return [ `indoor-area-fill_${sourceId}`, + `indoor-area-extrusion_${sourceId}`, `indoor-area-line_${sourceId}`, `indoor-transportation_${sourceId}`, `indoor-area-label_${sourceId}` From daa644e95fe74d39aaf71fcc7b55471b87738605 Mon Sep 17 00:00:00 2001 From: Thomas Schmidt Date: Mon, 25 May 2026 00:00:08 +0200 Subject: [PATCH 5/6] debounce calls --- CHANGELOG.md | 2 +- .../maplibre/layers/indoor/indoor.js | 65 ++++++++++--------- app/javascript/maplibre/map.js | 8 ++- 3 files changed, 41 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72f95b71..1df93190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +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 -* Add indoor layer (vector data from [indoorequal](https://indoorequal.org/)) +* Added indoor layer (vector data from [indoorequal](https://indoorequal.org/)) ## 2026-04 diff --git a/app/javascript/maplibre/layers/indoor/indoor.js b/app/javascript/maplibre/layers/indoor/indoor.js index e4ba953d..710a28a2 100644 --- a/app/javascript/maplibre/layers/indoor/indoor.js +++ b/app/javascript/maplibre/layers/indoor/indoor.js @@ -1,7 +1,8 @@ -import { Layer } from 'maplibre/layers/layer' -import { map, removeStyleLayers } from 'maplibre/map' +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) { @@ -103,40 +104,44 @@ export class IndoorLayer extends Layer { setupLevelDetection() { this.removeLevelDetection() + if (this.show === false) return if (!map.getSource(this.sourceId)) return this.idleHandler = () => { - // 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' - }) - - 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() - } + 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(), 1000) + this.initialTimeout = setTimeout(() => this.idleHandler(), 500) } removeLevelDetection() { diff --git a/app/javascript/maplibre/map.js b/app/javascript/maplibre/map.js index 628fbd7f..717db5a0 100644 --- a/app/javascript/maplibre/map.js +++ b/app/javascript/maplibre/map.js @@ -539,8 +539,8 @@ 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) + if (map.getLayer('building-3d')) { // name in openfreemapLiberty + map.setPaintProperty('building-3d', 'fill-extrusion-opacity', 0.6) } // Each entry is a layer group; groups are listed bottom-to-top. mapSymbols @@ -555,7 +555,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 +578,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) { From 1a04be95e7c316ff72d20708347c302950018890 Mon Sep 17 00:00:00 2001 From: Thomas Schmidt Date: Mon, 25 May 2026 13:46:04 +0200 Subject: [PATCH 6/6] address copilot review comments --- app/javascript/maplibre/layers/indoor/control.js | 7 +++++++ app/javascript/maplibre/layers/indoor/indoor.js | 6 +++--- app/javascript/maplibre/layers/indoor/styles.js | 5 ++--- app/javascript/maplibre/map.js | 5 ++++- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/javascript/maplibre/layers/indoor/control.js b/app/javascript/maplibre/layers/indoor/control.js index bb5d1b7e..a4c2ba1f 100644 --- a/app/javascript/maplibre/layers/indoor/control.js +++ b/app/javascript/maplibre/layers/indoor/control.js @@ -18,6 +18,13 @@ export class IndoorLevelControl { 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) diff --git a/app/javascript/maplibre/layers/indoor/indoor.js b/app/javascript/maplibre/layers/indoor/indoor.js index 710a28a2..1ac9b088 100644 --- a/app/javascript/maplibre/layers/indoor/indoor.js +++ b/app/javascript/maplibre/layers/indoor/indoor.js @@ -45,7 +45,7 @@ export class IndoorLayer extends Layer { 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=${apiKey}`], + tiles: [`https://tiles.indoorequal.org/tiles/{z}/{x}/{y}.pbf?key=${encodeURIComponent(apiKey)}`], minzoom: 0, maxzoom: 20, attribution: '© Indoor Equal' @@ -64,7 +64,7 @@ export class IndoorLayer extends Layer { return Promise.resolve() } - const levelFilter = ['==', ['get', 'level'], this.currentLevel] + const levelFilter = ['==', ['to-string', ['get', 'level']], this.currentLevel] addIndoorLayers(this.sourceId, levelFilter) this.setupLevelDetection() @@ -88,7 +88,7 @@ export class IndoorLayer extends Layer { if (this.currentLevel === level) return this.currentLevel = level - const levelFilter = ['==', ['get', 'level'], level] + const levelFilter = ['==', ['to-string', ['get', 'level']], level] const layerIds = getIndoorLayerIds(this.sourceId) diff --git a/app/javascript/maplibre/layers/indoor/styles.js b/app/javascript/maplibre/layers/indoor/styles.js index db7baba6..7af4868a 100644 --- a/app/javascript/maplibre/layers/indoor/styles.js +++ b/app/javascript/maplibre/layers/indoor/styles.js @@ -4,7 +4,7 @@ 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 {string} levelFilter - MapLibre filter expression for the current level + * @param {Array} levelFilter - MapLibre filter expression array for the current level */ export function addIndoorLayers(sourceId, levelFilter) { map.addLayer({ @@ -86,7 +86,6 @@ export function getIndoorLayerIds(sourceId) { `indoor-area-fill_${sourceId}`, `indoor-area-extrusion_${sourceId}`, `indoor-area-line_${sourceId}`, - `indoor-transportation_${sourceId}`, - `indoor-area-label_${sourceId}` + `indoor-transportation_${sourceId}` ] } diff --git a/app/javascript/maplibre/map.js b/app/javascript/maplibre/map.js index 717db5a0..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')) { // name in openfreemapLiberty + // 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