From 54ba75dd1c701cd0134d5646e08c5074108a9c29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonia=20P=C3=A9rez-Cerezo?= Date: Mon, 10 Mar 2025 15:45:37 +0100 Subject: [PATCH 1/8] Start writing on editing feature --- frontend/common/map.js | 154 +++++++++++++++++++++++++++++++ frontend/{ => common}/styles.css | 0 frontend/map.js | 75 --------------- 3 files changed, 154 insertions(+), 75 deletions(-) create mode 100644 frontend/common/map.js rename frontend/{ => common}/styles.css (100%) delete mode 100644 frontend/map.js diff --git a/frontend/common/map.js b/frontend/common/map.js new file mode 100644 index 0000000..f065954 --- /dev/null +++ b/frontend/common/map.js @@ -0,0 +1,154 @@ + +const rules = new Array; +const l = new Array; +var legend = L.control({position: 'bottomleft'}); +legend.onAdd = function (map) { + var div = L.DomUtil.create('div', 'legend'); + for (var i = 0; i < l.length; i++) { + div.innerHTML += '' + l[i]["name"] + "
"; + } + return div; +}; + + +const map = L.map("map", { center: [52,13], zoom: 3, minZoom: 0 }); + +function update_hash() { + const {lat, lng} = this.getCenter(); + const zoom = this.getZoom(); + const digits = 4; + window.history.replaceState(null, '', `#map=${zoom}/${lat.toFixed(digits)}/${lng.toFixed(digits)}`); +} + +function onHashChange() { + const hash = document.location.hash; + const coords = decodeURIComponent(hash.slice(5)).split("/") // strip off the #map= part + const latLng = L.latLng(parseFloat(coords[1]), parseFloat(coords[2])); + map.setView(latLng, parseInt(coords[0])); +} + +map.on("moveend", update_hash); +map.on("zoomend", update_hash); + +fetch("layers.json") + .then((response) => response.json()) + .then((data) => { + document.title = data["name"] + layers = data["layers"] + for (let key in layers) { + l.push({ name: layers[key]["humanname"], color: layers[key]["color"] }); + rules.push({ + dataLayer: key, + symbolizer: new protomapsL.LineSymbolizer(layers[key]) + }); + } + const tiles = data["tilelayer"] + var osm = L.tileLayer( + tiles["url_template"], + { + maxZoom: 19, + attribution: tiles["attribution"] + } + ); + var strecken = protomapsL.leafletLayer({ + url: "strecken.pmtiles", + maxDataZoom: data["maxZoom"] ?? 10, + maxZoom: 19, + paintRules: rules, + }); + + osm.addTo(map); + legend.addTo(map); + strecken.addTo(map); + }) + +let dirHandle; +let editMode = false; +let markers = []; +let geojsons = []; +let geojson; + +async function updateBrouter () { + if (markers.length < 1) { + return; + } + geojsons = []; + for (let i = 0; i < markers.length - 1 ; i++) { + const marker1 = markers[i].getLatLng(); + const marker2 = markers[i+1].getLatLng(); + const url = `https://brouter.de/brouter?lonlats=${marker1.lng},${marker1.lat}|${marker2.lng},${marker2.lat}&profile=rail&alternativeidx=0&format=geojson`; + fetch(url).then((response) => response.json()) + .then((data) => { + if (geojson != undefined) { + map.removeLayer(geojson); + } + geojsons.push(data.features[0]); + const dat = {type: "FeatureCollection", features: geojsons}; + geojson = L.geoJSON(dat); + geojson.addTo(map); + }) + } +} + +map.on('click', function(e) { + // if (!editMode) { + // return; + // } + marker = new L.marker(e.latlng, {draggable: true}) ; + markers.push(marker); + updateBrouter(); + marker.on("click", function(e) { + map.removeLayer(this); + markers = markers.filter(item => item != this); + updateBrouter(); + console.log(markers); + }) + marker.addTo(map); +}); + + +async function pickDirectory(){ + if (!editMode) { + dirHandle = await window.showDirectoryPicker(); + document.getElementById("edit-mode").style.color = "red"; + document.getElementById("edit-mode").innerHTML = "save"; + editMode = true; + } else { + const filename = window.prompt("Enter filename:", "test"); + const dat = {type: "FeatureCollection", features: geojsons}; + const file = await dirHandle.getFileHandle(`${filename}.geojson`, { + create: true + }); + const blob = new Blob([JSON.stringify(dat)]); + const writableStream = await file.createWritable(); + await writableStream.write(blob); + await writableStream.close(); + alert("Saved file!"); + } +} + +const customButton = L.control({ position: 'topright' }); +customButton.onAdd = () => { + const buttonDiv = L.DomUtil.create('div', 'button-wrapper'); + + buttonDiv.innerHTML = ``; + buttonDiv.addEventListener('click', () => pickDirectory()) + return buttonDiv; +}; +customButton.addTo(map) + + + +function resize() { + document.getElementById("map").style.height = window.innerHeight + 'px'; +} +resize(); +window.addEventListener('resize', () => { + resize(); +}); + +window.addEventListener("hashchange", onHashChange); +onHashChange(); + + + diff --git a/frontend/styles.css b/frontend/common/styles.css similarity index 100% rename from frontend/styles.css rename to frontend/common/styles.css diff --git a/frontend/map.js b/frontend/map.js deleted file mode 100644 index 413d285..0000000 --- a/frontend/map.js +++ /dev/null @@ -1,75 +0,0 @@ - -const rules = new Array; -const l = new Array; -var legend = L.control({position: 'bottomleft'}); -legend.onAdd = function (map) { - var div = L.DomUtil.create('div', 'legend'); - for (var i = 0; i < l.length; i++) { - div.innerHTML += '' + l[i]["name"] + "
"; - } - return div; -}; - - -const map = L.map("map", { center: [52,13], zoom: 3, minZoom: 0 }); - -function update_hash() { - const {lat, lng} = this.getCenter(); - const zoom = this.getZoom(); - const digits = 4; - window.history.replaceState(null, '', `#map=${zoom}/${lat.toFixed(digits)}/${lng.toFixed(digits)}`); -} - -function onHashChange() { - const hash = document.location.hash; - const coords = decodeURIComponent(hash.slice(5)).split("/") // strip off the #map= part - const latLng = L.latLng(parseFloat(coords[1]), parseFloat(coords[2])); - map.setView(latLng, parseInt(coords[0])); -} - -map.on("moveend", update_hash); -map.on("zoomend", update_hash); - -fetch("layers.json") - .then((response) => response.json()) - .then((data) => { - document.title = data["name"] - layers = data["layers"] - for (let key in layers) { - l.push({ name: layers[key]["humanname"], color: layers[key]["color"] }); - rules.push({ - dataLayer: key, - symbolizer: new protomapsL.LineSymbolizer(layers[key]) - }); - } - const tiles = data["tilelayer"] - var osm = L.tileLayer( - tiles["url_template"], - { - maxZoom: 19, - attribution: tiles["attribution"] - } - ); - var strecken = protomapsL.leafletLayer({ - url: "strecken.pmtiles", - maxDataZoom: data["maxZoom"] ?? 10, - maxZoom: 19, - paintRules: rules, - }); - - osm.addTo(map); - legend.addTo(map); - strecken.addTo(map); - }) - -function resize() { - document.getElementById("map").style.height = window.innerHeight + 'px'; -} -resize(); -window.addEventListener('resize', () => { - resize(); -}); - -window.addEventListener("hashchange", onHashChange); -onHashChange(); - From 954e8c4500518b5bc2d812a2226bd29f03f623c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonia=20P=C3=A9rez-Cerezo?= Date: Mon, 10 Mar 2025 15:51:17 +0100 Subject: [PATCH 2/8] remove the geojson path if less than two markers are present --- frontend/common/map.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/common/map.js b/frontend/common/map.js index f065954..6cc636a 100644 --- a/frontend/common/map.js +++ b/frontend/common/map.js @@ -69,7 +69,10 @@ let geojsons = []; let geojson; async function updateBrouter () { - if (markers.length < 1) { + if (markers.length < 2) { + if (geojson != undefined) { + map.removeLayer(geojson); + } return; } geojsons = []; From 2fee843c52549286da784372faffecbabc1c7a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonia=20P=C3=A9rez-Cerezo?= Date: Mon, 10 Mar 2025 18:56:43 +0100 Subject: [PATCH 3/8] Display currently edited layer, hide edit button if not supported --- frontend/common/map.js | 55 ++++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/frontend/common/map.js b/frontend/common/map.js index 6cc636a..dc7ad23 100644 --- a/frontend/common/map.js +++ b/frontend/common/map.js @@ -1,3 +1,6 @@ +function layer_legend(layer) { + return '' + layer["name"]; +} const rules = new Array; const l = new Array; @@ -5,7 +8,7 @@ var legend = L.control({position: 'bottomleft'}); legend.onAdd = function (map) { var div = L.DomUtil.create('div', 'legend'); for (var i = 0; i < l.length; i++) { - div.innerHTML += '' + l[i]["name"] + "
"; + div.innerHTML += layer_legend(l[i]) + "
"; } return div; }; @@ -36,7 +39,7 @@ fetch("layers.json") document.title = data["name"] layers = data["layers"] for (let key in layers) { - l.push({ name: layers[key]["humanname"], color: layers[key]["color"] }); + l.push({ dirname: key , name: layers[key]["humanname"], color: layers[key]["color"] }); rules.push({ dataLayer: key, symbolizer: new protomapsL.LineSymbolizer(layers[key]) @@ -67,6 +70,7 @@ let editMode = false; let markers = []; let geojsons = []; let geojson; +let editlayer; async function updateBrouter () { if (markers.length < 2) { @@ -87,7 +91,14 @@ async function updateBrouter () { } geojsons.push(data.features[0]); const dat = {type: "FeatureCollection", features: geojsons}; - geojson = L.geoJSON(dat); + if (editlayer != undefined) { + const style = { + "color": editlayer.color + }; + geojson = L.geoJSON(dat, {style: style}); + } else { + geojson = L.geoJSON(dat); + } geojson.addTo(map); }) } @@ -104,15 +115,28 @@ map.on('click', function(e) { map.removeLayer(this); markers = markers.filter(item => item != this); updateBrouter(); - console.log(markers); }) + marker.on("dragend", function(e) { + updateBrouter(); + }); marker.addTo(map); }); -async function pickDirectory(){ +async function pickDirectory(e){ + e.stopPropagation() + L.DomEvent.preventDefault(e); if (!editMode) { dirHandle = await window.showDirectoryPicker(); + for (i = 0; i < l.length ; i++ ) { + console.log(l[i].dirname); + if (l[i].dirname === dirHandle.name) { + editlayer = l[i]; + this.innerHTML = "Editing layer " + layer_legend(l[i]) + "
" + this.innerHTML + break; + } + } + document.getElementById("edit-mode").style.color = "red"; document.getElementById("edit-mode").innerHTML = "save"; editMode = true; @@ -130,16 +154,17 @@ async function pickDirectory(){ } } -const customButton = L.control({ position: 'topright' }); -customButton.onAdd = () => { - const buttonDiv = L.DomUtil.create('div', 'button-wrapper'); - - buttonDiv.innerHTML = ``; - buttonDiv.addEventListener('click', () => pickDirectory()) - return buttonDiv; -}; -customButton.addTo(map) - +if ("showDirectoryPicker" in window) { + const customButton = L.control({ position: 'topright' }); + customButton.onAdd = () => { + const buttonDiv = L.DomUtil.create('div', 'legend'); + + buttonDiv.innerHTML = ``; + buttonDiv.addEventListener('click', pickDirectory, this) + return buttonDiv; + }; + customButton.addTo(map) +} function resize() { From 1416481ba90b6d94c5c0bee4440af3bee6ad4220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonia=20P=C3=A9rez-Cerezo?= Date: Mon, 10 Mar 2025 19:21:55 +0100 Subject: [PATCH 4/8] colorize the start and end icons --- frontend/common/map.js | 9 ++++++++- frontend/common/styles.css | 2 ++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/common/map.js b/frontend/common/map.js index dc7ad23..052a0d2 100644 --- a/frontend/common/map.js +++ b/frontend/common/map.js @@ -73,6 +73,13 @@ let geojson; let editlayer; async function updateBrouter () { + if (markers.length > 0) { + for (i=1; i< markers.length-1; i++) { + markers[i]._icon.classList.remove("red"); + } + markers[markers.length-1]._icon.classList.add("red"); + markers[0]._icon.classList.add("green"); + } if (markers.length < 2) { if (geojson != undefined) { map.removeLayer(geojson); @@ -110,7 +117,6 @@ map.on('click', function(e) { // } marker = new L.marker(e.latlng, {draggable: true}) ; markers.push(marker); - updateBrouter(); marker.on("click", function(e) { map.removeLayer(this); markers = markers.filter(item => item != this); @@ -120,6 +126,7 @@ map.on('click', function(e) { updateBrouter(); }); marker.addTo(map); + updateBrouter(); }); diff --git a/frontend/common/styles.css b/frontend/common/styles.css index d756f67..150beda 100644 --- a/frontend/common/styles.css +++ b/frontend/common/styles.css @@ -19,3 +19,5 @@ body { display: inline-block; margin-right: 0.7em; } +img.red { filter: hue-rotate(120deg); } +img.green { filter: hue-rotate(-120deg); } From 4d26db348c9969af01c3376cdec17189b89fe766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonia=20P=C3=A9rez-Cerezo?= Date: Mon, 10 Mar 2025 19:46:14 +0100 Subject: [PATCH 5/8] make geojson path persist after saving and allow drawing more features --- frontend/common/map.js | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/frontend/common/map.js b/frontend/common/map.js index 052a0d2..0bbd987 100644 --- a/frontend/common/map.js +++ b/frontend/common/map.js @@ -72,6 +72,19 @@ let geojsons = []; let geojson; let editlayer; +function addGeoJsonToMap(dat) { + if (editlayer != undefined) { + const style = { + "color": editlayer.color + }; + g = L.geoJSON(dat, {style: style}); + } else { + g = L.geoJSON(dat); + } + g.addTo(map); + return g; +} + async function updateBrouter () { if (markers.length > 0) { for (i=1; i< markers.length-1; i++) { @@ -98,15 +111,7 @@ async function updateBrouter () { } geojsons.push(data.features[0]); const dat = {type: "FeatureCollection", features: geojsons}; - if (editlayer != undefined) { - const style = { - "color": editlayer.color - }; - geojson = L.geoJSON(dat, {style: style}); - } else { - geojson = L.geoJSON(dat); - } - geojson.addTo(map); + geojson = addGeoJsonToMap(dat); }) } } @@ -157,6 +162,12 @@ async function pickDirectory(e){ const writableStream = await file.createWritable(); await writableStream.write(blob); await writableStream.close(); + addGeoJsonToMap(dat); + for (i=0; i Date: Mon, 10 Mar 2025 21:23:49 +0100 Subject: [PATCH 6/8] Remove var keywords, hide editing behind ?edit=1 query string --- frontend/common/map.js | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/frontend/common/map.js b/frontend/common/map.js index 0bbd987..1de05f1 100644 --- a/frontend/common/map.js +++ b/frontend/common/map.js @@ -4,10 +4,10 @@ function layer_legend(layer) { const rules = new Array; const l = new Array; -var legend = L.control({position: 'bottomleft'}); +const legend = L.control({position: 'bottomleft'}); legend.onAdd = function (map) { - var div = L.DomUtil.create('div', 'legend'); - for (var i = 0; i < l.length; i++) { + const div = L.DomUtil.create('div', 'legend'); + for (let i = 0; i < l.length; i++) { div.innerHTML += layer_legend(l[i]) + "
"; } return div; @@ -37,7 +37,7 @@ fetch("layers.json") .then((response) => response.json()) .then((data) => { document.title = data["name"] - layers = data["layers"] + const layers = data["layers"] for (let key in layers) { l.push({ dirname: key , name: layers[key]["humanname"], color: layers[key]["color"] }); rules.push({ @@ -46,14 +46,14 @@ fetch("layers.json") }); } const tiles = data["tilelayer"] - var osm = L.tileLayer( + const osm = L.tileLayer( tiles["url_template"], { maxZoom: 19, attribution: tiles["attribution"] } ); - var strecken = protomapsL.leafletLayer({ + const strecken = protomapsL.leafletLayer({ url: "strecken.pmtiles", maxDataZoom: data["maxZoom"] ?? 10, maxZoom: 19, @@ -172,13 +172,19 @@ async function pickDirectory(e){ } } -if ("showDirectoryPicker" in window) { +const searchParams = new URLSearchParams(window.location.search) +const edit = searchParams.get("edit"); + +if (edit) { const customButton = L.control({ position: 'topright' }); customButton.onAdd = () => { const buttonDiv = L.DomUtil.create('div', 'legend'); - - buttonDiv.innerHTML = ``; - buttonDiv.addEventListener('click', pickDirectory, this) + if ("showDirectoryPicker" in window) { + buttonDiv.innerHTML = ``; + buttonDiv.addEventListener('click', pickDirectory, this) + } else { + buttonDiv.innerHTML = "Your browser does not support editing.
As of 2025, editing is supported on Chromium-based browsers only."; + } return buttonDiv; }; customButton.addTo(map) From 209eb3c7c20441f0dbf48fc088a3396d624df467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonia=20P=C3=A9rez-Cerezo?= Date: Mon, 10 Mar 2025 22:00:06 +0100 Subject: [PATCH 7/8] Request read/write persmissions, don't place markers outside of edit mode --- frontend/common/map.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/common/map.js b/frontend/common/map.js index 1de05f1..771c311 100644 --- a/frontend/common/map.js +++ b/frontend/common/map.js @@ -117,9 +117,9 @@ async function updateBrouter () { } map.on('click', function(e) { - // if (!editMode) { - // return; - // } + if (!editMode) { + return; + } marker = new L.marker(e.latlng, {draggable: true}) ; markers.push(marker); marker.on("click", function(e) { @@ -139,7 +139,7 @@ async function pickDirectory(e){ e.stopPropagation() L.DomEvent.preventDefault(e); if (!editMode) { - dirHandle = await window.showDirectoryPicker(); + dirHandle = await window.showDirectoryPicker({ mode: 'readwrite' }); for (i = 0; i < l.length ; i++ ) { console.log(l[i].dirname); if (l[i].dirname === dirHandle.name) { From efeec0bf4278781791b2203c4160c19a7dcde49a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonia=20P=C3=A9rez-Cerezo?= Date: Mon, 10 Mar 2025 22:08:12 +0100 Subject: [PATCH 8/8] Document editing mode --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index acbaeda..d6db7f0 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,20 @@ value also increases the size of the `strecken.pmtiles` file. If not given explicitly `maxZoom` defaults to 10. +## Map Editing Mode + +As a new experimental feature, Streckenkarte implements a simple +[brouter][brouter] frontend to allow for easy editing, currently only +supported on browsers that support +[showDirectoryPicker](https://developer.mozilla.org/en-US/docs/Web/API/Window/showDirectoryPicker). It +can be accessed by adding `?edit=1` to the URL and then clicking the +"edit" button that appears in the top right corner. Then, the +directory containing the files for the layer that is to be edited can +be opened in the file selection dialog and lines can be drawn as in +the normal brouter web frontend. By clicking on "save", the lines are +saved locally in the chosen directory and will only appear on the +production map if processed correctly by the tile-generating script. + ## Troubleshooting ### My pmtiles file is huge (hundreds of megabytes)