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)
diff --git a/frontend/common/map.js b/frontend/common/map.js
new file mode 100644
index 0000000..771c311
--- /dev/null
+++ b/frontend/common/map.js
@@ -0,0 +1,206 @@
+function layer_legend(layer) {
+ return '' + layer["name"];
+}
+
+const rules = new Array;
+const l = new Array;
+const legend = L.control({position: 'bottomleft'});
+legend.onAdd = function (map) {
+ const div = L.DomUtil.create('div', 'legend');
+ for (let i = 0; i < l.length; i++) {
+ div.innerHTML += layer_legend(l[i]) + "
";
+ }
+ 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"]
+ const layers = data["layers"]
+ for (let key in layers) {
+ l.push({ dirname: key , name: layers[key]["humanname"], color: layers[key]["color"] });
+ rules.push({
+ dataLayer: key,
+ symbolizer: new protomapsL.LineSymbolizer(layers[key])
+ });
+ }
+ const tiles = data["tilelayer"]
+ const osm = L.tileLayer(
+ tiles["url_template"],
+ {
+ maxZoom: 19,
+ attribution: tiles["attribution"]
+ }
+ );
+ const 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;
+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++) {
+ 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);
+ }
+ 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 = addGeoJsonToMap(dat);
+ })
+ }
+}
+
+map.on('click', function(e) {
+ if (!editMode) {
+ return;
+ }
+ marker = new L.marker(e.latlng, {draggable: true}) ;
+ markers.push(marker);
+ marker.on("click", function(e) {
+ map.removeLayer(this);
+ markers = markers.filter(item => item != this);
+ updateBrouter();
+ })
+ marker.on("dragend", function(e) {
+ updateBrouter();
+ });
+ marker.addTo(map);
+ updateBrouter();
+});
+
+
+async function pickDirectory(e){
+ e.stopPropagation()
+ L.DomEvent.preventDefault(e);
+ if (!editMode) {
+ dirHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
+ 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;
+ } 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();
+ addGeoJsonToMap(dat);
+ for (i=0; i {
+ const buttonDiv = L.DomUtil.create('div', 'legend');
+ 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)
+}
+
+
+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 77%
rename from frontend/styles.css
rename to frontend/common/styles.css
index d756f67..150beda 100644
--- a/frontend/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); }
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();
-