Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
44 changes: 44 additions & 0 deletions app/assets/stylesheets/controls.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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>');
Expand Down
4 changes: 4 additions & 0 deletions app/javascript/controllers/map/layers_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,10 @@ export default class extends Controller {
this.createLayer('basemap', 'Basemap layer')
}

createIndoorLayer(_event) {
this.createLayer('indoor', 'Indoor map')
}
Comment on lines +348 to +350

createLayer(type, name, query=null, geojson=null) {
let layerId = functions.featureId()
// must match server attribute order, for proper comparison in map_channel
Expand Down
10 changes: 5 additions & 5 deletions app/javascript/maplibre/controls/shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down
32 changes: 23 additions & 9 deletions app/javascript/maplibre/feature.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ window.marked = marked

export let highlightedFeatureId
export let highlightedFeatureSource
export let highlightedSourceLayer = null
export let stickyFeatureHighlight = false
let elevationChart

Expand Down Expand Up @@ -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')) {
Expand All @@ -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() }
Expand All @@ -278,21 +285,28 @@ 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))
.flatMap(layer => layer.geojson.features)
.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)
}
}

Expand Down
4 changes: 3 additions & 1 deletion app/javascript/maplibre/layers/factory.js
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -10,7 +11,8 @@ const layerTypes = {
overpass: OverpassLayer,
wikipedia: WikipediaLayer,
basemap: BasemapLayer,
raster: RasterLayer
raster: RasterLayer,
indoor: IndoorLayer
}

export function createLayerInstance(data) {
Expand Down
141 changes: 141 additions & 0 deletions app/javascript/maplibre/layers/indoor/control.js
Original file line number Diff line number Diff line change
@@ -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)
}
Comment on lines +19 to +36
}

/**
* 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'
}
Comment on lines +126 to +139
}
}
Loading
Loading