Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c336539
Handle geojson layers like other layers
digitaltom Feb 2, 2026
e30eb46
Update @turf/bbox to version 7.3.3
depfu[bot] Jan 31, 2026
0fe1003
Update @turf/boolean-point-on-line to version 7.3.3
depfu[bot] Jan 31, 2026
65b7335
Update rubycritic to version 4.12.0
depfu[bot] Feb 1, 2026
ece1224
Update globals to version 17.3.0
depfu[bot] Feb 2, 2026
a28ebd3
generalize layer - source naming
digitaltom Feb 3, 2026
e78f365
Apply suggestion from @Copilot
digitaltom Feb 3, 2026
875194f
only select geojson features for edit
digitaltom Feb 3, 2026
7b43bd1
update brakeman
digitaltom Feb 4, 2026
d50c5ac
fix import
digitaltom Feb 4, 2026
e5990ec
load layers directly into layers var
digitaltom Feb 4, 2026
c406f0a
move geojson layer methods
digitaltom Feb 5, 2026
2ace993
start to move render methods into specific layers
digitaltom Feb 5, 2026
bfd3198
Update app/javascript/maplibre/feature.js
digitaltom Feb 5, 2026
b759397
Merge branch 'main' into unify_layer_handling
digitaltom Feb 5, 2026
e7600a8
autodetect flyto source
digitaltom Feb 5, 2026
fc574be
wait for geojson load before styling
digitaltom Feb 6, 2026
d26418e
use layer specific redraw
digitaltom Feb 6, 2026
1ae8389
move km markers to layers
digitaltom Feb 6, 2026
8deee4a
mock more overpass calls
digitaltom Feb 6, 2026
dd81d6a
migrate redrawGeojson calls
digitaltom Feb 7, 2026
1a86f16
migrate undo
digitaltom Feb 7, 2026
0ef4355
migrate off from redrawGeojson
digitaltom Feb 7, 2026
0bef4f9
avoid recursion
digitaltom Feb 7, 2026
b3f7b57
fix line symbols
digitaltom Feb 7, 2026
0717112
fix km markers
digitaltom Feb 7, 2026
f65d36e
improved layer loading promises
digitaltom Feb 7, 2026
bf4b86d
support heatmap + cluster again in overpass
digitaltom Feb 7, 2026
e2b0c6c
cluster + label wikipedia articles
digitaltom Feb 7, 2026
68a2caf
fix contours, allow cluster for geojson layer
digitaltom Feb 7, 2026
294da9d
text changes
digitaltom Feb 7, 2026
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 Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ GEM
bindex (0.8.1)
bootsnap (1.21.1)
msgpack (~> 1.2)
brakeman (8.0.1)
brakeman (8.0.2)
racc
bson (5.2.0)
builder (3.3.0)
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@

# Mapforge

Mapforge is an open source web application that lets you create and share your places, tracks and events as GeoJSON layers on top of different base maps. It uses [MapLibre GL JS](https://maplibre.org/maplibre-gl-js/docs/) as its map library and supports both desktop and mobile.
Mapforge is an open source, easy to use GIS software. It's a web application that lets you create and share your places, tracks and events as GeoJSON layers on top of different base maps. It uses [MapLibre GL JS](https://maplibre.org/maplibre-gl-js/docs/) as map library and supports both desktop and mobile.

The browser connects to the server via WebSockets, so that changes are immediately synced to all clients. This enables collaborative editing and sharing real-time maps.
Your browser connects to the server via WebSockets, so that changes are immediately synced to all clients. This enables collaborative editing and sharing real-time maps.

The main instance is running at [mapforge.org](https://mapforge.org), see [self-hosting](#selfhosting) how to run your own. Check the [changelog](CHANGELOG.md) for recent changes.

Expand Down
2 changes: 1 addition & 1 deletion app/channels/map_channel.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ def map_atts(data)
end

def layer_atts(data)
data.slice("id", "type", "name", "query")
data.slice("id", "type", "name", "query", "heatmap", "cluster")
end

# load map with write access
Expand Down
1 change: 0 additions & 1 deletion app/controllers/maps_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ 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)

case params["engine"]
when "deck"
Expand Down
28 changes: 13 additions & 15 deletions app/javascript/channels/map_channel.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import consumer from 'channels/consumer'
import {
upsert, destroyFeature, setBackgroundMapLayer, mapProperties,
initializeMaplibreProperties, map, layers, resetGeojsonLayers, loadLayers,
reloadMapProperties, removeGeoJSONSource, redrawGeojson
} from 'maplibre/map'
import { initializeLayers } from 'maplibre/layers/layers'
import { initLayersModal } from 'maplibre/controls/shared'
initializeMaplibreProperties, map,
reloadMapProperties } from 'maplibre/map'
import { layers, initializeLayerStyles, loadLayerDefinitions } from 'maplibre/layers/layers'


