Skip to content
2 changes: 1 addition & 1 deletion app/assets/stylesheets/docs.css
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ nav.fixed-top~#docs {
padding-bottom: 0.4rem;
width: fit-content;
border-bottom: 0.15rem solid transparent;
border-image: linear-gradient(to right, var(--color-dark-moss-green), var(--color-light-sand)) 1;
border-image: linear-gradient(to right, var(--color-light-sand), var(--color-dark-moss-green)) 1;
}

h2, h3, h4 {
Expand Down
18 changes: 9 additions & 9 deletions app/javascript/channels/map_channel.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import consumer from 'channels/consumer'
import { initializeLayerStyles, layers, loadLayerDefinitions } from 'maplibre/layers/layers'
import { createLayerInstance } from 'maplibre/layers/factory'
import { initializeLayerSources, initializeLayerStyles, layers, loadLayerDefinitions } from 'maplibre/layers/layers'
import {
destroyFeature,
initializeMaplibreProperties, map,
Expand Down Expand Up @@ -101,25 +102,24 @@ export function initializeSocket () {
case 'update_layer':
const index = layers.findIndex(l => l.id === data.layer.id)
if (index > -1) {
// Remove geojson key before comparison
const { ['geojson']: _, ...layerDef } = layers[index]
const layerDef = layers[index].toJSON()
if (JSON.stringify(layerDef) !== JSON.stringify(data.layer)) {
// preserve geojson data when updating layer definition
const geojson = layers[index].geojson
layers[index] = data.layer
if (geojson) { layers[index].geojson = geojson }
console.log('Layer updated on server, reloading layer styles', data.layer)
layers[index].update(data.layer)
initializeLayerStyles(data.layer.id)
setLayerVisibility(data.layer.type + '-source-' + data.layer.id, data.layer.show !== false)
setLayerVisibility(layers[index].sourceId, data.layer.show !== false)
}
} else {
layers.push(data.layer)
const newLayer = createLayerInstance(data.layer)
layers.push(newLayer)
initializeLayerSources(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[delIndex].cleanup()
layers.splice(delIndex, 1)
// trigger a full map redraw
setBackgroundMapLayer(mapProperties.base_map, true)
Expand Down
23 changes: 11 additions & 12 deletions app/javascript/controllers/feature/edit_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import { status } from 'helpers/status'
import { flyToFeature } from 'maplibre/animations'
import { draw, handleDelete } from 'maplibre/edit'
import { confirmImageLocation, featureIcon, featureImage, uploadImageToFeature } from 'maplibre/feature'
import { renderGeoJSONLayer } from 'maplibre/layers/geojson'
import { getFeature, getLayer } from 'maplibre/layers/layers'
import { getFeature, getLayer, renderLayer } from 'maplibre/layers/layers'
import { featureColor, featureOutlineColor } from 'maplibre/styles/styles'
import { addUndoState } from 'maplibre/undo'

Expand Down Expand Up @@ -41,7 +40,7 @@ export default class extends Controller {
document.querySelector('#feature-edit-raw .error').innerHTML = ''
try {
feature.properties = JSON.parse(document.querySelector('#feature-edit-raw textarea').value)
renderGeoJSONLayer(this.layerIdValue, true)
renderLayer(this.layerIdValue, true)
mapChannel.send_message('update_feature', feature)
} catch (error) {
console.error('Error updating feature:', error.message)
Expand All @@ -56,7 +55,7 @@ export default class extends Controller {
feature.properties.title = title
if (document.querySelector('#feature-show-title-on-map')?.checked) {
feature.properties.label = title
renderGeoJSONLayer(this.layerIdValue, false)
renderLayer(this.layerIdValue, false)
}
document.querySelector('#feature-title').textContent = title
functions.debounce(() => { this.saveFeature() }, 'title')
Expand All @@ -70,7 +69,7 @@ export default class extends Controller {
} else {
delete feature.properties.label
}
renderGeoJSONLayer(this.layerIdValue, false)
renderLayer(this.layerIdValue, false)
functions.debounce(() => { this.saveFeature() }, 'show-title-on-map', 1000)
}

Expand All @@ -86,7 +85,7 @@ export default class extends Controller {
}
feature.properties[propertyName] = value
draw.setFeatureProperty(this.featureIdValue, propertyName, value)
renderGeoJSONLayer(this.layerIdValue, true)
renderLayer(this.layerIdValue, true)
}

// called as preview on slider change
Expand Down Expand Up @@ -134,15 +133,15 @@ export default class extends Controller {
document.querySelector('#stroke-color').removeAttribute('disabled')
}
feature.properties.stroke = color
renderGeoJSONLayer(this.layerIdValue, true)
renderLayer(this.layerIdValue, true)
}

updateFillColor () {
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 }
renderGeoJSONLayer(this.layerIdValue, true)
renderLayer(this.layerIdValue, true)
}

updateFillColorTransparent () {
Expand All @@ -158,7 +157,7 @@ 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 }
renderGeoJSONLayer(this.layerIdValue, true)
renderLayer(this.layerIdValue, true)
}

updateShowKmMarkers () {
Expand All @@ -170,7 +169,7 @@ export default class extends Controller {
delete feature.properties['show-km-markers']
delete feature.properties['stroke-image-url']
}
renderGeoJSONLayer(this.layerIdValue, true)
renderLayer(this.layerIdValue, true)
}

updateMarkerSymbol () {
Expand All @@ -183,7 +182,7 @@ 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) })
renderGeoJSONLayer(this.layerIdValue, true)
renderLayer(this.layerIdValue, true)
}

