Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

All notable changes to this project will be documented in this file.

## 2026-07

* Performance improvements for large maps

## 2026-06

* Providing the official [OpenSUSE Conference 2026 map](https://mapforge.org/m/osc26/openSUSE%20Conference%202026)
Expand Down
17 changes: 13 additions & 4 deletions app/controllers/maps_controller.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
class MapsController < ApplicationController
include MapListFilters

before_action :set_map, only: %i[show properties feature destroy copy]
before_action :set_map_mode, only: %i[show]
before_action :set_map, only: %i[show properties feature layer destroy copy]
before_action :set_map_mode, only: %i[show layer]
before_action :join, only: %i[show]
before_action :set_global_js_values, only: %i[show tutorial]
before_action :check_permissions, only: %i[show properties]
before_action :check_permissions, only: %i[show properties layer]
before_action :require_login, only: %i[my create copy]
before_action :require_map_owner, only: %i[destroy]

Expand Down Expand Up @@ -52,6 +52,8 @@ def show
gon.rails_env = Rails.env
gon.csrf_token = form_authenticity_token
gon.map_properties = @map_properties
gon.map_layers = @map.layers.map(&:to_summary_json)
gon.map_updated_at = @map.updated_at

case params["engine"]
when "deck"
Expand All @@ -64,7 +66,7 @@ def show
# updated_at bumps on any feature/layer/map change via the touch chain,
# so it's a sufficient (and private) validator for a 304 on repeat loads.
if stale?(etag: @map.updated_at)
render json: @map.to_json
render json: @map.to_json(include_features: params[:export].present?)
end
end
format.geojson { render json: @map.to_geojson }
Expand Down Expand Up @@ -115,6 +117,13 @@ def feature
end
end

def layer
layer = @map.layers.find(params["layer_id"])
head :not_found and return unless layer
# Sending 304 on repeat loads un unchanged layer.
render json: layer.to_geojson if stale?(etag: layer.updated_at)
end
Comment thread
digitaltom marked this conversation as resolved.

# Turbo sends the DELETE request automatically with Content-Type: text/vnd.turbo-stream.html
# We can return a turbo stream command that removes the map element in place
# To avoid turbo_stream response, force format :html
Expand Down
23 changes: 14 additions & 9 deletions app/javascript/channels/map_channel.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import consumer from 'channels/consumer'
import { createLayerInstance } from 'maplibre/layers/factory'
import { initializeLayerSources, initializeLayerStyles, layers, loadLayerDefinitions } from 'maplibre/layers/layers'
import { initializeLayerSources, initializeLayerStyles, layers, loadLayerDefinitions, resetLayerInitialization } from 'maplibre/layers/layers'
import {
destroyFeature,
initializeMaplibreProperties,
Expand Down Expand Up @@ -45,11 +45,11 @@ export function initializeSocket () {
console.log('Connected to map_channel ' + window.gon.map_id)
mapChannel = this
window.mapChannel = mapChannel
// On reconnect (channelStatus === 'off'), defer the 'online' event until
// after the reload chain finishes — otherwise data-online='true' fires
// before window.gon catches up, racing against tests and any code that
// reads map_properties on reconnect.
// On reconnect (channelStatus === 'off'), defer the 'online' event
if (channelStatus === 'off') {
// Rebuild layers directly (rather than initializeLayers()) to force a refetch and handle
// a possible basemap change; reset the memoization so a later initializeLayers() re-runs.
resetLayerInitialization()
reloadMapProperties().then(() => {
const propsChanged = initializeMaplibreProperties()
// Only reload layer data if the map actually changed while we were disconnected.
Expand All @@ -60,7 +60,9 @@ export function initializeSocket () {
const dataChanged = window.gon.map_updated_at !== loadedMapUpdatedAt
if (dataChanged && !reloadInProgress) {
reloadInProgress = true
loadLayerDefinitions().then(async () => {
// refetch: true forces a fresh pull from the server (bypassing the gon-embedded
// summaries used on initial load) so we pick up whatever changed while offline.
loadLayerDefinitions({ refetch: true }).then(async () => {
// If basemap actually changed, setBackgroundMapLayer() will trigger
// initializeStyles() via style.load (which re-initializes layer sources/styles).
// If not, we re-initialize them directly to catch up on any missed updates.
Expand Down Expand Up @@ -193,13 +195,16 @@ export function initializeSocket () {
},

send_message (event, data) {
// copy feature to avoid mutation
const payload = JSON.parse(JSON.stringify(data))
// shallow copy to avoid mutating caller's data; geometry is never touched so it's left shared
const payload = { ...data }
payload.map_id = window.gon.map_id
payload.user_id = window.gon.user_id
payload.uuid = connectionUUID
// dropping properties.id before sending to server
if (payload.properties && payload.properties.id) { delete payload.properties.id }
if (payload.properties && payload.properties.id) {
payload.properties = { ...payload.properties }
delete payload.properties.id
}
if (event !== 'mouse') console.log('Sending: [' + event + '] :', payload)
// Call the original perform method
this.perform(event, payload)
Expand Down
78 changes: 58 additions & 20 deletions app/javascript/maplibre/layers/geojson.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { buffer } from "@turf/buffer"
import { draw, select } from 'maplibre/edit'
import { initializeKmMarkerStyles, renderKmMarkers } from 'maplibre/layers/geojson/km_markers'
import { detectLevels, filterFeaturesByLevel } from 'maplibre/layers/geojson/levels'
import { detectLevels, filterFeaturesByLevel, getActiveLevel } from 'maplibre/controls/levels'
import { initializeExtrasLabelStyles, renderRouteExtras } from 'maplibre/layers/geojson/route_extras'
import { Layer } from 'maplibre/layers/layer'
import { getFeature } from 'maplibre/layers/layers'
Expand Down Expand Up @@ -41,6 +41,10 @@ export class GeoJSONLayer extends Layer {
return `extrusion-source-${this.id}`
}

get dataUrl() {
return `/m/${window.gon.map_id}/layer/${this.id}.geojson`
}

createSource() {
super.createSource()
addGeoJSONSource(this.kmMarkerSourceId, false)
Expand All @@ -63,7 +67,7 @@ export class GeoJSONLayer extends Layer {
initializeExtrasLabelStyles(this.routeExtrasSourceId)
initializeViewStyles(this.extrusionSourceId)

// Exclude features with route extras from the main line layers (they're rendered in route-extras-source instead)
// Exclude features with route extras from the main linestring layers (they're rendered in route-extras-source instead)
const mainLineFilter = ['all',
['==', ['geometry-type'], 'LineString'],
['!', ['has', 'show-route-extras']],
Expand All @@ -78,17 +82,51 @@ export class GeoJSONLayer extends Layer {
map.setLayoutProperty(`line-layer_${this.routeExtrasSourceId}`, 'line-cap', 'butt')

this.setupEventHandlers()
this.render()
return Promise.resolve()
return this.loadData()
}

render(resetDraw = true) {
console.log("Redraw: Setting source data for geojson layer", this.layer)
// setData(url) lets MapLibre fetch AND parse the features in its web worker (off the main
// thread, so the UI stays responsive even for large layers); once loaded we read them back
// via getData() into this.layer.geojson (for lookup/sync/derived sources) without a 2nd request.
loadData() {
const sourceId = this.sourceId
const source = map.getSource(sourceId)
return new Promise((resolve) => {
const cleanup = () => {
map.off('sourcedata', onData)
map.off('error', onError)
}
const onData = (e) => {
// make sure to only act on the completed load of this source
if (e.sourceId !== sourceId || e.sourceDataType === 'metadata' || !map.isSourceLoaded(sourceId)) { return }
cleanup()
source.getData()
.then(geojson => { this.layer.geojson = geojson; this.render(true, { sourceLoaded: true }); resolve(geojson) })
.catch(error => { console.error(`Failed to read data for ${sourceId}`, error); resolve() })
}
// A failed URL fetch fires an 'error' event (not a completed 'sourcedata'). Resolve rather
// than reject so a single failing layer doesn't hang initializeLayerStyles' Promise.all.
const onError = (e) => {
if (e.sourceId !== sourceId) { return }
cleanup()
console.error(`Failed to load data for ${sourceId}`, e.error)
resolve()
}
map.on('sourcedata', onData)
map.on('error', onError)
source.setData(this.dataUrl)
})
}

// sourceLoaded=true means the main MapLibre source already holds this.layer.geojson
// (loadData just streamed it from the layer URL). When no level filter is active, the
// source already holds exactly the features we'd upload, so skip the redundant re-parse.
render(resetDraw = true, { sourceLoaded = false } = {}) {
if (!this.layer?.geojson?.features) { return }
this.ensureFeaturePropertyIds()
const source = map.getSource(this.sourceId)
if (!source) { console.warn(`Source ${this.sourceId} not found, skipping render`); return }

// Signal that GeoJSON is re-rendering (set to false)
map.getContainer().setAttribute('data-geojson-loaded', 'false')
this.ensureFeaturePropertyIds()

// Detect available levels first so activeLevel is defaulted before filtering
detectLevels()
Expand All @@ -99,19 +137,19 @@ export class GeoJSONLayer extends Layer {
renderKmMarkers(filteredFeatures, this.kmMarkerSourceId)
renderRouteExtras(filteredFeatures, this.routeExtrasSourceId)
this.renderExtrusionLines(filteredFeatures)
const geojson = { type: 'FeatureCollection', features: filteredFeatures }

const source = map.getSource(this.sourceId)
if (!source) {
console.warn(`Source ${this.sourceId} not found, skipping render`)
return
}
source.setData(geojson, false)

// Wait for MapLibre to complete the render, then signal completion
map.once('render', () => {
if (sourceLoaded && !getActiveLevel()) {
// MapLibre's URL load already holds exactly this set; don't re-parse it.
map.getContainer().setAttribute('data-geojson-loaded', 'true')
})
} else {
console.log("Redraw: Setting source data for geojson layer", this.layer)
map.getContainer().setAttribute('data-geojson-loaded', 'false')
source.setData({ type: 'FeatureCollection', features: filteredFeatures }, false)
// Wait for MapLibre to complete the render, then signal completion
map.once('render', () => {
map.getContainer().setAttribute('data-geojson-loaded', 'true')
})
}

this.resetDrawFeatures(resetDraw)
}
Expand Down
32 changes: 20 additions & 12 deletions app/javascript/maplibre/layers/layer.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,24 +191,32 @@ export class Layer {
filter: ['!', ['has', 'cluster']]
})

// Sort by ID so cycling order is stable even after frontFeature() reorders the source
const stack = [...new Map(allFeatures
// queryRenderedFeatures returns features top-most first → visual z-order
const selectable = allFeatures
.filter(f => !f.properties?.cluster && SELECTABLE_SOURCE_PREFIXES.some(p => f.source.startsWith(p)))
.map(f => [f.id, f])).values()]
.sort((a, b) => String(a.id).localeCompare(String(b.id)))
if (!stack.length) { return }

const isViewMode = window.gon.map_mode === 'ro' || e.originalEvent.shiftKey
const clickableStack = isViewMode
? stack.filter(f => f.properties?.onclick !== false)
: stack
const clickable = isViewMode
? selectable.filter(f => f.properties?.onclick !== false)
: selectable
if (!clickable.length) { return }

// Visual top = first in z-order (matches hover behavior)
const visualTop = clickable[0]

if (!clickableStack.length) { return }
// Stable cycling order, deduped by id, independent of z-order reshuffling by frontFeature()
const clickableStack = [...new Map(clickable.map(f => [f.id, f])).values()]
.sort((a, b) => String(a.id).localeCompare(String(b.id)))

const currentIdx = clickableStack.findIndex(f => f.id === highlightedFeatureId)
let feature = (currentIdx === -1 || currentIdx === clickableStack.length - 1)
? clickableStack[0]
: clickableStack[currentIdx + 1]
let feature
if (!stickyFeatureHighlight || currentIdx === -1) {
feature = visualTop // fresh click → top feature
} else {
feature = currentIdx === clickableStack.length - 1 // re-click → advance cycle
? clickableStack[0]
: clickableStack[currentIdx + 1]
}

if (isViewMode) {

Expand Down
Loading
Loading