export let mapChannel
Expand Down Expand Up @@ -41,10 +39,10 @@ export function initializeSocket () {
if (channelStatus === 'off') {
reloadMapProperties().then(() => {
initializeMaplibreProperties()
resetGeojsonLayers()
loadLayers()
setBackgroundMapLayer(mapProperties.base_map, false)
map.fire('load', { detail: { message: 'Map re-loaded by map_channel' } })
loadLayerDefinitions().then(() => {
setBackgroundMapLayer(mapProperties.base_map, true)
map.fire('load', { detail: { message: 'Map re-loaded by map_channel' } })
})
// status('Connection to server re-established')
})
} else {
Expand Down Expand Up @@ -102,20 +100,20 @@ export function initializeSocket () {
const { ['geojson']: _, ...layerDef } = layers[index]
if (JSON.stringify(layerDef) !== JSON.stringify(data.layer)) {
layers[index] = data.layer
initializeLayers(data.layer.id)
console.log('Layer updated on server, reloading layer styles', data.layer)
initializeLayerStyles(data.layer.id)
}
} else {
layers.push(data.layer)
initializeLayers(data.layer.id)
initializeLayerStyles(data.layer.id)
}
break
case 'delete_layer':
const delIndex = layers.findIndex(l => l.id === data.layer.id)
if (delIndex > -1) {
layers.splice(delIndex, 1)
removeGeoJSONSource('overpass-source-' + data.layer.id)
initLayersModal()
redrawGeojson()
// trigger a full map redraw
setBackgroundMapLayer(mapProperties.base_map, true)
}
break
case 'mouse':
Expand Down Expand Up @@ -150,7 +148,7 @@ export function initializeSocket () {
payload.map_id = window.gon.map_id
payload.user_id = window.gon.user_id
payload.uuid = connectionUUID
// dropping properties.id from redrawGeojson() before sending to server
// dropping properties.id before sending to server
if (payload.properties && payload.properties.id) { delete payload.properties.id }
if (event !== 'mouse') console.log('Sending: [' + event + '] :', payload)
// Call the original perform method
Expand Down
84 changes: 46 additions & 38 deletions app/javascript/controllers/feature/edit_controller.js
Original file line number Diff line number Diff line change
@@ -1,38 +1,46 @@
import { Controller } from '@hotwired/stimulus'
import { mapChannel } from 'channels/map_channel'
import { geojsonData, redrawGeojson } from 'maplibre/map'
import { featureIcon, featureImage, uploadImageToFeature, confirmImageLocation } from 'maplibre/feature'
import { handleDelete, draw } from 'maplibre/edit'
import { featureColor, featureOutlineColor } from 'maplibre/styles'
import { status } from 'helpers/status'
import * as functions from 'helpers/functions'
import * as dom from 'helpers/dom'
import { addUndoState } from 'maplibre/undo'
import { getFeature, getLayer } from 'maplibre/layers/layers'
import { renderGeoJSONLayer } from 'maplibre/layers/geojson'

export default class extends Controller {
// https://stimulus.hotwired.dev/reference/values
static values = {
featureId: String
featureId: String,
layerId: String
}

// emoji picker
picker = null

featureIdValueChanged(value) {
if (value) {
this.layerIdValue = getLayer(value).id
}
}

delete_feature (e) {
if (dom.isInputElement(e.target)) return // Don't trigger if typing in input

const feature = this.getFeature()
const feature = this.getEditFeature()
if (confirm(`Really delete this ${feature.geometry.type}?`)) {
handleDelete({ features: [feature] })
}
}

update_feature_raw () {
const feature = this.getFeature()
const feature = this.getEditFeature()
document.querySelector('#feature-edit-raw .error').innerHTML = ''
try {
feature.properties = JSON.parse(document.querySelector('#feature-edit-raw textarea').value)
redrawGeojson()
renderGeoJSONLayer(this.layerIdValue, true)
mapChannel.send_message('update_feature', feature)
} catch (error) {
console.error('Error updating feature:', error.message)
Expand All @@ -42,96 +50,96 @@ export default class extends Controller {
}

updateTitle () {
const feature = this.getFeature()
const feature = this.getEditFeature()
const title = document.querySelector('#feature-title-input input').value
feature.properties.title = title
document.querySelector('#feature-title').textContent = title
functions.debounce(() => { this.saveFeature() }, 'title')
}

updateLabel () {
const feature = this.getFeature()
const feature = this.getEditFeature()
const label = document.querySelector('#feature-label input').value
feature.properties.label = label
redrawGeojson(false)
renderGeoJSONLayer(this.layerIdValue, false)
functions.debounce(() => { this.saveFeature() }, 'label', 1000)
}

// called as preview on slider change
updatePointSize () {
const feature = this.getFeature()
const feature = this.getEditFeature()
const size = document.querySelector('#point-size').value
document.querySelector('#point-size-val').textContent = size
feature.properties['marker-size'] = size
// draw layer feature properties aren't getting updated by draw.set()
draw.setFeatureProperty(this.featureIdValue, 'marker-size', size)
redrawGeojson(true)
renderGeoJSONLayer(this.layerIdValue, true)
}

updatePointScaling() {
const feature = this.getFeature()
const feature = this.getEditFeature()
const val = document.querySelector('#point-scaling').checked
feature.properties['marker-scaling'] = val
// draw layer feature properties aren't getting updated by draw.set()
draw.setFeatureProperty(this.featureIdValue, 'marker-scaling', val)
redrawGeojson(true)
renderGeoJSONLayer(this.layerIdValue, true)
}

// called as preview on slider change
updateLineWidth () {
const feature = this.getFeature()
const feature = this.getEditFeature()
const size = document.querySelector('#line-width').value
document.querySelector('#line-width-val').textContent = size
feature.properties['stroke-width'] = size
// draw layer feature properties aren't getting updated by draw.set()
draw.setFeatureProperty(this.featureIdValue, 'stroke-width', size)
redrawGeojson(true)
renderGeoJSONLayer(this.layerIdValue, true)
}

// called as preview on slider change
updateOutLineWidth () {
const feature = this.getFeature()
const feature = this.getEditFeature()
const size = document.querySelector('#outline-width').value
document.querySelector('#outline-width-val').textContent = size
feature.properties['stroke-width'] = size
// draw layer feature properties aren't getting updated by draw.set()
draw.setFeatureProperty(this.featureIdValue, 'stroke-width', size)
redrawGeojson(true)
renderGeoJSONLayer(this.layerIdValue, true)
}

// called as preview on slider change
updateFillExtrusionHeight () {
const feature = this.getFeature()
const feature = this.getEditFeature()
const size = document.querySelector('#fill-extrusion-height').value
document.querySelector('#fill-extrusion-height-val').textContent = size + 'm'
feature.properties['fill-extrusion-height'] = Number(size)
// draw layer feature properties aren't getting updated by draw.set()
draw.setFeatureProperty(this.featureIdValue, 'fill-extrusion-height', Number(size))
// needs redraw to add extrusion
redrawGeojson(true)
renderGeoJSONLayer(this.layerIdValue, true)
}

updateOpacity () {
const feature = this.getFeature()
const feature = this.getEditFeature()
const opacity = document.querySelector('#opacity').value / 10
document.querySelector('#opacity-val').textContent = opacity * 100 + '%'
feature.properties['fill-opacity'] = opacity
// draw layer feature properties aren't getting updated by draw.set()
draw.setFeatureProperty(this.featureIdValue, 'fill-opacity', opacity)
redrawGeojson(true)
renderGeoJSONLayer(this.layerIdValue, true)
}

updateStrokeColor () {
const feature = this.getFeature()
const feature = this.getEditFeature()
const color = document.querySelector('#stroke-color').value
feature.properties.stroke = color
// draw layer feature properties aren't getting updated by draw.set()
draw.setFeatureProperty(this.featureIdValue, 'stroke', color)
redrawGeojson(true)
renderGeoJSONLayer(this.layerIdValue, true)
}

updateStrokeColorTransparent () {
const feature = this.getFeature()
const feature = this.getEditFeature()
let color
if (document.querySelector('#stroke-color-transparent').checked) {
color = 'transparent'
Expand All @@ -142,19 +150,19 @@ export default class extends Controller {
document.querySelector('#stroke-color').removeAttribute('disabled')
}
feature.properties.stroke = color
redrawGeojson(true)
renderGeoJSONLayer(this.layerIdValue, true)
}

updateFillColor () {
const feature = this.getFeature()
const feature = this.getEditFeature()
const color = document.querySelector('#fill-color').value
if (feature.geometry.type === 'Polygon' || feature.geometry.type === 'MultiPolygon') { feature.properties.fill = color }
if (feature.geometry.type === 'Point') { feature.properties['marker-color'] = color }
redrawGeojson(true)
renderGeoJSONLayer(this.layerIdValue, true)
}

updateFillColorTransparent () {
const feature = this.getFeature()
const feature = this.getEditFeature()
let color
if (document.querySelector('#fill-color-transparent').checked) {
color = 'transparent'
Expand All @@ -166,23 +174,23 @@ export default class extends Controller {
}
if (feature.geometry.type === 'Polygon' || feature.geometry.type === 'MultiPolygon') { feature.properties.fill = color }
if (feature.geometry.type === 'Point') { feature.properties['marker-color'] = color }
redrawGeojson(true)
renderGeoJSONLayer(this.layerIdValue, true)
}

updateShowKmMarkers () {
const feature = this.getFeature()
const feature = this.getEditFeature()
if (document.querySelector('#show-km-markers').checked) {
feature.properties['show-km-markers'] = true
// feature.properties['stroke-image-url'] = "/icons/direction-arrow.png"
} else {
delete feature.properties['show-km-markers']
delete feature.properties['stroke-image-url']
}
redrawGeojson(false)
renderGeoJSONLayer(this.layerIdValue, true)
}

updateMarkerSymbol () {
const feature = this.getFeature()
const feature = this.getEditFeature()
let symbol = document.querySelector('#marker-symbol').value
document.querySelector('#emoji').textContent = symbol
// strip variation selector (emoji) U+FE0F to match icon file names
Expand All @@ -191,11 +199,11 @@ export default class extends Controller {
// draw layer feature properties aren't getting updated by draw.set()
draw.setFeatureProperty(this.featureIdValue, 'marker-symbol', symbol)
functions.e('.feature-symbol', e => { e.innerHTML = featureIcon(feature) })
redrawGeojson(true)
renderGeoJSONLayer(this.layerIdValue, true)
}

async updateMarkerImage () {
const feature = this.getFeature()
const feature = this.getEditFeature()
const image = document.querySelector('#marker-image').files[0]
const imageLocation = await confirmImageLocation(image)
if (imageLocation) { feature.geometry.coordinates = imageLocation }
Expand All @@ -214,7 +222,7 @@ export default class extends Controller {
functions.e('.feature-symbol', e => { e.innerHTML = featureIcon(feature) })
functions.e('.feature-image', e => { e.innerHTML = featureImage(feature) })

redrawGeojson(true)
renderGeoJSONLayer(this.layerIdValue, true)
this.saveFeature()
})
}
Expand Down Expand Up @@ -265,19 +273,19 @@ export default class extends Controller {
}

saveFeature () {
const feature = this.getFeature()
const feature = this.getEditFeature()
status('Saving feature ' + feature.id)
// send shallow copy of feature to avoid changes during send
mapChannel.send_message('update_feature', { ...feature })
}

addUndo() {
const feature = this.getFeature()
const feature = this.getEditFeature()
addUndoState('Feature property update', feature)
}

getFeature () {
getEditFeature () {
const id = this.featureIdValue
return geojsonData.features.find(f => f.id === id)
return getFeature(id)
}
Comment on lines +287 to 290
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getEditFeature() calls getFeature(id) but this file does not import getFeature, so the controller will crash when invoked. Import getFeature from maplibre/layers/layers (and consider handling null if the feature cannot be found).

Copilot uses AI. Check for mistakes.
}
Loading