async updateMarkerImage () {
Expand All @@ -205,7 +204,7 @@ export default class extends Controller {
feature.geometry.coordinates = imageLocation
flyToFeature(feature)
}
renderGeoJSONLayer(this.layerIdValue, true)
renderLayer(this.layerIdValue, true)
this.saveFeature()
})
}
Expand Down
19 changes: 13 additions & 6 deletions app/javascript/controllers/map/context_menu_controller.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { Controller } from '@hotwired/stimulus'
import { mapChannel } from 'channels/map_channel'
import { status } from 'helpers/status'
import * as functions from 'helpers/functions'
import { status } from 'helpers/status'
import { hideContextMenu } from 'maplibre/controls/context_menu'
import { getFeature } from 'maplibre/layers/layers'
import { getFeature, renderLayers } from 'maplibre/layers/layers'
import { addFeature } from 'maplibre/map'
import { addUndoState } from 'maplibre/undo'
import { renderGeoJSONLayers } from 'maplibre/layers/geojson'

export default class extends Controller {

Expand All @@ -18,7 +17,7 @@ export default class extends Controller {
addUndoState('Feature update', feature)
if (feature.geometry.type === 'LineString') { feature.geometry.coordinates.splice(vertexIndex, 1) }
if (feature.geometry.type === 'Polygon') { feature.geometry.coordinates[0].splice(vertexIndex, 1) }
renderGeoJSONLayers(true)
renderLayers('geojson', true)
mapChannel.send_message('update_feature', { ...feature })
status('Point deleted')
hideContextMenu()
Expand All @@ -27,7 +26,7 @@ export default class extends Controller {
cutLine(event) {
const target = event.currentTarget
const feature = getFeature(target.dataset.featureId, 'geojson')

const vertexIndex = parseInt(target.dataset.index, 10)
const coords = feature.geometry.coordinates
const firstCoords = coords.slice(0, vertexIndex + 1)
Expand All @@ -36,7 +35,7 @@ export default class extends Controller {
// Keep original feature, shorten it to the first segment
addUndoState('Feature update', feature)
feature.geometry.coordinates = firstCoords
renderGeoJSONLayers(true)
renderLayers('geojson', true)
mapChannel.send_message('update_feature', { ...feature })

const secondFeature = {
Expand All @@ -52,4 +51,12 @@ export default class extends Controller {
status('Line cut into 2 segments')
hideContextMenu()
}

addToGeojsonLayer(event) {
const target = event.currentTarget
const feature = getFeature(target.dataset.featureId, 'basemap')
addFeature(feature)
addUndoState('Feature added', feature)
mapChannel.send_message('new_feature', feature)
}
}
54 changes: 26 additions & 28 deletions app/javascript/controllers/map/layers_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ import { status } from 'helpers/status'
import { flyToFeature } from 'maplibre/animations'
import { initLayersModal } from 'maplibre/controls/shared'
import { confirmImageLocation, uploadImageToFeature } from 'maplibre/feature'
import { renderGeoJSONLayer } from 'maplibre/layers/geojson'
import { initializeLayerSources, initializeLayerStyles, layers, loadAllLayerData, loadLayerData } from 'maplibre/layers/layers'
import { initializeOverpassLayers } from 'maplibre/layers/overpass'
import { createLayerInstance } from 'maplibre/layers/factory'
import { initializeLayerSources, initializeLayerStyles, layers, loadAllLayerData, loadLayerData, renderLayer } from 'maplibre/layers/layers'
import { queries } from 'maplibre/layers/queries'
import { map, mapProperties, removeGeoJSONSource, setLayerVisibility, upsert } from 'maplibre/map'

Expand Down Expand Up @@ -111,7 +110,7 @@ export default class extends Controller {
uploadImageToFeature(file, feature).then( () => {
upsert(feature)
// redraw first geojson layer
renderGeoJSONLayer(layers.find(l => l.type === 'geojson').id)
renderLayer(layers.find(l => l.type === 'geojson').id)
mapChannel.send_message('new_feature', { ...feature })
status('Added image')
flyToFeature(feature)
Expand Down Expand Up @@ -159,22 +158,19 @@ export default class extends Controller {
layer["cluster"] = clustered
layer["heatmap"] = layer.query.includes("heatmap=true")
event.target.closest('.layer-item').querySelector('.layer-name').innerHTML = layer.name
const { geojson: _geojson, ...sendLayer } = layer
mapChannel.send_message('update_layer', sendLayer)
mapChannel.send_message('update_layer', layer.toJSON())
event.target.closest('.layer-item').querySelector('.reload-icon').classList.add('layer-refresh-animate')
initializeOverpassLayers(layerId)
layer.initialize().then(() => { initLayersModal() })
}

refreshLayer (event) {
event.preventDefault()
const layerId = event.target.closest('.layer-item').getAttribute('data-layer-id')
functions.e('#layer-reload', e => { e.classList.add('hidden') })
functions.e('#layer-loading', e => { e.classList.remove('hidden') })
event.target.closest('.layer-item').querySelector('.reload-icon').classList.add('layer-refresh-animate')
loadLayerData(layerId).then( () => {
initLayersModal()
functions.e('#layer-loading', e => { e.classList.add('hidden') })
functions.e(`#layer-list-${layerId} .reload-icon`, e => { e.classList.remove('layer-refresh-animate') })
})
}

Expand Down Expand Up @@ -209,7 +205,7 @@ export default class extends Controller {
const wasVisible = layer.show !== false
layer.show = !wasVisible

setLayerVisibility(layer.type + '-source-' + layerId, layer.show)
setLayerVisibility(layer.sourceId, layer.show)

// update UI (both desktop and mobile visibility buttons)
layerElement.querySelectorAll('button.layer-visibility i, button.layer-visibility-mobile i').forEach(icon => {
Expand Down Expand Up @@ -240,13 +236,12 @@ export default class extends Controller {

// sync to server only in rw mode
if (window.gon.map_mode === "rw") {
const { geojson: _geojson, ...sendLayer } = layer
mapChannel.send_message('update_layer', sendLayer)
mapChannel.send_message('update_layer', layer.toJSON())
}
}

createWikipediaLayer() {
this.createLayer('wikipedia', 'Wikipedia', '')
this.createLayer('wikipedia', 'Wikipedia')
}

createSelectedOverpassLayer(event) {
Expand All @@ -263,25 +258,29 @@ export default class extends Controller {
}
}

createLayer(type, name, query) {
createBaseMapLayer(_event) {
this.createLayer('basemap', 'Basemap layer')
}

createLayer(type, name, query=null) {
let layerId = functions.featureId()
// must match server attribute order, for proper comparison in map_channel
let layer = { "id": layerId, "type": type, "name": name, "heatmap": false, "cluster": true, "show": true}
let layerData = { "id": layerId, "type": type, "name": name, "heatmap": false, "cluster": true, "show": true}
if (type == 'overpass') {
layer["query"] = query
layerData["query"] = query
// TODO: move cluster + heatmap to layer checkboxes
const clustered = !layer.query.includes("heatmap=true") &&
!layer.query.includes("cluster=false") &&
!layer.query.includes("geom") // clustering breaks lines & geometries
layer["cluster"] = clustered
layer["heatmap"] = layer.query.includes("heatmap=true")
const clustered = !layerData.query.includes("heatmap=true") &&
!layerData.query.includes("cluster=false") &&
!layerData.query.includes("geom") // clustering breaks lines & geometries
layerData["cluster"] = clustered
layerData["heatmap"] = layerData.query.includes("heatmap=true")
}
let layer = createLayerInstance(layerData)
layers.push(layer)
initLayersModal()
initializeLayerSources(layerId)
initializeLayerStyles(layerId)
mapChannel.send_message('new_layer', layer)
initLayersModal()
document.querySelector('#layer-list-' + layerId + ' .reload-icon').classList.add('layer-refresh-animate')
mapChannel.send_message('new_layer', layerData)
return layerId
}

Expand All @@ -291,12 +290,11 @@ export default class extends Controller {
dom.closeTooltips()
const layerElement = event.target.closest('.layer-item')
const layerId = layerElement.getAttribute('data-layer-id')
const layerType = layerElement.getAttribute('data-layer-type')
const layer = layers.find(f => f.id === layerId)
const { geojson: _geojson, ...sendLayer } = layer
layer.cleanup()
layers.splice(layers.indexOf(layer), 1)
removeGeoJSONSource(layerType + '-source-' + layerId)
mapChannel.send_message('delete_layer', sendLayer)
removeGeoJSONSource(layer.sourceId)
mapChannel.send_message('delete_layer', layer.toJSON())
initLayersModal()
}
}
33 changes: 31 additions & 2 deletions app/javascript/controllers/map_controller.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { Controller } from '@hotwired/stimulus'
import * as functions from 'helpers/functions'
import { initializeMap, setBackgroundMapLayer, initializeViewMode,
import { initializeMap, setBackgroundMapLayer, initializeViewMode,
initializeStaticMode, addFeature } from 'maplibre/map'
import { initializeEditMode } from 'maplibre/edit'
import { initializeSocket, mapChannel } from 'channels/map_channel'
import { addUndoState } from 'maplibre/undo'
import { addUndoState, clearUndoHistory } from 'maplibre/undo'
import { resetInitializationState } from 'maplibre/layers/layers'
import { clearImageState, resetLabelFont } from 'maplibre/styles/styles'

export default class extends Controller {
async connect () {
// Clear module-level state from previous map
resetInitializationState()
clearImageState()
clearUndoHistory()
resetLabelFont()

functions.e('#map-header nav', e => { e.style.display = 'none' })
await initializeMap('maplibre-map')
// static mode is used for screenshots
Expand All @@ -20,6 +28,27 @@ export default class extends Controller {
setBackgroundMapLayer()
}

disconnect() {
// Clean up when navigating away from the map
console.log('Map controller disconnecting, cleaning up...')

// Remove the map instance
if (window.map) {
try {
window.map.remove()
window.map = null
} catch (e) {
console.warn('Error removing map instance:', e)
}
}

// Clear module-level state
resetInitializationState()
clearImageState()
clearUndoHistory()
resetLabelFont()
}

// paste feature from clipboard
async paste(_event) {

Expand Down
Loading
Loading