From ae5325d4d1a6dd59dc35232483f154851a8df83a Mon Sep 17 00:00:00 2001 From: Arno Deceuninck Date: Tue, 13 Jul 2021 11:26:50 +0200 Subject: [PATCH 01/16] First draft of split road --- Svg.ts | 7 +- UI/Popup/SplitRoadWizard.ts | 163 +++++++++++++++++++++++++++++++++++ assets/svg/scissors.svg | 1 + test.ts | 166 +----------------------------------- 4 files changed, 173 insertions(+), 164 deletions(-) create mode 100644 UI/Popup/SplitRoadWizard.ts create mode 100644 assets/svg/scissors.svg diff --git a/Svg.ts b/Svg.ts index 9a5c94b8f..d24c1072a 100644 --- a/Svg.ts +++ b/Svg.ts @@ -264,6 +264,11 @@ export default class Svg { public static ring_svg() { return new Img(Svg.ring, true);} public static ring_ui() { return new FixedUiElement(Svg.ring_img);} + public static scissors = "Created by basith ibrahimfrom the Noun Project" + public static scissors_img = Img.AsImageElement(Svg.scissors) + public static scissors_svg() { return new Img(Svg.scissors, true);} + public static scissors_ui() { return new FixedUiElement(Svg.scissors_img);} + public static search = " " public static search_img = Img.AsImageElement(Svg.search) public static search_svg() { return new Img(Svg.search, true);} @@ -334,4 +339,4 @@ export default class Svg { public static wikipedia_svg() { return new Img(Svg.wikipedia, true);} public static wikipedia_ui() { return new FixedUiElement(Svg.wikipedia_img);} -public static All = {"SocialImageForeground.svg": Svg.SocialImageForeground,"add.svg": Svg.add,"addSmall.svg": Svg.addSmall,"ampersand.svg": Svg.ampersand,"arrow-left-smooth.svg": Svg.arrow_left_smooth,"arrow-right-smooth.svg": Svg.arrow_right_smooth,"back.svg": Svg.back,"bug.svg": Svg.bug,"camera-plus.svg": Svg.camera_plus,"checkmark.svg": Svg.checkmark,"circle.svg": Svg.circle,"clock.svg": Svg.clock,"close.svg": Svg.close,"compass.svg": Svg.compass,"cross_bottom_right.svg": Svg.cross_bottom_right,"crosshair-blue-center.svg": Svg.crosshair_blue_center,"crosshair-blue.svg": Svg.crosshair_blue,"crosshair.svg": Svg.crosshair,"delete_icon.svg": Svg.delete_icon,"direction.svg": Svg.direction,"direction_gradient.svg": Svg.direction_gradient,"direction_masked.svg": Svg.direction_masked,"direction_outline.svg": Svg.direction_outline,"direction_stroke.svg": Svg.direction_stroke,"down.svg": Svg.down,"envelope.svg": Svg.envelope,"floppy.svg": Svg.floppy,"gear.svg": Svg.gear,"help.svg": Svg.help,"home.svg": Svg.home,"home_white_bg.svg": Svg.home_white_bg,"josm_logo.svg": Svg.josm_logo,"layers.svg": Svg.layers,"layersAdd.svg": Svg.layersAdd,"logo.svg": Svg.logo,"logout.svg": Svg.logout,"mapcomplete_logo.svg": Svg.mapcomplete_logo,"mapillary.svg": Svg.mapillary,"mapillary_black.svg": Svg.mapillary_black,"min.svg": Svg.min,"no_checkmark.svg": Svg.no_checkmark,"or.svg": Svg.or,"osm-copyright.svg": Svg.osm_copyright,"osm-logo-us.svg": Svg.osm_logo_us,"osm-logo.svg": Svg.osm_logo,"pencil.svg": Svg.pencil,"phone.svg": Svg.phone,"pin.svg": Svg.pin,"plus.svg": Svg.plus,"pop-out.svg": Svg.pop_out,"reload.svg": Svg.reload,"ring.svg": Svg.ring,"search.svg": Svg.search,"send_email.svg": Svg.send_email,"share.svg": Svg.share,"square.svg": Svg.square,"star.svg": Svg.star,"star_half.svg": Svg.star_half,"star_outline.svg": Svg.star_outline,"star_outline_half.svg": Svg.star_outline_half,"statistics.svg": Svg.statistics,"translate.svg": Svg.translate,"up.svg": Svg.up,"wikidata.svg": Svg.wikidata,"wikimedia-commons-white.svg": Svg.wikimedia_commons_white,"wikipedia.svg": Svg.wikipedia};} +public static All = {"SocialImageForeground.svg": Svg.SocialImageForeground,"add.svg": Svg.add,"addSmall.svg": Svg.addSmall,"ampersand.svg": Svg.ampersand,"arrow-left-smooth.svg": Svg.arrow_left_smooth,"arrow-right-smooth.svg": Svg.arrow_right_smooth,"back.svg": Svg.back,"bug.svg": Svg.bug,"camera-plus.svg": Svg.camera_plus,"checkmark.svg": Svg.checkmark,"circle.svg": Svg.circle,"clock.svg": Svg.clock,"close.svg": Svg.close,"compass.svg": Svg.compass,"cross_bottom_right.svg": Svg.cross_bottom_right,"crosshair-blue-center.svg": Svg.crosshair_blue_center,"crosshair-blue.svg": Svg.crosshair_blue,"crosshair.svg": Svg.crosshair,"delete_icon.svg": Svg.delete_icon,"direction.svg": Svg.direction,"direction_gradient.svg": Svg.direction_gradient,"direction_masked.svg": Svg.direction_masked,"direction_outline.svg": Svg.direction_outline,"direction_stroke.svg": Svg.direction_stroke,"down.svg": Svg.down,"envelope.svg": Svg.envelope,"floppy.svg": Svg.floppy,"gear.svg": Svg.gear,"help.svg": Svg.help,"home.svg": Svg.home,"home_white_bg.svg": Svg.home_white_bg,"josm_logo.svg": Svg.josm_logo,"layers.svg": Svg.layers,"layersAdd.svg": Svg.layersAdd,"logo.svg": Svg.logo,"logout.svg": Svg.logout,"mapcomplete_logo.svg": Svg.mapcomplete_logo,"mapillary.svg": Svg.mapillary,"mapillary_black.svg": Svg.mapillary_black,"min.svg": Svg.min,"no_checkmark.svg": Svg.no_checkmark,"or.svg": Svg.or,"osm-copyright.svg": Svg.osm_copyright,"osm-logo-us.svg": Svg.osm_logo_us,"osm-logo.svg": Svg.osm_logo,"pencil.svg": Svg.pencil,"phone.svg": Svg.phone,"pin.svg": Svg.pin,"plus.svg": Svg.plus,"pop-out.svg": Svg.pop_out,"reload.svg": Svg.reload,"ring.svg": Svg.ring,"scissors.svg": Svg.scissors,"search.svg": Svg.search,"send_email.svg": Svg.send_email,"share.svg": Svg.share,"square.svg": Svg.square,"star.svg": Svg.star,"star_half.svg": Svg.star_half,"star_outline.svg": Svg.star_outline,"star_outline_half.svg": Svg.star_outline_half,"statistics.svg": Svg.statistics,"translate.svg": Svg.translate,"up.svg": Svg.up,"wikidata.svg": Svg.wikidata,"wikimedia-commons-white.svg": Svg.wikimedia_commons_white,"wikipedia.svg": Svg.wikipedia};} diff --git a/UI/Popup/SplitRoadWizard.ts b/UI/Popup/SplitRoadWizard.ts new file mode 100644 index 000000000..5caf0ec61 --- /dev/null +++ b/UI/Popup/SplitRoadWizard.ts @@ -0,0 +1,163 @@ +import {VariableUiElement} from "../Base/VariableUIElement"; +import Toggle from "../Input/Toggle"; +import Translations from "../i18n/Translations"; +import Svg from "../../Svg"; +import {UIEventSource} from "../../Logic/UIEventSource"; +import {TagsFilter} from "../../Logic/Tags/TagsFilter"; +import TagRenderingConfig from "../../Customizations/JSON/TagRenderingConfig"; +import Combine from "../Base/Combine"; +import {SubtleButton} from "../Base/SubtleButton"; +import {FixedUiElement} from "../Base/FixedUiElement"; +import {Translation} from "../i18n/Translation"; +import {AndOrTagConfigJson} from "../../Customizations/JSON/TagConfigJson"; +import BaseUIElement from "../BaseUIElement"; +import SplitRoadAction from "../../Logic/Osm/SplitRoadAction"; +import Minimap from "../Base/Minimap"; +import State from "../../State"; + +export default class SplitRoadWizard extends Toggle { + /** + * A UI Element used for splitting roads + * + * @param id: The id of the road to remove + */ + constructor(id: string) { + + + const splitClicked = new UIEventSource(false); + + const splitButton = new SubtleButton(Svg.scissors_ui(), "Split road"); + splitButton.onClick( + () => { + splitClicked.setData(true) + } + ) + + // const isShown = new UIEventSource(id.indexOf("-") < 0) + + const miniMap = new Minimap({background: State.state.backgroundLayer}); + + super(miniMap, splitButton, splitClicked); + + } + + + private static constructConfirmButton(deleteReasons: UIEventSource): BaseUIElement { + const t = Translations.t.delete; + const btn = new Combine([ + Svg.delete_icon_ui().SetClass("w-6 h-6 mr-3 block"), + t.delete.Clone() + ]).SetClass("flex btn bg-red-500") + + + const btnNonActive = new Combine([ + Svg.delete_icon_ui().SetClass("w-6 h-6 mr-3 block"), + t.delete.Clone() + ]).SetClass("flex btn btn-disabled bg-red-200") + + return new Toggle( + btn, + btnNonActive, + deleteReasons.map(reason => reason !== undefined) + ) + + } + + + private static constructExplanation(tags: UIEventSource, deleteAction: SplitRoadAction) { + const t = Translations.t.delete; + return new VariableUiElement(tags.map( + currentTags => { + const cbd = deleteAction.canBeDeleted.data; + if (currentTags === undefined) { + return t.explanations.selectReason.Clone().SetClass("subtle"); + } + + const hasDeletionTag = currentTags.asChange(currentTags).some(kv => kv.k === "_delete_reason") + + if (cbd.canBeDeleted && hasDeletionTag) { + return t.explanations.hardDelete.Clone() + } + return new Combine([t.explanations.softDelete.Subs({reason: cbd.reason}), + new FixedUiElement(currentTags.asHumanString(false, true, currentTags)).SetClass("subtle") + ]).SetClass("flex flex-col") + + + } + , [deleteAction.canBeDeleted] + )).SetClass("block") + } + + private static generateDeleteTagRenderingConfig(softDeletionTags: TagsFilter, + nonDeleteOptions: { if: TagsFilter; then: Translation }[], + extraDeleteReasons: { explanation: Translation; changesetMessage: string }[], + currentTags: any) { + const t = Translations.t.delete + nonDeleteOptions = nonDeleteOptions ?? [] + const softDeletionTagsStr = [] + if (softDeletionTags !== undefined) { + softDeletionTags.asChange(currentTags) + } + const extraOptionsStr: { if: AndOrTagConfigJson, then: any }[] = [] + for (const nonDeleteOption of nonDeleteOptions) { + const newIf: string[] = nonDeleteOption.if.asChange({}).map(kv => kv.k + "=" + kv.v) + + extraOptionsStr.push({ + if: {and: newIf}, + then: nonDeleteOption.then + }) + } + + for (const extraDeleteReason of (extraDeleteReasons ?? [])) { + extraOptionsStr.push({ + if: {and: ["_delete_reason=" + extraDeleteReason.changesetMessage]}, + then: extraDeleteReason.explanation + }) + } + return new TagRenderingConfig( + { + question: t.whyDelete, + render: "Deleted because {_delete_reason}", + freeform: { + key: "_delete_reason", + addExtraTags: softDeletionTagsStr + }, + mappings: [ + + ...extraOptionsStr, + + { + if: { + and: [ + "_delete_reason=testing point", + ...softDeletionTagsStr + ] + }, + then: t.reasons.test + }, + { + if: { + and: [ + "_delete_reason=disused", + ...softDeletionTagsStr + ] + }, + then: t.reasons.disused + }, + { + if: { + and: [ + "_delete_reason=not found", + ...softDeletionTagsStr + ] + }, + then: t.reasons.notFound + } + ] + + + }, undefined, "Delete wizard" + ) + } + +} \ No newline at end of file diff --git a/assets/svg/scissors.svg b/assets/svg/scissors.svg new file mode 100644 index 000000000..be55cb476 --- /dev/null +++ b/assets/svg/scissors.svg @@ -0,0 +1 @@ +Created by basith ibrahimfrom the Noun Project \ No newline at end of file diff --git a/test.ts b/test.ts index eb29b9921..630d0a7dc 100644 --- a/test.ts +++ b/test.ts @@ -1,166 +1,6 @@ -import {OsmObject} from "./Logic/Osm/OsmObject"; -import DeleteButton from "./UI/Popup/DeleteWizard"; -import Combine from "./UI/Base/Combine"; +import SplitRoadWizard from "./UI/Popup/SplitRoadWizard"; import State from "./State"; -import DeleteWizard from "./UI/Popup/DeleteWizard"; -import {UIEventSource} from "./Logic/UIEventSource"; -import {Tag} from "./Logic/Tags/Tag"; -import {QueryParameters} from "./Logic/Web/QueryParameters"; -import {Translation} from "./UI/i18n/Translation"; -/*import ValidatedTextField from "./UI/Input/ValidatedTextField"; -import Combine from "./UI/Base/Combine"; -import {VariableUiElement} from "./UI/Base/VariableUIElement"; -import {UIEventSource} from "./Logic/UIEventSource"; -import TagRenderingConfig from "./Customizations/JSON/TagRenderingConfig"; -import State from "./State"; -import TagRenderingQuestion from "./UI/Popup/TagRenderingQuestion"; -import {SlideShow} from "./UI/Image/SlideShow"; -import {FixedUiElement} from "./UI/Base/FixedUiElement"; -import Img from "./UI/Base/Img"; -import {AttributedImage} from "./UI/Image/AttributedImage"; -import {Imgur} from "./Logic/ImageProviders/Imgur"; -import Minimap from "./UI/Base/Minimap"; -import Loc from "./Models/Loc"; -import AvailableBaseLayers from "./Logic/Actors/AvailableBaseLayers"; -import ShowDataLayer from "./UI/ShowDataLayer"; -import LayoutConfig from "./Customizations/JSON/LayoutConfig"; import {AllKnownLayouts} from "./Customizations/AllKnownLayouts"; - -function TestSlideshow() { - const elems = new UIEventSource([ - new FixedUiElement("A"), - new FixedUiElement("qmsldkfjqmlsdkjfmqlskdjfmqlksdf").SetClass("text-xl"), - new Img("https://i.imgur.com/8lIQ5Hv.jpg"), - new AttributedImage("https://i.imgur.com/y5XudzW.jpg", Imgur.singleton), - new Img("https://www.grunge.com/img/gallery/the-real-reason-your-cat-sleeps-so-much/intro-1601496900.webp") - ]) - new SlideShow(elems).AttachTo("maindiv") -} - -function TestTagRendering() { - State.state = new State(undefined) - const tagsSource = new UIEventSource({ - id: "node/1" - }) - new TagRenderingQuestion( - tagsSource, - new TagRenderingConfig({ - multiAnswer: false, - freeform: { - key: "valve" - }, - question: "What valves are supported?", - render: "This pump supports {valve}", - mappings: [ - { - if: "valve=dunlop", - then: "This pump supports dunlop" - }, - { - if: "valve=shrader", - then: "shrader is supported", - } - ], - - }, undefined, "test"), - [] - ).AttachTo("maindiv") - new VariableUiElement(tagsSource.map(tags => tags["valves"])).SetClass("alert").AttachTo("extradiv") -} - -function TestAllInputMethods() { - - new Combine(ValidatedTextField.tpList.map(tp => { - const tf = ValidatedTextField.InputForType(tp.name); - - return new Combine([tf, new VariableUiElement(tf.GetValue()).SetClass("alert")]); - })).AttachTo("maindiv") -} - -function TestMiniMap() { - - const location = new UIEventSource({ - lon: 4.84771728515625, - lat: 51.17920846421931, - zoom: 14 - }) - const map0 = new Minimap({ - location: location, - allowMoving: true, - background: new AvailableBaseLayers(location).availableEditorLayers.map(layers => layers[2]) - }) - map0.SetStyle("width: 500px; height: 250px; overflow: hidden; border: 2px solid red") - .AttachTo("maindiv") - - const layout = AllKnownLayouts.layoutsList[1] - State.state = new State(layout) - console.log("LAYOUT is", layout.id) - - const feature = { - "type": "Feature", - _matching_layer_id: "bike_repair_station", - "properties": { - id: "node/-1", - "amenity": "bicycle_repair_station" - }, - "geometry": { - "type": "Point", - "coordinates": [ - 4.84771728515625, - 51.17920846421931 - ] - } - } - - ; - - State.state.allElements.addOrGetElement(feature) - - const featureSource = new UIEventSource([{ - freshness: new Date(), - feature: feature - }]) - - new ShowDataLayer( - featureSource, - map0.leafletMap, - new UIEventSource(layout) - ) - - const map1 = new Minimap({ - location: location, - allowMoving: true, - background: new AvailableBaseLayers(location).availableEditorLayers.map(layers => layers[5]) - }, - ) - - map1.SetStyle("width: 500px; height: 250px; overflow: hidden; border : 2px solid black") - .AttachTo("extradiv") - - - new ShowDataLayer( - featureSource, - map1.leafletMap, - new UIEventSource(layout) - ) - - featureSource.ping() -} -//*/ -QueryParameters.GetQueryParameter("test", "true").setData("true") -State.state= new State(undefined) -const id = "node/5414688303" -State.state.allElements.addElementById(id, new UIEventSource({id: id})) -new Combine([ - new DeleteWizard(id, { - noDeleteOptions: [ - { - if:[ new Tag("access","private")], - then: new Translation({ - en: "Very private! Delete now or me send lawfull lawyer" - }) - } - ] - }), -]).AttachTo("maindiv") +State.state = new State(AllKnownLayouts.layoutsList[4]); +new SplitRoadWizard("way/1234").AttachTo("maindiv") \ No newline at end of file From 159e4d3350f32e780820dd63f97b0594efbb65f0 Mon Sep 17 00:00:00 2001 From: Arno Deceuninck Date: Tue, 13 Jul 2021 16:11:57 +0200 Subject: [PATCH 02/16] Select point on minimap where to split --- Logic/GeoOperations.ts | 9 ++ UI/Base/Minimap.ts | 2 +- UI/Popup/SplitRoadWizard.ts | 217 ++++++++++++++---------------------- langs/en.json | 6 + test.html | 14 ++- test.ts | 38 ++++++- 6 files changed, 146 insertions(+), 140 deletions(-) diff --git a/Logic/GeoOperations.ts b/Logic/GeoOperations.ts index 31cb88ad2..e4ee3234c 100644 --- a/Logic/GeoOperations.ts +++ b/Logic/GeoOperations.ts @@ -273,6 +273,15 @@ export class GeoOperations { } return undefined; } + + /** + * Generates the closest point on a way from a given point + * @param way The road on which you want to find a point + * @param point Point defined as [lon, lat] + */ + public static nearestPoint(way, point: [number, number]){ + return turf.nearestPointOnLine(way, point); + } } diff --git a/UI/Base/Minimap.ts b/UI/Base/Minimap.ts index 647fade47..1eeb7aada 100644 --- a/UI/Base/Minimap.ts +++ b/UI/Base/Minimap.ts @@ -25,7 +25,7 @@ export default class Minimap extends BaseUIElement { super() options = options ?? {} this._background = options?.background ?? new UIEventSource(AvailableBaseLayers.osmCarto) - this._location = options?.location ?? new UIEventSource(undefined) + this._location = options?.location ?? new UIEventSource({lat: 0, lon: 0, zoom: 1}) this._id = "minimap" + Minimap._nextId; this._allowMoving = options.allowMoving ?? true; Minimap._nextId++ diff --git a/UI/Popup/SplitRoadWizard.ts b/UI/Popup/SplitRoadWizard.ts index 5caf0ec61..55410c676 100644 --- a/UI/Popup/SplitRoadWizard.ts +++ b/UI/Popup/SplitRoadWizard.ts @@ -1,19 +1,16 @@ -import {VariableUiElement} from "../Base/VariableUIElement"; import Toggle from "../Input/Toggle"; -import Translations from "../i18n/Translations"; import Svg from "../../Svg"; import {UIEventSource} from "../../Logic/UIEventSource"; -import {TagsFilter} from "../../Logic/Tags/TagsFilter"; -import TagRenderingConfig from "../../Customizations/JSON/TagRenderingConfig"; -import Combine from "../Base/Combine"; import {SubtleButton} from "../Base/SubtleButton"; -import {FixedUiElement} from "../Base/FixedUiElement"; -import {Translation} from "../i18n/Translation"; -import {AndOrTagConfigJson} from "../../Customizations/JSON/TagConfigJson"; -import BaseUIElement from "../BaseUIElement"; -import SplitRoadAction from "../../Logic/Osm/SplitRoadAction"; import Minimap from "../Base/Minimap"; import State from "../../State"; +import ShowDataLayer from "../ShowDataLayer"; +import {GeoOperations} from "../../Logic/GeoOperations"; +import {LeafletMouseEvent} from "leaflet"; +import LayerConfig from "../../Customizations/JSON/LayerConfig"; +import Combine from "../Base/Combine"; +import {Button} from "../Base/Button"; +import Translations from "../i18n/Translations"; export default class SplitRoadWizard extends Toggle { /** @@ -23,9 +20,59 @@ export default class SplitRoadWizard extends Toggle { */ constructor(id: string) { + const t = Translations.t.split; - const splitClicked = new UIEventSource(false); + // Contains the points on the road that are selected to split on + const splitPositions = new UIEventSource([]); + // Toggle variable between show split button and map + const splitClicked = new UIEventSource(true); // todo: -> false + + // Minimap on which you can select the points to be splitted + const miniMap = new Minimap({background: State.state.backgroundLayer}); + miniMap.SetStyle("width: 100%; height: 50rem;"); + + // Define how a cut is displayed on the map + const layoutConfigJson = {id: "splitpositions", source: {osmTags: "_cutposition=yes"}, icon: "./assets/svg/plus.svg"} + State.state.layoutToUse.data.layers.push(new LayerConfig(layoutConfigJson,undefined,"Split Road Wizard")) + + // Load the road with given id on the minimap + const roadElement = State.state.allElements.ContainingFeatures.get(id) + const roadEventSource = new UIEventSource([{feature: roadElement, freshness: new Date()}]); + // Datalayer displaying the road and the cut points (if any) + const dataLayer = new ShowDataLayer(roadEventSource, miniMap.leafletMap, State.state.layoutToUse, false, true); + + /** + * Handles a click on the overleaf map. + * Finds the closest intersection with the road and adds a point there, ready to confirm the cut. + * @param coordinates Clicked location, [lon, lat] + */ + function onMapClick(coordinates) { + // Get nearest point on the road + const pointOnRoad = GeoOperations.nearestPoint(roadElement, coordinates); // pointOnRoad is a geojson + + // Update point properties to let it match the layer + pointOnRoad.properties._cutposition = "yes"; + pointOnRoad._matching_layer_id = "splitpositions"; + + // Add it to the list of all points and notify observers + splitPositions.data.push(pointOnRoad); + splitPositions.ping(); + + // let the state remember the point, to be able to retrieve it later by id + State.state.allElements.addOrGetElement(pointOnRoad); + + roadEventSource.data.push({feature: pointOnRoad, freshness: new Date()}); // show the point on the data layer + roadEventSource.ping(); // not updated using .setData, so manually ping observers + } + + // When clicked, pass clicked location coordinates to onMapClick function + miniMap.leafletMap.addCallbackAndRunD( + (leafletMap) => leafletMap.on("click", (mouseEvent: LeafletMouseEvent) => { + onMapClick([mouseEvent.latlng.lng, mouseEvent.latlng.lat]) + })) + + // Toggle between splitmap const splitButton = new SubtleButton(Svg.scissors_ui(), "Split road"); splitButton.onClick( () => { @@ -33,131 +80,33 @@ export default class SplitRoadWizard extends Toggle { } ) - // const isShown = new UIEventSource(id.indexOf("-") < 0) + // Only show the splitButton if logged in, else show login prompt + const splitToggle = new Toggle( + splitButton, + t.loginToSplit.Clone().onClick(State.state.osmConnection.AttemptLogin), + State.state.osmConnection.isLoggedIn) - const miniMap = new Minimap({background: State.state.backgroundLayer}); + // Save button + const saveButton = new Button("Split here", () => window.alert("Splitting...")); + saveButton.SetClass("block btn btn-primary"); + const disabledSaveButton = new Button("Split here", undefined); + disabledSaveButton.SetClass("block btn btn-disabled"); + // Only show the save button if there are split points defined + const saveToggle = new Toggle(disabledSaveButton, saveButton, splitPositions.map((data) => data.length === 0)) - super(miniMap, splitButton, splitClicked); + const cancelButton = new Button("Cancel", () => { + splitClicked.setData(false); + + splitPositions.setData([]); + roadEventSource.setData([roadEventSource.data[0]]) + }); + + cancelButton.SetClass("block btn btn-secondary"); + + const splitTitle = t.splitTitle; + + const mapView = new Combine([splitTitle, miniMap, cancelButton, saveToggle]); + super(mapView, splitToggle, splitClicked); } - - - private static constructConfirmButton(deleteReasons: UIEventSource): BaseUIElement { - const t = Translations.t.delete; - const btn = new Combine([ - Svg.delete_icon_ui().SetClass("w-6 h-6 mr-3 block"), - t.delete.Clone() - ]).SetClass("flex btn bg-red-500") - - - const btnNonActive = new Combine([ - Svg.delete_icon_ui().SetClass("w-6 h-6 mr-3 block"), - t.delete.Clone() - ]).SetClass("flex btn btn-disabled bg-red-200") - - return new Toggle( - btn, - btnNonActive, - deleteReasons.map(reason => reason !== undefined) - ) - - } - - - private static constructExplanation(tags: UIEventSource, deleteAction: SplitRoadAction) { - const t = Translations.t.delete; - return new VariableUiElement(tags.map( - currentTags => { - const cbd = deleteAction.canBeDeleted.data; - if (currentTags === undefined) { - return t.explanations.selectReason.Clone().SetClass("subtle"); - } - - const hasDeletionTag = currentTags.asChange(currentTags).some(kv => kv.k === "_delete_reason") - - if (cbd.canBeDeleted && hasDeletionTag) { - return t.explanations.hardDelete.Clone() - } - return new Combine([t.explanations.softDelete.Subs({reason: cbd.reason}), - new FixedUiElement(currentTags.asHumanString(false, true, currentTags)).SetClass("subtle") - ]).SetClass("flex flex-col") - - - } - , [deleteAction.canBeDeleted] - )).SetClass("block") - } - - private static generateDeleteTagRenderingConfig(softDeletionTags: TagsFilter, - nonDeleteOptions: { if: TagsFilter; then: Translation }[], - extraDeleteReasons: { explanation: Translation; changesetMessage: string }[], - currentTags: any) { - const t = Translations.t.delete - nonDeleteOptions = nonDeleteOptions ?? [] - const softDeletionTagsStr = [] - if (softDeletionTags !== undefined) { - softDeletionTags.asChange(currentTags) - } - const extraOptionsStr: { if: AndOrTagConfigJson, then: any }[] = [] - for (const nonDeleteOption of nonDeleteOptions) { - const newIf: string[] = nonDeleteOption.if.asChange({}).map(kv => kv.k + "=" + kv.v) - - extraOptionsStr.push({ - if: {and: newIf}, - then: nonDeleteOption.then - }) - } - - for (const extraDeleteReason of (extraDeleteReasons ?? [])) { - extraOptionsStr.push({ - if: {and: ["_delete_reason=" + extraDeleteReason.changesetMessage]}, - then: extraDeleteReason.explanation - }) - } - return new TagRenderingConfig( - { - question: t.whyDelete, - render: "Deleted because {_delete_reason}", - freeform: { - key: "_delete_reason", - addExtraTags: softDeletionTagsStr - }, - mappings: [ - - ...extraOptionsStr, - - { - if: { - and: [ - "_delete_reason=testing point", - ...softDeletionTagsStr - ] - }, - then: t.reasons.test - }, - { - if: { - and: [ - "_delete_reason=disused", - ...softDeletionTagsStr - ] - }, - then: t.reasons.disused - }, - { - if: { - and: [ - "_delete_reason=not found", - ...softDeletionTagsStr - ] - }, - then: t.reasons.notFound - } - ] - - - }, undefined, "Delete wizard" - ) - } - } \ No newline at end of file diff --git a/langs/en.json b/langs/en.json index 7f327653c..fed6347da 100644 --- a/langs/en.json +++ b/langs/en.json @@ -27,6 +27,12 @@ "intro": "MapComplete is an OpenStreetMap-viewer and editor, which shows you information about a specific theme.", "pickTheme": "Pick a theme below to get started." }, + "split": { + "split": "Split", + "cancel": "Cancel", + "loginToSplit": "You must be logged in to split a road", + "splitTitle": "Choose on the map where to split this road" + }, "delete": { "delete": "Delete", "cancel": "Cancel", diff --git a/test.html b/test.html index 8b6c44878..e93cbd6f2 100644 --- a/test.html +++ b/test.html @@ -4,10 +4,16 @@ Small tests - - - - + + + + + + + + + + diff --git a/test.ts b/test.ts index 630d0a7dc..b20f90323 100644 --- a/test.ts +++ b/test.ts @@ -2,5 +2,41 @@ import SplitRoadWizard from "./UI/Popup/SplitRoadWizard"; import State from "./State"; import {AllKnownLayouts} from "./Customizations/AllKnownLayouts"; -State.state = new State(AllKnownLayouts.layoutsList[4]); +const way = { + "type": "Feature", + "properties": { + "id": "way/1234", + "highway": "residential", + "cyclestreet": "yes" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 4.488961100578308, + 51.204971024401374 + ], + [ + 4.4896745681762695, + 51.204712226516435 + ], + [ + 4.489814043045044, + 51.20459459063348 + ], + [ + 4.48991060256958, + 51.204439983016115 + ], + [ + 4.490291476249695, + 51.203845074952376 + ] + ] + } +} + +State.state = new State(AllKnownLayouts.allKnownLayouts.get("fietsstraten")); +// add road to state +State.state.allElements.addOrGetElement(way); new SplitRoadWizard("way/1234").AttachTo("maindiv") \ No newline at end of file From f77c1efdf5b99e1f7295e3b2fc49c229711fd8b9 Mon Sep 17 00:00:00 2001 From: Arno Deceuninck Date: Wed, 14 Jul 2021 15:28:02 +0200 Subject: [PATCH 03/16] SplitAction logic, not yet pushing changes to osm, pieter will take over --- Logic/GeoOperations.ts | 2 +- Logic/Osm/Changes.ts | 84 ++++++++++++++++--- Logic/Osm/OsmObject.ts | 2 +- Logic/Osm/SplitAction.ts | 162 ++++++++++++++++++++++++++++++++++++ UI/Popup/SplitRoadWizard.ts | 4 +- test.ts | 37 +++++--- 6 files changed, 262 insertions(+), 29 deletions(-) create mode 100644 Logic/Osm/SplitAction.ts diff --git a/Logic/GeoOperations.ts b/Logic/GeoOperations.ts index e4ee3234c..4550dabd4 100644 --- a/Logic/GeoOperations.ts +++ b/Logic/GeoOperations.ts @@ -280,7 +280,7 @@ export class GeoOperations { * @param point Point defined as [lon, lat] */ public static nearestPoint(way, point: [number, number]){ - return turf.nearestPointOnLine(way, point); + return turf.nearestPointOnLine(way, point, {units: "kilometers"}); } } diff --git a/Logic/Osm/Changes.ts b/Logic/Osm/Changes.ts index 4a4b00d35..d26dec6a6 100644 --- a/Logic/Osm/Changes.ts +++ b/Logic/Osm/Changes.ts @@ -1,4 +1,4 @@ -import {OsmNode, OsmObject} from "./OsmObject"; +import {OsmNode, OsmObject, OsmWay} from "./OsmObject"; import State from "../../State"; import {Utils} from "../../Utils"; import {UIEventSource} from "../UIEventSource"; @@ -86,6 +86,14 @@ export class Changes implements FeatureSource{ this.uploadAll([], this.pending.data); this.pending.setData([]); } + + /** + * Returns a new ID and updates the value for the next ID + */ + public getNewID(){ + return Changes._nextId--; + } + /** * Create a new node element at the given lat/long. * An internal OsmObject is created to upload later on, a geojson represention is returned. @@ -93,8 +101,7 @@ export class Changes implements FeatureSource{ */ public createElement(basicTags: Tag[], lat: number, lon: number) { console.log("Creating a new element with ", basicTags) - const osmNode = new OsmNode(Changes._nextId); - Changes._nextId--; + const osmNode = new OsmNode(this.getNewID()); const id = "node/" + osmNode.id; osmNode.lat = lat; @@ -114,16 +121,7 @@ export class Changes implements FeatureSource{ } } - // The basictags are COPIED, the id is included in the properties - // The tags are not yet written into the OsmObject, but this is applied onto a - const changes = []; - for (const kv of basicTags) { - properties[kv.key] = kv.value; - if (typeof kv.value !== "string") { - throw "Invalid value: don't use a regex in a preset" - } - changes.push({elementId: id, key: kv.key, value: kv.value}) - } + const changes = this.createTagChangeList(basicTags, properties, id); console.log("New feature added and pinged") this.features.data.push({feature:geojson, freshness: new Date()}); @@ -135,6 +133,57 @@ export class Changes implements FeatureSource{ return geojson; } + /** + * Creates a new road with given tags that consist of the points corresponding to given nodeIDs + * @param basicTags The tags to add to the road + * @param nodeIDs IDs of nodes of which the road consists. Those nodes must already exist in osm or already be added to the changeset. + * @param coordinates The coordinates correspoinding to the nodeID at the same index. Each coordinate is a [lon, lat] point + * @return geojson A geojson representation of the created road + */ + public createRoad(basicTags: Tag[], nodeIDs, coordinates) { + const osmWay = new OsmWay(this.getNewID()); + + const id = "way/" + osmWay.id; + osmWay.nodes = nodeIDs; + const properties = {id: id}; + + const geojson = { + "type": "Feature", + "properties": properties, + "id": id, + "geometry": { + "type": "LineString", + "coordinates": coordinates + } + } + + const changes = this.createTagChangeList(basicTags, properties, id); + + console.log("New feature added and pinged") + this.features.data.push({feature:geojson, freshness: new Date()}); + this.features.ping(); + + State.state.allElements.addOrGetElement(geojson).ping(); + + this.uploadAll([osmWay], changes); + return geojson; + } + + + private createTagChangeList(basicTags: Tag[], properties: { id: string }, id: string) { + // The basictags are COPIED, the id is included in the properties + // The tags are not yet written into the OsmObject, but this is applied onto a + const changes = []; + for (const kv of basicTags) { + properties[kv.key] = kv.value; + if (typeof kv.value !== "string") { + throw "Invalid value: don't use a regex in a preset" + } + changes.push({elementId: id, key: kv.key, value: kv.value}) + } + return changes; + } + private uploadChangesWithLatestVersions( knownElements: OsmObject[], newElements: OsmObject[], pending: { elementId: string; key: string; value: string }[]) { const knownById = new Map(); @@ -244,4 +293,13 @@ export class Changes implements FeatureSource{ }) } + + /** + * Changes the nodes of road with given id to the given nodes + * @param roadID The ID of the road to update + * @param newNodes The node id's the road consists of (should already be added to the changeset or in osm) + */ + public updateRoadCoordinates(roadID: string, newNodes: number[]) { + // TODO + } } \ No newline at end of file diff --git a/Logic/Osm/OsmObject.ts b/Logic/Osm/OsmObject.ts index 6f9ec95b3..2fe1ef50b 100644 --- a/Logic/Osm/OsmObject.ts +++ b/Logic/Osm/OsmObject.ts @@ -444,7 +444,7 @@ export class OsmWay extends OsmObject { this.nodes = element.nodes; } - asGeoJson() { + public asGeoJson() { return { "type": "Feature", "properties": this.tags, diff --git a/Logic/Osm/SplitAction.ts b/Logic/Osm/SplitAction.ts new file mode 100644 index 000000000..4405fd199 --- /dev/null +++ b/Logic/Osm/SplitAction.ts @@ -0,0 +1,162 @@ +import {UIEventSource} from "../UIEventSource"; +import {OsmNode, OsmObject, OsmWay} from "./OsmObject"; +import State from "../../State"; +import {distance} from "@turf/turf"; +import {GeoOperations} from "../GeoOperations"; +import {Changes} from "./Changes"; + +/** + * Splits a road in different segments, each splitted at one of the given points (or a point on the road close to it) + * @param roadID The id of the road you want to split + * @param points The points on the road where you want the split to occur (geojson point list) + */ +export async function splitRoad(roadID, points) { + if (points.length != 1) { + // TODO: more than one point + console.log(points) + window.alert("Warning, currently only tested on one point, you selected " + points.length + " points") + } + + let road = State.state.allElements.ContainingFeatures.get(roadID); + + /** + * Compares two points based on the starting point of the road, can be used in sort function + * @param point1 [lon, lat] point + * @param point2 [lon, lat] point + */ + function comparePointDistance(point1, point2) { + let distFromStart1 = GeoOperations.nearestPoint(road, point1).properties.location; + let distFromStart2 = GeoOperations.nearestPoint(road, point2).properties.location; + return distFromStart1 - distFromStart2; // Sort requires a number to return instead of a bool + } + + /** + * Eliminates split points close (<4m) to existing points on the road, so you can split on these points instead + * @param road The road geojson object + * @param points The points on the road where you want the split to occur (geojson point list) + * @return realSplitPoints List containing all new locations where you should split + */ + function getSplitPoints(road, points) { + // Copy the list + let roadPoints = [...road.geometry.coordinates]; + + // Get the coordinates of all geojson points + let splitPointsCoordinates = points.map((point) => point.geometry.coordinates); + + roadPoints.push(...splitPointsCoordinates); + + // Sort all points on the road based on the distance from the start + roadPoints.sort(comparePointDistance) + + // Remove points close to existing points on road + let realSplitPoints = [...splitPointsCoordinates]; + for (let index = roadPoints.length - 1; index > 0; index--) { + // Iterate backwards to prevent problems when removing elements + let dist = distance(roadPoints[index - 1], roadPoints[index], {units: "kilometers"}); + // Remove all cutpoints closer than 4m to their previous point + if ((dist < 0.004) && (splitPointsCoordinates.includes(roadPoints[index]))) { + console.log("Removed a splitpoint, using a closer point to the road instead") + realSplitPoints.splice(index, 1) + realSplitPoints.push(roadPoints[index - 1]) + } + } + return realSplitPoints; + } + + let realSplitPoints = getSplitPoints(road, points); + + // Create a sorted list containing all points + let allPoints = [...road.geometry.coordinates]; + allPoints.push(...realSplitPoints); + allPoints.sort(comparePointDistance); + + // The changeset that will contain the operations to split the road + let changes = new Changes(); + + // Download the data of the current road from Osm to get the ID's of the coordinates + let osmRoad: UIEventSource = OsmObject.DownloadObject(roadID); + + // TODO: Remove delay, use a callback on odmRoad instead and execute all code below in callback function + function delay(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + await delay(3000); + + // Dict to quickly convert a coordinate to a nodeID + let coordToIDMap = {}; + + /** + * Converts a coordinate to a string, so it's hashable (e.g. for using it in a dict) + * @param coord [lon, lat] point + */ + function getCoordKey(coord: [number, number]) { + return coord[0] + "," + coord[1]; + } + + osmRoad.data.coordinates.forEach((coord, i) => coordToIDMap[getCoordKey([coord[1], coord[0]])] = osmRoad.data.nodes[i]); + + let currentRoadPoints: number[] = []; + let currentRoadCoordinates: [number, number][] = [] + + /** + * Given a coordinate, check whether there is already a node in osm created (on the road or cutpoints) or create + * such point if it doesn't exist yet and return the id of this coordinate + * @param coord [lon, lat] point + * @return pointID The ID of the existing/created node on given coordinates + */ + function getOrCreateNodeID(coord) { + console.log(coordToIDMap) + let poinID = coordToIDMap[getCoordKey(coord)]; + if (poinID == undefined) { + console.log(getCoordKey(coord) + " not in map") + // TODO: Check if lat, lon is correct + let newNode = changes.createElement([], coord[1], coord[0]); + + coordToIDMap[coord] = newNode.id; + poinID = newNode.id; + + console.log("New point created "); + } + return poinID; + } + + /** + * Creates a new road in OSM, while copying the tags from osmRoad and using currentRoadPoints as points + * @param currentRoadPoints List of id's of nodes the road should exist of + * @param osmRoad The road to copy the tags from + */ + function createNewRoadSegment(currentRoadPoints, osmRoad) { + changes.createRoad(osmRoad.data.tags, currentRoadPoints, currentRoadCoordinates); + } + + for (let coord of allPoints) { + console.log("Handling coord") + let pointID = getOrCreateNodeID(coord); + currentRoadPoints.push(pointID); + currentRoadCoordinates.push(coord); + if (realSplitPoints.includes(coord)) { + console.log("Handling split") + // Should split here + // currentRoadPoints contains a list containing all points for this road segment + createNewRoadSegment(currentRoadPoints, osmRoad); + + // Cleanup for next split + currentRoadPoints = [pointID]; + currentRoadCoordinates = [coord]; + console.log("Splitting here...") + } + } + + // Update the road to contain only the points of the last segment + // changes.updateRoadCoordinates(roadID, currentRoadPoints); + + // push the applied changes + changes.flushChanges(); + + return; +} + + +// TODO: Vlakbij bestaand punt geklikt? Bestaand punt hergebruiken +// Nieuw wegobject aanmaken, en oude hergebruiken voor andere helft van de weg +// TODO: CHeck if relation exist to the road -> Delete them when splitted, because they might be outdated after the split diff --git a/UI/Popup/SplitRoadWizard.ts b/UI/Popup/SplitRoadWizard.ts index 55410c676..8aed42a7e 100644 --- a/UI/Popup/SplitRoadWizard.ts +++ b/UI/Popup/SplitRoadWizard.ts @@ -11,6 +11,7 @@ import LayerConfig from "../../Customizations/JSON/LayerConfig"; import Combine from "../Base/Combine"; import {Button} from "../Base/Button"; import Translations from "../i18n/Translations"; +import {splitRoad} from "../../Logic/Osm/SplitAction"; export default class SplitRoadWizard extends Toggle { /** @@ -87,7 +88,7 @@ export default class SplitRoadWizard extends Toggle { State.state.osmConnection.isLoggedIn) // Save button - const saveButton = new Button("Split here", () => window.alert("Splitting...")); + const saveButton = new Button("Split here", () => splitRoad(id, splitPositions.data)); saveButton.SetClass("block btn btn-primary"); const disabledSaveButton = new Button("Split here", undefined); disabledSaveButton.SetClass("block btn btn-disabled"); @@ -98,6 +99,7 @@ export default class SplitRoadWizard extends Toggle { splitClicked.setData(false); splitPositions.setData([]); + // Only keep showing the road, the cutpoints must be removed from the map roadEventSource.setData([roadEventSource.data[0]]) }); diff --git a/test.ts b/test.ts index b20f90323..3357bf645 100644 --- a/test.ts +++ b/test.ts @@ -5,32 +5,43 @@ import {AllKnownLayouts} from "./Customizations/AllKnownLayouts"; const way = { "type": "Feature", "properties": { - "id": "way/1234", "highway": "residential", - "cyclestreet": "yes" + "maxweight": "3.5", + "maxweight:conditional": "none @ delivery", + "name": "Silsstraat", + "_last_edit:contributor": "Jorisbo", + "_last_edit:contributor:uid": 1983103, + "_last_edit:changeset": 70963524, + "_last_edit:timestamp": "2019-06-05T18:20:44Z", + "_version_number": 9, + "id": "way/23583625" }, "geometry": { "type": "LineString", "coordinates": [ [ - 4.488961100578308, - 51.204971024401374 + 4.4889691, + 51.2049831 ], [ - 4.4896745681762695, - 51.204712226516435 + 4.4895496, + 51.2047718 ], [ - 4.489814043045044, - 51.20459459063348 + 4.48966, + 51.2047147 ], [ - 4.48991060256958, - 51.204439983016115 + 4.4897439, + 51.2046548 ], [ - 4.490291476249695, - 51.203845074952376 + 4.4898162, + 51.2045921 + ], + [ + 4.4902997, + 51.2038418 ] ] } @@ -39,4 +50,4 @@ const way = { State.state = new State(AllKnownLayouts.allKnownLayouts.get("fietsstraten")); // add road to state State.state.allElements.addOrGetElement(way); -new SplitRoadWizard("way/1234").AttachTo("maindiv") \ No newline at end of file +new SplitRoadWizard("way/23583625").AttachTo("maindiv") \ No newline at end of file From 9348a019d6c0887bc8838c0466ff36abae2f1755 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Thu, 15 Jul 2021 00:26:18 +0200 Subject: [PATCH 04/16] Further work on the road splitting feature --- Logic/Osm/Actions/Action.ts | 0 Logic/Osm/CreateNewNodeAction.ts | 0 Logic/Osm/RelationSplitlHandler.ts | 0 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 Logic/Osm/Actions/Action.ts create mode 100644 Logic/Osm/CreateNewNodeAction.ts create mode 100644 Logic/Osm/RelationSplitlHandler.ts diff --git a/Logic/Osm/Actions/Action.ts b/Logic/Osm/Actions/Action.ts new file mode 100644 index 000000000..e69de29bb diff --git a/Logic/Osm/CreateNewNodeAction.ts b/Logic/Osm/CreateNewNodeAction.ts new file mode 100644 index 000000000..e69de29bb diff --git a/Logic/Osm/RelationSplitlHandler.ts b/Logic/Osm/RelationSplitlHandler.ts new file mode 100644 index 000000000..e69de29bb From 1da3f8a3320989c7695d91f11c98731236e0df31 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Thu, 15 Jul 2021 00:26:25 +0200 Subject: [PATCH 05/16] Further work on the road splitting feature --- Logic/FeatureSource/FeatureSourceMerger.ts | 4 + Logic/Osm/Actions/Action.ts | 7 + Logic/Osm/Changes.ts | 125 +++---- Logic/Osm/CreateNewNodeAction.ts | 3 + Logic/Osm/OsmObject.ts | 11 +- Logic/Osm/RelationSplitlHandler.ts | 11 + Logic/Osm/SplitAction.ts | 372 ++++++++++++--------- UI/Popup/SplitRoadWizard.ts | 57 ++-- test.ts | 35 +- 9 files changed, 351 insertions(+), 274 deletions(-) diff --git a/Logic/FeatureSource/FeatureSourceMerger.ts b/Logic/FeatureSource/FeatureSourceMerger.ts index e9901d1f5..26d9ce498 100644 --- a/Logic/FeatureSource/FeatureSourceMerger.ts +++ b/Logic/FeatureSource/FeatureSourceMerger.ts @@ -1,6 +1,10 @@ import FeatureSource from "./FeatureSource"; import {UIEventSource} from "../UIEventSource"; +/** + * Merges features from different featureSources + * Uses the freshest feature available in the case multiple sources offer data with the same identifier + */ export default class FeatureSourceMerger implements FeatureSource { public features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]); diff --git a/Logic/Osm/Actions/Action.ts b/Logic/Osm/Actions/Action.ts index e69de29bb..c09e20cb8 100644 --- a/Logic/Osm/Actions/Action.ts +++ b/Logic/Osm/Actions/Action.ts @@ -0,0 +1,7 @@ +/** + * An action is a change to the OSM-database + * It will generate some new/modified/deleted objects, which are all bundled by the 'changes'-object + */ +export default interface Action { + +} \ No newline at end of file diff --git a/Logic/Osm/Changes.ts b/Logic/Osm/Changes.ts index d26dec6a6..0d5a05a87 100644 --- a/Logic/Osm/Changes.ts +++ b/Logic/Osm/Changes.ts @@ -1,4 +1,4 @@ -import {OsmNode, OsmObject, OsmWay} from "./OsmObject"; +import {OsmNode, OsmObject} from "./OsmObject"; import State from "../../State"; import {Utils} from "../../Utils"; import {UIEventSource} from "../UIEventSource"; @@ -121,7 +121,7 @@ export class Changes implements FeatureSource{ } } - const changes = this.createTagChangeList(basicTags, properties, id); + const changes = Changes.createTagChangeList(basicTags, properties, id); console.log("New feature added and pinged") this.features.data.push({feature:geojson, freshness: new Date()}); @@ -133,44 +133,8 @@ export class Changes implements FeatureSource{ return geojson; } - /** - * Creates a new road with given tags that consist of the points corresponding to given nodeIDs - * @param basicTags The tags to add to the road - * @param nodeIDs IDs of nodes of which the road consists. Those nodes must already exist in osm or already be added to the changeset. - * @param coordinates The coordinates correspoinding to the nodeID at the same index. Each coordinate is a [lon, lat] point - * @return geojson A geojson representation of the created road - */ - public createRoad(basicTags: Tag[], nodeIDs, coordinates) { - const osmWay = new OsmWay(this.getNewID()); - const id = "way/" + osmWay.id; - osmWay.nodes = nodeIDs; - const properties = {id: id}; - - const geojson = { - "type": "Feature", - "properties": properties, - "id": id, - "geometry": { - "type": "LineString", - "coordinates": coordinates - } - } - - const changes = this.createTagChangeList(basicTags, properties, id); - - console.log("New feature added and pinged") - this.features.data.push({feature:geojson, freshness: new Date()}); - this.features.ping(); - - State.state.allElements.addOrGetElement(geojson).ping(); - - this.uploadAll([osmWay], changes); - return geojson; - } - - - private createTagChangeList(basicTags: Tag[], properties: { id: string }, id: string) { + private static createTagChangeList(basicTags: Tag[], properties: { id: string }, id: string) { // The basictags are COPIED, the id is included in the properties // The tags are not yet written into the OsmObject, but this is applied onto a const changes = []; @@ -229,46 +193,46 @@ export class Changes implements FeatureSource{ State.state.osmConnection.UploadChangeset( State.state.layoutToUse.data, State.state.allElements, - function (csId) { - - let modifications = ""; - for (const element of changedElements) { - if (!element.changed) { - continue; - } - modifications += element.ChangesetXML(csId) + "\n"; - } - - - let creations = ""; - for (const newElement of newElements) { - creations += newElement.ChangesetXML(csId); - } - - - let changes = ``; - - if (creations.length > 0) { - changes += - "" + - creations + - ""; - } - - if (modifications.length > 0) { - changes += - "\n" + - modifications + - "\n"; - } - - changes += ""; - - return changes; - }); + (csId) => Changes.createChangesetFor(csId,changedElements, newElements ) + ); }; + public static createChangesetFor(csId: string, changedElements: OsmObject[], newElements: OsmObject[]): string { + + let modifications = ""; + for (const element of changedElements) { + modifications += element.ChangesetXML(csId) + "\n"; + } + + + let creations = ""; + for (const newElement of newElements) { + creations += newElement.ChangesetXML(csId); + } + + + let changes = ``; + + if (creations.length > 0) { + changes += + "\n\n" + + creations + + ""; + } + + if (modifications.length > 0) { + changes += + "\n\n" + + modifications + + "\n"; + } + + changes += ""; + + return changes; + } + private uploadAll( newElements: OsmObject[], pending: { elementId: string; key: string; value: string }[] @@ -293,13 +257,4 @@ export class Changes implements FeatureSource{ }) } - - /** - * Changes the nodes of road with given id to the given nodes - * @param roadID The ID of the road to update - * @param newNodes The node id's the road consists of (should already be added to the changeset or in osm) - */ - public updateRoadCoordinates(roadID: string, newNodes: number[]) { - // TODO - } } \ No newline at end of file diff --git a/Logic/Osm/CreateNewNodeAction.ts b/Logic/Osm/CreateNewNodeAction.ts index e69de29bb..c0d9efba1 100644 --- a/Logic/Osm/CreateNewNodeAction.ts +++ b/Logic/Osm/CreateNewNodeAction.ts @@ -0,0 +1,3 @@ +export default class CreateNewNodeAction { + +} \ No newline at end of file diff --git a/Logic/Osm/OsmObject.ts b/Logic/Osm/OsmObject.ts index 2fe1ef50b..2a2af0d50 100644 --- a/Logic/Osm/OsmObject.ts +++ b/Logic/Osm/OsmObject.ts @@ -60,6 +60,8 @@ export abstract class OsmObject { case("relation"): new OsmRelation(idN).Download(newContinuation); break; + default: + throw "Invalid road type:" + type; } return src; @@ -150,7 +152,7 @@ export abstract class OsmObject { const minlat = bounds[1][0] const maxlat = bounds[0][0]; const url = `${OsmObject.backendURL}api/0.6/map.json?bbox=${minlon},${minlat},${maxlon},${maxlat}` - Utils.downloadJson(url).then( data => { + Utils.downloadJson(url).then(data => { const elements: any[] = data.elements; const objects = OsmObject.ParseObjects(elements) callback(objects); @@ -354,9 +356,9 @@ export class OsmNode extends OsmObject { ChangesetXML(changesetId: string): string { let tags = this.TagsXML(); - return ' \n' + + return ' \n' + tags + - ' \n'; + ' \n'; } SaveExtraData(element) { @@ -401,7 +403,6 @@ export class OsmWay extends OsmObject { constructor(id) { super("way", id); - } centerpoint(): [number, number] { @@ -418,7 +419,7 @@ export class OsmWay extends OsmObject { return ' \n' + nds + tags + - ' \n'; + ' \n'; } SaveExtraData(element, allNodes: OsmNode[]) { diff --git a/Logic/Osm/RelationSplitlHandler.ts b/Logic/Osm/RelationSplitlHandler.ts index e69de29bb..7657e094f 100644 --- a/Logic/Osm/RelationSplitlHandler.ts +++ b/Logic/Osm/RelationSplitlHandler.ts @@ -0,0 +1,11 @@ +/** + * The logic to handle relations after a way within + */ +export default class RelationSplitlHandler { + + constructor() { + + } + + +} \ No newline at end of file diff --git a/Logic/Osm/SplitAction.ts b/Logic/Osm/SplitAction.ts index 4405fd199..6153f2bad 100644 --- a/Logic/Osm/SplitAction.ts +++ b/Logic/Osm/SplitAction.ts @@ -1,162 +1,222 @@ -import {UIEventSource} from "../UIEventSource"; -import {OsmNode, OsmObject, OsmWay} from "./OsmObject"; -import State from "../../State"; -import {distance} from "@turf/turf"; +import {OsmNode, OsmObject, OsmRelation, OsmWay} from "./OsmObject"; import {GeoOperations} from "../GeoOperations"; +import State from "../../State"; +import {UIEventSource} from "../UIEventSource"; import {Changes} from "./Changes"; -/** - * Splits a road in different segments, each splitted at one of the given points (or a point on the road close to it) - * @param roadID The id of the road you want to split - * @param points The points on the road where you want the split to occur (geojson point list) - */ -export async function splitRoad(roadID, points) { - if (points.length != 1) { - // TODO: more than one point - console.log(points) - window.alert("Warning, currently only tested on one point, you selected " + points.length + " points") - } - - let road = State.state.allElements.ContainingFeatures.get(roadID); - - /** - * Compares two points based on the starting point of the road, can be used in sort function - * @param point1 [lon, lat] point - * @param point2 [lon, lat] point - */ - function comparePointDistance(point1, point2) { - let distFromStart1 = GeoOperations.nearestPoint(road, point1).properties.location; - let distFromStart2 = GeoOperations.nearestPoint(road, point2).properties.location; - return distFromStart1 - distFromStart2; // Sort requires a number to return instead of a bool - } - - /** - * Eliminates split points close (<4m) to existing points on the road, so you can split on these points instead - * @param road The road geojson object - * @param points The points on the road where you want the split to occur (geojson point list) - * @return realSplitPoints List containing all new locations where you should split - */ - function getSplitPoints(road, points) { - // Copy the list - let roadPoints = [...road.geometry.coordinates]; - - // Get the coordinates of all geojson points - let splitPointsCoordinates = points.map((point) => point.geometry.coordinates); - - roadPoints.push(...splitPointsCoordinates); - - // Sort all points on the road based on the distance from the start - roadPoints.sort(comparePointDistance) - - // Remove points close to existing points on road - let realSplitPoints = [...splitPointsCoordinates]; - for (let index = roadPoints.length - 1; index > 0; index--) { - // Iterate backwards to prevent problems when removing elements - let dist = distance(roadPoints[index - 1], roadPoints[index], {units: "kilometers"}); - // Remove all cutpoints closer than 4m to their previous point - if ((dist < 0.004) && (splitPointsCoordinates.includes(roadPoints[index]))) { - console.log("Removed a splitpoint, using a closer point to the road instead") - realSplitPoints.splice(index, 1) - realSplitPoints.push(roadPoints[index - 1]) - } - } - return realSplitPoints; - } - - let realSplitPoints = getSplitPoints(road, points); - - // Create a sorted list containing all points - let allPoints = [...road.geometry.coordinates]; - allPoints.push(...realSplitPoints); - allPoints.sort(comparePointDistance); - - // The changeset that will contain the operations to split the road - let changes = new Changes(); - - // Download the data of the current road from Osm to get the ID's of the coordinates - let osmRoad: UIEventSource = OsmObject.DownloadObject(roadID); - - // TODO: Remove delay, use a callback on odmRoad instead and execute all code below in callback function - function delay(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - await delay(3000); - - // Dict to quickly convert a coordinate to a nodeID - let coordToIDMap = {}; - - /** - * Converts a coordinate to a string, so it's hashable (e.g. for using it in a dict) - * @param coord [lon, lat] point - */ - function getCoordKey(coord: [number, number]) { - return coord[0] + "," + coord[1]; - } - - osmRoad.data.coordinates.forEach((coord, i) => coordToIDMap[getCoordKey([coord[1], coord[0]])] = osmRoad.data.nodes[i]); - - let currentRoadPoints: number[] = []; - let currentRoadCoordinates: [number, number][] = [] - - /** - * Given a coordinate, check whether there is already a node in osm created (on the road or cutpoints) or create - * such point if it doesn't exist yet and return the id of this coordinate - * @param coord [lon, lat] point - * @return pointID The ID of the existing/created node on given coordinates - */ - function getOrCreateNodeID(coord) { - console.log(coordToIDMap) - let poinID = coordToIDMap[getCoordKey(coord)]; - if (poinID == undefined) { - console.log(getCoordKey(coord) + " not in map") - // TODO: Check if lat, lon is correct - let newNode = changes.createElement([], coord[1], coord[0]); - - coordToIDMap[coord] = newNode.id; - poinID = newNode.id; - - console.log("New point created "); - } - return poinID; - } - - /** - * Creates a new road in OSM, while copying the tags from osmRoad and using currentRoadPoints as points - * @param currentRoadPoints List of id's of nodes the road should exist of - * @param osmRoad The road to copy the tags from - */ - function createNewRoadSegment(currentRoadPoints, osmRoad) { - changes.createRoad(osmRoad.data.tags, currentRoadPoints, currentRoadCoordinates); - } - - for (let coord of allPoints) { - console.log("Handling coord") - let pointID = getOrCreateNodeID(coord); - currentRoadPoints.push(pointID); - currentRoadCoordinates.push(coord); - if (realSplitPoints.includes(coord)) { - console.log("Handling split") - // Should split here - // currentRoadPoints contains a list containing all points for this road segment - createNewRoadSegment(currentRoadPoints, osmRoad); - - // Cleanup for next split - currentRoadPoints = [pointID]; - currentRoadCoordinates = [coord]; - console.log("Splitting here...") - } - } - - // Update the road to contain only the points of the last segment - // changes.updateRoadCoordinates(roadID, currentRoadPoints); - - // push the applied changes - changes.flushChanges(); - - return; +interface SplitInfo { + originalIndex?: number, // or negative for new elements + lngLat: [number, number], + doSplit: boolean } +export default class SplitAction { + private readonly roadObject: any; -// TODO: Vlakbij bestaand punt geklikt? Bestaand punt hergebruiken -// Nieuw wegobject aanmaken, en oude hergebruiken voor andere helft van de weg -// TODO: CHeck if relation exist to the road -> Delete them when splitted, because they might be outdated after the split + /*** + * + * @param roadObject: the geojson of the road object. Properties.id must be the corresponding OSM-id + */ + constructor(roadObject: any) { + this.roadObject = roadObject; + } + + private static SegmentSplitInfo(splitInfo: SplitInfo[]): SplitInfo[][] { + const wayParts = [] + let currentPart = [] + for (const splitInfoElement of splitInfo) { + currentPart.push(splitInfoElement) + + if (splitInfoElement.doSplit) { + // We have to do a split! + // We add the current index to the currentParts, flush it and add it again + wayParts.push(currentPart) + currentPart = [splitInfoElement] + } + } + wayParts.push(currentPart) + return wayParts + } + + public DoSplit(splitPoints: any[]) { + // We mark the new split points with a new id + for (const splitPoint of splitPoints) { + splitPoint.properties["_is_split_point"] = true + } + + + const self = this; + const id = this.roadObject.properties.id + const osmWay = >OsmObject.DownloadObject(id) + const partOf = OsmObject.DownloadReferencingRelations(id) + osmWay.map(originalElement => { + + if(originalElement === undefined || partOf === undefined){ + return; + } + + const changes = State.state?.changes ?? new Changes(); + // First, calculate splitpoints and remove points close to one another + const splitInfo = self.CalculateSplitCoordinates(splitPoints) + // Now we have a list with e.g. + // [ { originalIndex: 0}, {originalIndex: 1, doSplit: true}, {originalIndex: 2}, {originalIndex: undefined, doSplit: true}, {originalIndex: 3}] + + // Lets change 'originalIndex' to the actual node id first: + for (const element of splitInfo) { + if (element.originalIndex >= 0) { + element.originalIndex = originalElement.nodes[element.originalIndex] + } else { + element.originalIndex = changes.getNewID(); + } + } + + // Next up is creating actual parts from this + const wayParts = SplitAction.SegmentSplitInfo(splitInfo); + + // Allright! At this point, we have our new ways! + // Which one is the longest of them (and can keep the id)? + + let longest = undefined; + for (const wayPart of wayParts) { + if (longest === undefined) { + longest = wayPart; + continue + } + if (wayPart.length > longest.length) { + longest = wayPart + } + } + + const newOsmObjects: OsmObject[] = [] + const modifiedObjects: OsmObject[] = [] + // Let's create the new points as needed + for (const element of splitInfo) { + if (element.originalIndex >= 0) { + continue; + } + const node = new OsmNode(element.originalIndex) + node.lon = element.lngLat[0] + node.lat = element.lngLat[1] + newOsmObjects.push(node) + } + + const newWayIds: number[] = [] + // Lets create OsmWays based on them + for (const wayPart of wayParts) { + + let isOriginal = wayPart === longest + if(isOriginal){ + // We change the actual element! + originalElement.nodes = wayPart.map(p => p.originalIndex); + originalElement.changed = true; + modifiedObjects.push(originalElement) + }else{ + let id = changes.getNewID(); + const way = new OsmWay(id) + way.tags = originalElement.tags; + way.nodes = wayPart.map(p => p.originalIndex); + way.changed = true; + newOsmObjects.push(way) + newWayIds.push(way.id) + } + + } + + // At last, we still have to check that we aren't part of a relation... + // At least, the order of the ways is identical, so we can keep the same roles + + modifiedObjects.push(...SplitAction.UpdateRelations(partOf.data, newWayIds, originalElement)) + // And we have our objects! + // Time to upload + + console.log(Changes.createChangesetFor("123", modifiedObjects, newOsmObjects)) + }, [partOf]) + } + + private static UpdateRelations(data: OsmRelation[], newWayIds: number[], originalElement: OsmWay):OsmRelation[]{ + // TODO + return [] + } + + /** + * Calculates the actual points to split + * If another point is closer then ~5m, we reuse that point + */ + private CalculateSplitCoordinates( + splitPoints: any[], + toleranceInM = 5): SplitInfo[] { + + const allPoints = [...splitPoints]; + // We have a bunch of coordinates here: [ [lat, lon], [lat, lon], ...] ... + const originalPoints: [number, number][] = this.roadObject.geometry.coordinates + // We project them onto the line (which should yield pretty much the same point + for (let i = 0; i < originalPoints.length; i++) { + let originalPoint = originalPoints[i]; + let projected = GeoOperations.nearestPoint(this.roadObject, originalPoint) + projected.properties["_is_split_point"] = false + projected.properties["_original_index"] = i + allPoints.push(projected) + } + // At this point, we have a list of both the split point and the old points, with some properties to discriminate between them + // We sort this list so that the new points are at the same location + allPoints.sort((a, b) => a.properties.location - b.properties.location) + + // When this is done, we check that no now point is too close to an already existing point and no very small segments get created + + for (let i = allPoints.length - 1; i > 0; i--) { + + const point = allPoints[i]; + if (point.properties._original_index !== undefined) { + // This point is already in OSM - we have to keep it! + continue; + } + + if (i != allPoints.length - 1) { + const prevPoint = allPoints[i + 1] + const diff = Math.abs(point.properties.location - prevPoint.properties.location) * 1000 + if (diff <= toleranceInM) { + // To close to the previous point! We delete this point... + allPoints.splice(i, 1) + // ... and mark the previous point as a split point + prevPoint.properties._is_split_point = true + continue; + } + } + + if (i > 0) { + const nextPoint = allPoints[i - 1] + const diff = Math.abs(point.properties.location - nextPoint.properties.location) * 1000 + if (diff <= toleranceInM) { + // To close to the next point! We delete this point... + allPoints.splice(i, 1) + // ... and mark the next point as a split point + nextPoint.properties._is_split_point = true + // noinspection UnnecessaryContinueJS + continue; + } + } + // We don't have to remove this point... + } + + const splitInfo: SplitInfo[] = [] + let nextId = -1 + + for (const p of allPoints) { + let index = p.properties._original_index + if (index === undefined) { + index = nextId; + nextId--; + } + const splitInfoElement = { + originalIndex: index, + lngLat: p.geometry.coordinates, + doSplit: p.properties._is_split_point + } + splitInfo.push(splitInfoElement) + } + + return splitInfo + } + + +} diff --git a/UI/Popup/SplitRoadWizard.ts b/UI/Popup/SplitRoadWizard.ts index 8aed42a7e..c9cd65e56 100644 --- a/UI/Popup/SplitRoadWizard.ts +++ b/UI/Popup/SplitRoadWizard.ts @@ -7,13 +7,15 @@ import State from "../../State"; import ShowDataLayer from "../ShowDataLayer"; import {GeoOperations} from "../../Logic/GeoOperations"; import {LeafletMouseEvent} from "leaflet"; -import LayerConfig from "../../Customizations/JSON/LayerConfig"; import Combine from "../Base/Combine"; import {Button} from "../Base/Button"; import Translations from "../i18n/Translations"; -import {splitRoad} from "../../Logic/Osm/SplitAction"; +import LayoutConfig from "../../Customizations/JSON/LayoutConfig"; +import SplitAction from "../../Logic/Osm/SplitAction"; export default class SplitRoadWizard extends Toggle { + private static splitLayout = new UIEventSource(SplitRoadWizard.GetSplitLayout()) + /** * A UI Element used for splitting roads * @@ -23,25 +25,25 @@ export default class SplitRoadWizard extends Toggle { const t = Translations.t.split; - // Contains the points on the road that are selected to split on - const splitPositions = new UIEventSource([]); + // Contains the points on the road that are selected to split on - contains geojson points with extra properties such as 'location' with the distance along the linestring + const splitPoints = new UIEventSource<{feature: any, freshness: Date}[]>([]); // Toggle variable between show split button and map - const splitClicked = new UIEventSource(true); // todo: -> false + const splitClicked = new UIEventSource(false); // Minimap on which you can select the points to be splitted const miniMap = new Minimap({background: State.state.backgroundLayer}); miniMap.SetStyle("width: 100%; height: 50rem;"); // Define how a cut is displayed on the map - const layoutConfigJson = {id: "splitpositions", source: {osmTags: "_cutposition=yes"}, icon: "./assets/svg/plus.svg"} - State.state.layoutToUse.data.layers.push(new LayerConfig(layoutConfigJson,undefined,"Split Road Wizard")) // Load the road with given id on the minimap const roadElement = State.state.allElements.ContainingFeatures.get(id) + const splitAction = new SplitAction(roadElement) const roadEventSource = new UIEventSource([{feature: roadElement, freshness: new Date()}]); // Datalayer displaying the road and the cut points (if any) - const dataLayer = new ShowDataLayer(roadEventSource, miniMap.leafletMap, State.state.layoutToUse, false, true); + new ShowDataLayer(roadEventSource, miniMap.leafletMap, State.state.layoutToUse, false, true); + new ShowDataLayer(splitPoints, miniMap.leafletMap, SplitRoadWizard.splitLayout, false, false) /** * Handles a click on the overleaf map. @@ -54,17 +56,14 @@ export default class SplitRoadWizard extends Toggle { // Update point properties to let it match the layer pointOnRoad.properties._cutposition = "yes"; - pointOnRoad._matching_layer_id = "splitpositions"; - - // Add it to the list of all points and notify observers - splitPositions.data.push(pointOnRoad); - splitPositions.ping(); + pointOnRoad["_matching_layer_id"] = "splitpositions"; // let the state remember the point, to be able to retrieve it later by id State.state.allElements.addOrGetElement(pointOnRoad); - - roadEventSource.data.push({feature: pointOnRoad, freshness: new Date()}); // show the point on the data layer - roadEventSource.ping(); // not updated using .setData, so manually ping observers + + // Add it to the list of all points and notify observers + splitPoints.data.push({feature: pointOnRoad, freshness: new Date()}); // show the point on the data layer + splitPoints.ping(); // not updated using .setData, so manually ping observers } // When clicked, pass clicked location coordinates to onMapClick function @@ -88,19 +87,16 @@ export default class SplitRoadWizard extends Toggle { State.state.osmConnection.isLoggedIn) // Save button - const saveButton = new Button("Split here", () => splitRoad(id, splitPositions.data)); + const saveButton = new Button("Split here", () => splitAction.DoSplit(splitPoints.data)); saveButton.SetClass("block btn btn-primary"); const disabledSaveButton = new Button("Split here", undefined); disabledSaveButton.SetClass("block btn btn-disabled"); // Only show the save button if there are split points defined - const saveToggle = new Toggle(disabledSaveButton, saveButton, splitPositions.map((data) => data.length === 0)) + const saveToggle = new Toggle(disabledSaveButton, saveButton, splitPoints.map((data) => data.length === 0)) const cancelButton = new Button("Cancel", () => { splitClicked.setData(false); - - splitPositions.setData([]); - // Only keep showing the road, the cutpoints must be removed from the map - roadEventSource.setData([roadEventSource.data[0]]) + splitPoints.setData([]); }); cancelButton.SetClass("block btn btn-secondary"); @@ -111,4 +107,21 @@ export default class SplitRoadWizard extends Toggle { super(mapView, splitToggle, splitClicked); } + + private static GetSplitLayout(): LayoutConfig { + return new LayoutConfig({ + maintainer: "mapcomplete", + language: [], + startLon: 0, + startLat: 0, + description: undefined, + icon: "", startZoom: 0, + title: "Split locations", + version: "", + + id: "splitpositions", + layers: [{id: "splitpositions", source: {osmTags: "_cutposition=yes"}, icon: "./assets/svg/plus.svg"}] + }, true, "split road wizard layout") + + } } \ No newline at end of file diff --git a/test.ts b/test.ts index 3357bf645..9b632dbe6 100644 --- a/test.ts +++ b/test.ts @@ -1,6 +1,5 @@ -import SplitRoadWizard from "./UI/Popup/SplitRoadWizard"; -import State from "./State"; -import {AllKnownLayouts} from "./Customizations/AllKnownLayouts"; +import SplitAction from "./Logic/Osm/SplitAction"; +import {GeoOperations} from "./Logic/GeoOperations"; const way = { "type": "Feature", @@ -47,7 +46,31 @@ const way = { } } -State.state = new State(AllKnownLayouts.allKnownLayouts.get("fietsstraten")); +let splitPoint = { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Point", + "coordinates": [ + 4.490211009979248, + 51.2041509326002 + ] + } +} + + +let splitClose = { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Point", + "coordinates": [ + 4.489563927054405, + 51.2047546593862 + ] + } +} +// State.state = new State(AllKnownLayouts.allKnownLayouts.get("fietsstraten")); // add road to state -State.state.allElements.addOrGetElement(way); -new SplitRoadWizard("way/23583625").AttachTo("maindiv") \ No newline at end of file +// State.state.allElements.addOrGetElement(way); +new SplitAction(way).DoSplit([splitPoint, splitClose].map(p => GeoOperations.nearestPoint(way,<[number, number]> p.geometry.coordinates))) \ No newline at end of file From dc4cdda3b521d6eb7cd2e7260bcc0998bb83df32 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Thu, 15 Jul 2021 00:39:40 +0200 Subject: [PATCH 06/16] Translation sync --- assets/themes/artwork/artwork.json | 2 +- assets/themes/bicycle_library/bicycle_library.json | 6 ++++-- assets/themes/openwindpowermap/openwindpowermap.json | 11 ++++++----- langs/themes/de.json | 8 ++++---- langs/themes/en.json | 7 +++++++ langs/themes/nl.json | 7 +++++++ 6 files changed, 29 insertions(+), 12 deletions(-) diff --git a/assets/themes/artwork/artwork.json b/assets/themes/artwork/artwork.json index 7b8cc876c..c3b7b7c55 100644 --- a/assets/themes/artwork/artwork.json +++ b/assets/themes/artwork/artwork.json @@ -374,7 +374,7 @@ "en": "Is there a website with more information about this artwork?", "nl": "Op welke website kan men meer informatie vinden over dit kunstwerk?", "fr": "Sur quel site web pouvons-nous trouver plus d'informations sur cette œuvre d'art?", - "de": "Auf welcher Website gibt es mehr Informationen über dieses Kunstwerk?", + "de": "Gibt es eine Website mit weiteren Informationen über dieses Kunstwerk?", "it": "Esiste un sito web con maggiori informazioni su quest’opera?", "ru": "Есть ли сайт с более подробной информацией об этой работе?", "ja": "この作品についての詳しい情報はどのウェブサイトにありますか?", diff --git a/assets/themes/bicycle_library/bicycle_library.json b/assets/themes/bicycle_library/bicycle_library.json index 437020a3f..6d0cf61e2 100644 --- a/assets/themes/bicycle_library/bicycle_library.json +++ b/assets/themes/bicycle_library/bicycle_library.json @@ -10,7 +10,8 @@ "ja", "fr", "zh_Hant", - "nb_NO" + "nb_NO", + "de" ], "title": { "en": "Bicycle libraries", @@ -20,7 +21,8 @@ "ja": "自転車ライブラリ", "fr": "Vélothèques", "zh_Hant": "單車圖書館", - "nb_NO": "Sykkelbibliotek" + "nb_NO": "Sykkelbibliotek", + "de": "Fahrradbibliothek" }, "description": { "nl": "Een fietsbibliotheek is een plaats waar men een fiets kan lenen, vaak voor een klein bedrag per jaar. Een typisch voorbeeld zijn kinderfietsbibliotheken, waar men een fiets op maat van het kind kan lenen. Is het kind de fiets ontgroeid, dan kan het te kleine fietsje omgeruild worden voor een grotere.", diff --git a/assets/themes/openwindpowermap/openwindpowermap.json b/assets/themes/openwindpowermap/openwindpowermap.json index b1e4ecaa7..22bba27ef 100644 --- a/assets/themes/openwindpowermap/openwindpowermap.json +++ b/assets/themes/openwindpowermap/openwindpowermap.json @@ -56,7 +56,7 @@ "tagRenderings": [ { "render": { - "en": "The power output of this wind turbine is {canonical(generator:output:electricity)}." + "en": "The power output of this wind turbine is {generator:output:electricity}." }, "question": { "en": "What is the power output of this wind turbine? (e.g. 2.3 MW)" @@ -79,7 +79,7 @@ }, { "render": { - "en": "The total height (including rotor radius) of this wind turbine is {canonical(height)}." + "en": "The total height (including rotor radius) of this wind turbine is {height} metres." }, "question": { "en": "What is the total height of this wind turbine (including rotor radius), in metres?" @@ -91,7 +91,7 @@ }, { "render": { - "en": "The rotor diameter of this wind turbine is {canonical(rotor:diameter)}." + "en": "The rotor diameter of this wind turbine is {rotor:diameter} metres." }, "question": { "en": "What is the rotor diameter of this wind turbine, in metres?" @@ -129,7 +129,7 @@ } ], "units": [ - { + { "appliesToKey": [ "generator:output:electricity" ], @@ -183,7 +183,8 @@ }, { "appliesToKey": [ - "height","rotor:diameter" + "height", + "rotor:diameter" ], "applicableUnits": [ { diff --git a/langs/themes/de.json b/langs/themes/de.json index 95abb60b1..6a0b29fad 100644 --- a/langs/themes/de.json +++ b/langs/themes/de.json @@ -87,6 +87,9 @@ "shortDescription": "Eine Karte aller Sitzbänke", "description": "Diese Karte zeigt alle Sitzbänke, die in OpenStreetMap eingetragen sind: Einzeln stehende Bänke und Bänke, die zu Haltestellen oder Unterständen gehören. Mit einem OpenStreetMap-Account können Sie neue Bänke eintragen oder Detailinformationen existierender Bänke bearbeiten." }, + "bicyclelib": { + "title": "Fahrradbibliothek" + }, "bookcases": { "title": "Öffentliche Bücherschränke Karte", "description": "Ein öffentlicher Bücherschrank ist ein kleiner Bücherschrank am Straßenrand, ein Kasten, eine alte Telefonzelle oder andere Gegenstände, in denen Bücher aufbewahrt werden. Jeder kann ein Buch hinstellen oder mitnehmen. Diese Karte zielt darauf ab, all diese Bücherschränke zu sammeln. Sie können neue Bücherschränke in der Nähe entdecken und mit einem kostenlosen OpenStreetMap-Account schnell Ihre Lieblingsbücherschränke hinzufügen." @@ -327,8 +330,5 @@ "toilets": { "title": "Offene Toilette Karte", "description": "Eine Karte der öffentlichen Toiletten" - }, - "bicyclelib": { - "title": "Fahrradbibliothek" } -} +} \ No newline at end of file diff --git a/langs/themes/en.json b/langs/themes/en.json index 48852d5a4..f5db488b9 100644 --- a/langs/themes/en.json +++ b/langs/themes/en.json @@ -1143,6 +1143,13 @@ "human": " gigawatts" } } + }, + "1": { + "applicableUnits": { + "0": { + "human": " meter" + } + } } } }, diff --git a/langs/themes/nl.json b/langs/themes/nl.json index 7966e9e2d..44bf09b5f 100644 --- a/langs/themes/nl.json +++ b/langs/themes/nl.json @@ -919,6 +919,13 @@ "human": " gigawatt" } } + }, + "1": { + "applicableUnits": { + "0": { + "human": " meter" + } + } } } }, From 96ecded0b98cbcfad75bfb015f1b8103e4054ae6 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Thu, 15 Jul 2021 20:47:28 +0200 Subject: [PATCH 07/16] Further development of split-road feature; refactoring of change-handling --- Customizations/JSON/LayerConfig.ts | 2 + Customizations/JSON/LayerConfigJson.ts | 5 + Customizations/JSON/LayoutConfig.ts | 2 +- InitUiElements.ts | 2 +- Logic/Actors/ChangeToElementsActor.ts | 36 ++ Logic/Actors/PendingChangesUploader.ts | 4 +- Logic/FeatureSource/ChangeApplicator.ts | 138 ++++++ Logic/FeatureSource/FeaturePipeline.ts | 21 +- Logic/Osm/Actions/Action.ts | 7 - Logic/Osm/Actions/ChangeDescription.ts | 30 ++ Logic/Osm/Actions/ChangeTagAction.ts | 52 +++ Logic/Osm/Actions/CreateNewNodeAction.ts | 44 ++ Logic/Osm/{ => Actions}/DeleteAction.ts | 16 +- Logic/Osm/Actions/OsmChangeAction.ts | 16 + Logic/Osm/Actions/RelationSplitlHandler.ts | 20 + Logic/Osm/Actions/SplitAction.ts | 236 ++++++++++ Logic/Osm/Changes.ts | 461 ++++++++++--------- Logic/Osm/CreateNewNodeAction.ts | 3 - Logic/Osm/OsmObject.ts | 22 +- Logic/Osm/RelationSplitlHandler.ts | 11 - Logic/Osm/SplitAction.ts | 222 --------- Logic/UIEventSource.ts | 37 +- State.ts | 4 + UI/Base/Minimap.ts | 1 + UI/BigComponents/SimpleAddUI.ts | 10 +- UI/Image/DeleteImage.ts | 8 +- UI/Image/ImageUploadFlow.ts | 6 +- UI/Popup/DeleteWizard.ts | 7 +- UI/Popup/FeatureInfoBox.ts | 18 +- UI/Popup/SplitRoadWizard.ts | 59 ++- UI/Popup/TagRenderingQuestion.ts | 5 +- UI/ShowDataLayer.ts | 7 +- UI/SpecialVisualizations.ts | 1 + Utils.ts | 13 + assets/themes/cyclestreets/cyclestreets.json | 3 +- langs/en.json | 4 +- test.ts | 2 +- 37 files changed, 967 insertions(+), 568 deletions(-) create mode 100644 Logic/Actors/ChangeToElementsActor.ts create mode 100644 Logic/FeatureSource/ChangeApplicator.ts delete mode 100644 Logic/Osm/Actions/Action.ts create mode 100644 Logic/Osm/Actions/ChangeDescription.ts create mode 100644 Logic/Osm/Actions/ChangeTagAction.ts create mode 100644 Logic/Osm/Actions/CreateNewNodeAction.ts rename Logic/Osm/{ => Actions}/DeleteAction.ts (95%) create mode 100644 Logic/Osm/Actions/OsmChangeAction.ts create mode 100644 Logic/Osm/Actions/RelationSplitlHandler.ts create mode 100644 Logic/Osm/Actions/SplitAction.ts delete mode 100644 Logic/Osm/CreateNewNodeAction.ts delete mode 100644 Logic/Osm/RelationSplitlHandler.ts delete mode 100644 Logic/Osm/SplitAction.ts diff --git a/Customizations/JSON/LayerConfig.ts b/Customizations/JSON/LayerConfig.ts index 48e96f4a2..f9efc9534 100644 --- a/Customizations/JSON/LayerConfig.ts +++ b/Customizations/JSON/LayerConfig.ts @@ -49,6 +49,7 @@ export default class LayerConfig { wayHandling: number; public readonly units: Unit[]; public readonly deletion: DeleteConfig | null + public readonly allowSplit: boolean presets: { title: Translation, @@ -67,6 +68,7 @@ export default class LayerConfig { context = context + "." + json.id; const self = this; this.id = json.id; + this.allowSplit = json.allowSplit ?? false; this.name = Translations.T(json.name, context + ".name"); if(json.description !== undefined){ diff --git a/Customizations/JSON/LayerConfigJson.ts b/Customizations/JSON/LayerConfigJson.ts index ca272ecb0..21f3e5c99 100644 --- a/Customizations/JSON/LayerConfigJson.ts +++ b/Customizations/JSON/LayerConfigJson.ts @@ -291,4 +291,9 @@ export interface LayerConfigJson { */ deletion?: boolean | DeleteConfigJson + /** + * IF set, a 'split this road' button is shown + */ + allowSplit?: boolean + } \ No newline at end of file diff --git a/Customizations/JSON/LayoutConfig.ts b/Customizations/JSON/LayoutConfig.ts index 12b9d5f76..ab597ea5a 100644 --- a/Customizations/JSON/LayoutConfig.ts +++ b/Customizations/JSON/LayoutConfig.ts @@ -65,7 +65,7 @@ export default class LayoutConfig { this.language = json.language; } if (this.language.length == 0) { - throw "No languages defined. Define at least one language" + throw `No languages defined. Define at least one language. (${context}.languages)` } if (json.title === undefined) { throw "Title not defined in " + this.id; diff --git a/InitUiElements.ts b/InitUiElements.ts index 0dbc7eaac..f7c344d45 100644 --- a/InitUiElements.ts +++ b/InitUiElements.ts @@ -420,10 +420,10 @@ export class InitUiElements { const source = new FeaturePipeline(state.filteredLayers, + State.state.changes, updater, state.osmApiFeatureSource, state.layoutToUse, - state.changes, state.locationControl, state.selectedElement); diff --git a/Logic/Actors/ChangeToElementsActor.ts b/Logic/Actors/ChangeToElementsActor.ts new file mode 100644 index 000000000..2f157866a --- /dev/null +++ b/Logic/Actors/ChangeToElementsActor.ts @@ -0,0 +1,36 @@ +import {ElementStorage} from "../ElementStorage"; +import {Changes} from "../Osm/Changes"; + +export default class ChangeToElementsActor { + constructor(changes: Changes, allElements: ElementStorage) { + changes.pendingChanges.addCallbackAndRun(changes => { + for (const change of changes) { + const id = change.type + "/" + change.id; + if (!allElements.has(id)) { +continue; // Will be picked up later on + } + const src = allElements.getEventSourceById(id) + + let changed = false; + for (const kv of change.tags ?? []) { + // Apply tag changes and ping the consumers + const k = kv.k + let v = kv.v + if (v === "") { + v = undefined; + } + if (src.data[k] === v) { + continue + } + changed = true; + src.data[k] = v; + } + if (changed) { + src.ping() + } + + + } + }) + } +} \ No newline at end of file diff --git a/Logic/Actors/PendingChangesUploader.ts b/Logic/Actors/PendingChangesUploader.ts index b2a8e100d..82adde57b 100644 --- a/Logic/Actors/PendingChangesUploader.ts +++ b/Logic/Actors/PendingChangesUploader.ts @@ -9,7 +9,7 @@ export default class PendingChangesUploader { constructor(changes: Changes, selectedFeature: UIEventSource) { const self = this; this.lastChange = new Date(); - changes.pending.addCallback(() => { + changes.pendingChanges.addCallback(() => { self.lastChange = new Date(); window.setTimeout(() => { @@ -54,7 +54,7 @@ export default class PendingChangesUploader { function onunload(e) { - if (changes.pending.data.length == 0) { + if(changes.pendingChanges.data.length == 0){ return; } changes.flushChanges("onbeforeunload - probably closing or something similar"); diff --git a/Logic/FeatureSource/ChangeApplicator.ts b/Logic/FeatureSource/ChangeApplicator.ts new file mode 100644 index 000000000..58ba5174c --- /dev/null +++ b/Logic/FeatureSource/ChangeApplicator.ts @@ -0,0 +1,138 @@ +import FeatureSource from "./FeatureSource"; +import {UIEventSource} from "../UIEventSource"; +import {Changes} from "../Osm/Changes"; +import {ChangeDescription} from "../Osm/Actions/ChangeDescription"; +import {Utils} from "../../Utils"; +import {OsmNode, OsmRelation, OsmWay} from "../Osm/OsmObject"; + +/** + * Applies changes from 'Changes' onto a featureSource + */ +export default class ChangeApplicator implements FeatureSource { + public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>; + public readonly name: string; + + constructor(source: FeatureSource, changes: Changes) { + + this.name = "ChangesApplied(" + source.name + ")" + this.features = source.features + + source.features.addCallbackAndRunD(features => { + ChangeApplicator.ApplyChanges(features, changes.pendingChanges.data) + }) + + changes.pendingChanges.addCallbackAndRunD(changes => { + ChangeApplicator.ApplyChanges(source.features.data, changes) + source.features.ping() + }) + + + } + + + private static ApplyChanges(features: { feature: any, freshness: Date }[], cs: ChangeDescription[]) { + if (cs.length === 0 || features === undefined) { + return features; + } + + const changesPerId: Map = new Map() + for (const c of cs) { + const id = c.type + "/" + c.id + if (!changesPerId.has(id)) { + changesPerId.set(id, []) + } + changesPerId.get(id).push(c) + } + + + const now = new Date() + + function add(feature) { + features.push({ + feature: feature, + freshness: now + }) + } + + // First, create the new features - they have a negative ID + // We don't set the properties yet though + changesPerId.forEach(cs => { + cs.forEach(change => { + if (change.id >= 0) { + return; // Nothing to do here, already created + } + + + try { + + switch (change.type) { + case "node": + const n = new OsmNode(change.id) + n.lat = change.changes["lat"] + n.lon = change.changes["lon"] + const geojson = n.asGeoJson() + add(geojson) + break; + case "way": + const w = new OsmWay(change.id) + w.nodes = change.changes["nodes"] + add(w.asGeoJson()) + break; + case "relation": + const r = new OsmRelation(change.id) + r.members = change.changes["members"] + add(r.asGeoJson()) + break; + } + + } catch (e) { + console.error(e) + } + }) + }) + + + for (const feature of features) { + const id = feature.feature.properties.id; + const f = feature.feature; + if (!changesPerId.has(id)) { + continue; + } + + + const changed = {} + // Copy all the properties + Utils.Merge(f, changed) + // play the changes onto the copied object + + for (const change of changesPerId.get(id)) { + for (const kv of change.tags ?? []) { + // Apply tag changes and ping the consumers + const k = kv.k + let v = kv.v + if (v === "") { + v = undefined; + } + f.properties[k] = v; + } + + // Apply other changes to the object + if (change.changes !== undefined) { + switch (change.type) { + case "node": + // @ts-ignore + const coor: { lat, lon } = change.changes; + f.geometry.coordinates = [[coor.lon, coor.lat]] + break; + case "way": + f.geometry.coordinates = change.changes["locations"] + break; + case "relation": + console.error("Changes to relations are not yet supported") + break; + } + } + } + } + } +} \ No newline at end of file diff --git a/Logic/FeatureSource/FeaturePipeline.ts b/Logic/FeatureSource/FeaturePipeline.ts index 340710503..6e77e0efd 100644 --- a/Logic/FeatureSource/FeaturePipeline.ts +++ b/Logic/FeatureSource/FeaturePipeline.ts @@ -13,6 +13,8 @@ import Loc from "../../Models/Loc"; import GeoJsonSource from "./GeoJsonSource"; import MetaTaggingFeatureSource from "./MetaTaggingFeatureSource"; import RegisteringFeatureSource from "./RegisteringFeatureSource"; +import {Changes} from "../Osm/Changes"; +import ChangeApplicator from "./ChangeApplicator"; export default class FeaturePipeline implements FeatureSource { @@ -21,10 +23,10 @@ export default class FeaturePipeline implements FeatureSource { public readonly name = "FeaturePipeline" constructor(flayers: UIEventSource<{ isDisplayed: UIEventSource, layerDef: LayerConfig }[]>, + changes: Changes, updater: FeatureSource, fromOsmApi: FeatureSource, layout: UIEventSource, - newPoints: FeatureSource, locationControl: UIEventSource, selectedElement: UIEventSource) { @@ -40,13 +42,16 @@ export default class FeaturePipeline implements FeatureSource { new MetaTaggingFeatureSource(allLoadedFeatures, new FeatureDuplicatorPerLayer(flayers, new RegisteringFeatureSource( - updater) + new ChangeApplicator( + updater, changes + )) )), layout)); const geojsonSources: FeatureSource [] = GeoJsonSource .ConstructMultiSource(flayers.data, locationControl) .map(geojsonSource => { - let source = new RegisteringFeatureSource(new FeatureDuplicatorPerLayer(flayers, geojsonSource)); + let source = new RegisteringFeatureSource(new FeatureDuplicatorPerLayer(flayers, + new ChangeApplicator(geojsonSource, changes))); if(!geojsonSource.isOsmCache){ source = new MetaTaggingFeatureSource(allLoadedFeatures, source, updater.features); } @@ -54,25 +59,19 @@ export default class FeaturePipeline implements FeatureSource { }); const amendedLocalStorageSource = - new RememberingSource(new RegisteringFeatureSource(new FeatureDuplicatorPerLayer(flayers, new LocalStorageSource(layout)) + new RememberingSource(new RegisteringFeatureSource(new FeatureDuplicatorPerLayer(flayers,new ChangeApplicator( new LocalStorageSource(layout), changes)) )); - newPoints = new MetaTaggingFeatureSource(allLoadedFeatures, - new FeatureDuplicatorPerLayer(flayers, - new RegisteringFeatureSource(newPoints))); - const amendedOsmApiSource = new RememberingSource( new MetaTaggingFeatureSource(allLoadedFeatures, new FeatureDuplicatorPerLayer(flayers, - - new RegisteringFeatureSource(fromOsmApi)))); + new RegisteringFeatureSource(new ChangeApplicator(fromOsmApi, changes))))); const merged = new FeatureSourceMerger([ amendedOverpassSource, amendedOsmApiSource, amendedLocalStorageSource, - newPoints, ...geojsonSources ]); diff --git a/Logic/Osm/Actions/Action.ts b/Logic/Osm/Actions/Action.ts deleted file mode 100644 index c09e20cb8..000000000 --- a/Logic/Osm/Actions/Action.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * An action is a change to the OSM-database - * It will generate some new/modified/deleted objects, which are all bundled by the 'changes'-object - */ -export default interface Action { - -} \ No newline at end of file diff --git a/Logic/Osm/Actions/ChangeDescription.ts b/Logic/Osm/Actions/ChangeDescription.ts new file mode 100644 index 000000000..aefab9c1c --- /dev/null +++ b/Logic/Osm/Actions/ChangeDescription.ts @@ -0,0 +1,30 @@ +export interface ChangeDescription { + + type: "node" | "way" | "relation", + /** + * Negative for a new objects + */ + id: number, + /* + v = "" or v = undefined to erase this tag + */ + tags?: { k: string, v: string }[], + + changes?: { + lat: number, + lon: number + } | { + // Coordinates are only used for rendering + locations: [number, number][] + nodes: number[], + } | { + members: { type: "node" | "way" | "relation", ref: number, role: string }[] + } + + /* + Set to delete the object + */ + doDelete?: boolean + + +} \ No newline at end of file diff --git a/Logic/Osm/Actions/ChangeTagAction.ts b/Logic/Osm/Actions/ChangeTagAction.ts new file mode 100644 index 000000000..1915d1858 --- /dev/null +++ b/Logic/Osm/Actions/ChangeTagAction.ts @@ -0,0 +1,52 @@ +import OsmChangeAction from "./OsmChangeAction"; +import {Changes} from "../Changes"; +import {ChangeDescription} from "./ChangeDescription"; +import {TagsFilter} from "../../Tags/TagsFilter"; + +export default class ChangeTagAction extends OsmChangeAction { + private readonly _elementId: string; + private readonly _tagsFilter: TagsFilter; + private readonly _currentTags: any; + + constructor(elementId: string, tagsFilter: TagsFilter, currentTags: any) { + super(); + this._elementId = elementId; + this._tagsFilter = tagsFilter; + this._currentTags = currentTags; + } + + /** + * Doublechecks that no stupid values are added + */ + private static checkChange(kv: { k: string, v: string }): { k: string, v: string } { + const key = kv.k; + const value = kv.v; + if (key === undefined || key === null) { + console.log("Invalid key"); + return undefined; + } + if (value === undefined || value === null) { + console.log("Invalid value for ", key); + return undefined; + } + + if (key.startsWith(" ") || value.startsWith(" ") || value.endsWith(" ") || key.endsWith(" ")) { + console.warn("Tag starts with or ends with a space - trimming anyway") + } + + return {k: key.trim(), v: value.trim()}; + } + + Perform(changes: Changes): ChangeDescription [] { + const changedTags: { k: string, v: string }[] = this._tagsFilter.asChange(this._currentTags).map(ChangeTagAction.checkChange) + const typeId = this._elementId.split("/") + const type = typeId[0] + const id = Number(typeId [1]) + return [{ + // @ts-ignore + type: type, + id: id, + tags: changedTags + }] + } +} \ No newline at end of file diff --git a/Logic/Osm/Actions/CreateNewNodeAction.ts b/Logic/Osm/Actions/CreateNewNodeAction.ts new file mode 100644 index 000000000..28a656039 --- /dev/null +++ b/Logic/Osm/Actions/CreateNewNodeAction.ts @@ -0,0 +1,44 @@ +import {Tag} from "../../Tags/Tag"; +import OsmChangeAction from "./OsmChangeAction"; +import {Changes} from "../Changes"; +import {ChangeDescription} from "./ChangeDescription"; +import {And} from "../../Tags/And"; + +export default class CreateNewNodeAction implements OsmChangeAction { + + private readonly _basicTags: Tag[]; + private readonly _lat: number; + private readonly _lon: number; + + constructor(basicTags: Tag[], lat: number, lon: number) { + this._basicTags = basicTags; + this._lat = lat; + this._lon = lon; + } + + Perform(changes: Changes): ChangeDescription[] { + const id = changes.getNewID() + const properties = { + id: "node/" + id + } + for (const kv of this._basicTags) { + if (typeof kv.value !== "string") { + throw "Invalid value: don't use a regex in a preset" + } + properties[kv.key] = kv.value; + } + + return [{ + tags: new And(this._basicTags).asChange(properties), + type: "node", + id: id, + changes:{ + lat: this._lat, + lon: this._lon + } + }] + + } + + +} \ No newline at end of file diff --git a/Logic/Osm/DeleteAction.ts b/Logic/Osm/Actions/DeleteAction.ts similarity index 95% rename from Logic/Osm/DeleteAction.ts rename to Logic/Osm/Actions/DeleteAction.ts index 73cb066df..213bb0e86 100644 --- a/Logic/Osm/DeleteAction.ts +++ b/Logic/Osm/Actions/DeleteAction.ts @@ -1,9 +1,9 @@ -import {UIEventSource} from "../UIEventSource"; -import {Translation} from "../../UI/i18n/Translation"; -import Translations from "../../UI/i18n/Translations"; -import {OsmObject} from "./OsmObject"; -import State from "../../State"; -import Constants from "../../Models/Constants"; +import {UIEventSource} from "../../UIEventSource"; +import {Translation} from "../../../UI/i18n/Translation"; +import State from "../../../State"; +import {OsmObject} from "../OsmObject"; +import Translations from "../../../UI/i18n/Translations"; +import Constants from "../../../Models/Constants"; export default class DeleteAction { @@ -30,7 +30,7 @@ export default class DeleteAction { * Does actually delete the feature; returns the event source 'this.isDeleted' * If deletion is not allowed, triggers the callback instead */ - public DoDelete(reason: string, onNotAllowed : () => void): UIEventSource { + public DoDelete(reason: string, onNotAllowed : () => void): void { const isDeleted = this.isDeleted const self = this; let deletionStarted = false; @@ -75,8 +75,6 @@ export default class DeleteAction { } ) - - return isDeleted; } /** diff --git a/Logic/Osm/Actions/OsmChangeAction.ts b/Logic/Osm/Actions/OsmChangeAction.ts new file mode 100644 index 000000000..ecb9f3df8 --- /dev/null +++ b/Logic/Osm/Actions/OsmChangeAction.ts @@ -0,0 +1,16 @@ +/** + * An action is a change to the OSM-database + * It will generate some new/modified/deleted objects, which are all bundled by the 'changes'-object + */ +import {Changes} from "../Changes"; +import {ChangeDescription} from "./ChangeDescription"; + +export default abstract class OsmChangeAction { + + + + public abstract Perform(changes: Changes): ChangeDescription[] + + + +} \ No newline at end of file diff --git a/Logic/Osm/Actions/RelationSplitlHandler.ts b/Logic/Osm/Actions/RelationSplitlHandler.ts new file mode 100644 index 000000000..601b2d136 --- /dev/null +++ b/Logic/Osm/Actions/RelationSplitlHandler.ts @@ -0,0 +1,20 @@ +/** + * The logic to handle relations after a way within + */ +import OsmChangeAction from "./OsmChangeAction"; +import {Changes} from "../Changes"; +import {ChangeDescription} from "./ChangeDescription"; +import {OsmRelation, OsmWay} from "../OsmObject"; + +export default class RelationSplitlHandler extends OsmChangeAction{ + + constructor(partOf: OsmRelation[], newWayIds: number[], originalNodes: number[]) { + super() + } + + Perform(changes: Changes): ChangeDescription[] { + return []; + } + + +} \ No newline at end of file diff --git a/Logic/Osm/Actions/SplitAction.ts b/Logic/Osm/Actions/SplitAction.ts new file mode 100644 index 000000000..c17fd3fde --- /dev/null +++ b/Logic/Osm/Actions/SplitAction.ts @@ -0,0 +1,236 @@ +import {OsmRelation, OsmWay} from "../OsmObject"; +import {Changes} from "../Changes"; +import {GeoOperations} from "../../GeoOperations"; +import OsmChangeAction from "./OsmChangeAction"; +import {ChangeDescription} from "./ChangeDescription"; +import RelationSplitlHandler from "./RelationSplitlHandler"; + +interface SplitInfo { + originalIndex?: number, // or negative for new elements + lngLat: [number, number], + doSplit: boolean +} + +export default class SplitAction extends OsmChangeAction { + private readonly roadObject: any; + private readonly osmWay: OsmWay; + private _partOf: OsmRelation[]; + private readonly _splitPoints: any[]; + + constructor(osmWay: OsmWay, wayGeoJson: any, partOf: OsmRelation[], splitPoints: any[]) { + super() + this.osmWay = osmWay; + this.roadObject = wayGeoJson; + this._partOf = partOf; + this._splitPoints = splitPoints; + } + + private static SegmentSplitInfo(splitInfo: SplitInfo[]): SplitInfo[][] { + const wayParts = [] + let currentPart = [] + for (const splitInfoElement of splitInfo) { + currentPart.push(splitInfoElement) + + if (splitInfoElement.doSplit) { + // We have to do a split! + // We add the current index to the currentParts, flush it and add it again + wayParts.push(currentPart) + currentPart = [splitInfoElement] + } + } + wayParts.push(currentPart) + return wayParts.filter(wp => wp.length > 0) + } + + Perform(changes: Changes): ChangeDescription[] { + const splitPoints = this._splitPoints + // We mark the new split points with a new id + console.log(splitPoints) + for (const splitPoint of splitPoints) { + splitPoint.properties["_is_split_point"] = true + } + + + const self = this; + const partOf = this._partOf + const originalElement = this.osmWay + const originalNodes = this.osmWay.nodes; + + // First, calculate splitpoints and remove points close to one another + const splitInfo = self.CalculateSplitCoordinates(splitPoints) + // Now we have a list with e.g. + // [ { originalIndex: 0}, {originalIndex: 1, doSplit: true}, {originalIndex: 2}, {originalIndex: undefined, doSplit: true}, {originalIndex: 3}] + + // Lets change 'originalIndex' to the actual node id first: + for (const element of splitInfo) { + if (element.originalIndex >= 0) { + element.originalIndex = originalElement.nodes[element.originalIndex] + } else { + element.originalIndex = changes.getNewID(); + } + } + + // Next up is creating actual parts from this + const wayParts: SplitInfo[][] = SplitAction.SegmentSplitInfo(splitInfo); +console.log("WayParts", wayParts, "by", splitInfo) + // Allright! At this point, we have our new ways! + // Which one is the longest of them (and can keep the id)? + + let longest = undefined; + for (const wayPart of wayParts) { + if (longest === undefined) { + longest = wayPart; + continue + } + if (wayPart.length > longest.length) { + longest = wayPart + } + } + + const changeDescription: ChangeDescription[] = [] + // Let's create the new points as needed + for (const element of splitInfo) { + if (element.originalIndex >= 0) { + continue; + } + changeDescription.push({ + type: "node", + id: element.originalIndex, + changes:{ + lon: element.lngLat[0], + lat: element.lngLat[1] + } + }) + } + + const newWayIds: number[] = [] + // Lets create OsmWays based on them + for (const wayPart of wayParts) { + + let isOriginal = wayPart === longest + if (isOriginal) { + // We change the actual element! + changeDescription.push({ + type:"way", + id: originalElement.id, + changes:{ + locations: wayPart.map(p => p.lngLat), + nodes: wayPart.map(p => p.originalIndex) + } + }) + } else { + let id = changes.getNewID(); + newWayIds.push(id) + + const kv = [] + for (const k in originalElement.tags) { + if(!originalElement.tags.hasOwnProperty(k)){ + continue + } + kv .push({k: k, v: originalElement.tags[k]}) + } + changeDescription.push({ + type:"way", + id:id, + tags: kv, + changes:{ + locations: wayPart.map(p => p.lngLat), + nodes: wayPart.map(p => p.originalIndex) + } + }) + } + + } + + // At last, we still have to check that we aren't part of a relation... + // At least, the order of the ways is identical, so we can keep the same roles + changeDescription.push(...new RelationSplitlHandler(partOf, newWayIds, originalNodes).Perform(changes)) + + // And we have our objects! + // Time to upload + + return changeDescription + } + + /** + * Calculates the actual points to split + * If another point is closer then ~5m, we reuse that point + */ + private CalculateSplitCoordinates( + splitPoints: any[], + toleranceInM = 5): SplitInfo[] { + + const allPoints = [...splitPoints]; + // We have a bunch of coordinates here: [ [lat, lon], [lat, lon], ...] ... + const originalPoints: [number, number][] = this.roadObject.geometry.coordinates + // We project them onto the line (which should yield pretty much the same point + for (let i = 0; i < originalPoints.length; i++) { + let originalPoint = originalPoints[i]; + let projected = GeoOperations.nearestPoint(this.roadObject, originalPoint) + projected.properties["_is_split_point"] = false + projected.properties["_original_index"] = i + allPoints.push(projected) + } + // At this point, we have a list of both the split point and the old points, with some properties to discriminate between them + // We sort this list so that the new points are at the same location + allPoints.sort((a, b) => a.properties.location - b.properties.location) + + // When this is done, we check that no now point is too close to an already existing point and no very small segments get created + + /* for (let i = allPoints.length - 1; i > 0; i--) { + + const point = allPoints[i]; + if (point.properties._original_index !== undefined) { + // This point is already in OSM - we have to keep it! + continue; + } + + if (i != allPoints.length - 1) { + const prevPoint = allPoints[i + 1] + const diff = Math.abs(point.properties.location - prevPoint.properties.location) * 1000 + if (diff <= toleranceInM) { + // To close to the previous point! We delete this point... + allPoints.splice(i, 1) + // ... and mark the previous point as a split point + prevPoint.properties._is_split_point = true + continue; + } + } + + if (i > 0) { + const nextPoint = allPoints[i - 1] + const diff = Math.abs(point.properties.location - nextPoint.properties.location) * 1000 + if (diff <= toleranceInM) { + // To close to the next point! We delete this point... + allPoints.splice(i, 1) + // ... and mark the next point as a split point + nextPoint.properties._is_split_point = true + // noinspection UnnecessaryContinueJS + continue; + } + } + // We don't have to remove this point... + }*/ + + const splitInfo: SplitInfo[] = [] + let nextId = -1 + + for (const p of allPoints) { + let index = p.properties._original_index + if (index === undefined) { + index = nextId; + nextId--; + } + const splitInfoElement = { + originalIndex: index, + lngLat: p.geometry.coordinates, + doSplit: p.properties._is_split_point + } + splitInfo.push(splitInfoElement) + } + + return splitInfo + } + + +} diff --git a/Logic/Osm/Changes.ts b/Logic/Osm/Changes.ts index 001005ea0..18b67970e 100644 --- a/Logic/Osm/Changes.ts +++ b/Logic/Osm/Changes.ts @@ -1,81 +1,225 @@ -import {OsmNode, OsmObject} from "./OsmObject"; +import {OsmNode, OsmObject, OsmRelation, OsmWay} from "./OsmObject"; import State from "../../State"; -import {Utils} from "../../Utils"; import {UIEventSource} from "../UIEventSource"; import Constants from "../../Models/Constants"; -import FeatureSource from "../FeatureSource/FeatureSource"; -import {TagsFilter} from "../Tags/TagsFilter"; -import {Tag} from "../Tags/Tag"; -import {OsmConnection} from "./OsmConnection"; +import OsmChangeAction from "./Actions/OsmChangeAction"; +import {ChangeDescription} from "./Actions/ChangeDescription"; import {LocalStorageSource} from "../Web/LocalStorageSource"; +import {Utils} from "../../Utils"; /** * Handles all changes made to OSM. * Needs an authenticator via OsmConnection */ -export class Changes implements FeatureSource { +export class Changes { private static _nextId = -1; // Newly assigned ID's are negative public readonly name = "Newly added features" /** - * The newly created points, as a FeatureSource + * All the newly created features as featureSource + all the modified features */ public features = new UIEventSource<{ feature: any, freshness: Date }[]>([]); - /** - * All the pending changes - */ - public readonly pending = LocalStorageSource.GetParsed<{ elementId: string, key: string, value: string }[]>("pending-changes", []) - - /** - * All the pending new objects to upload - */ - private readonly newObjects = LocalStorageSource.GetParsed<{ id: number, lat: number, lon: number }[]>("newObjects", []) + public readonly pendingChanges = new UIEventSource([]) // LocalStorageSource.GetParsed("pending-changes", []) private readonly isUploading = new UIEventSource(false); - /** - * Adds a change to the pending changes - */ - private static checkChange(kv: { k: string, v: string }): { k: string, v: string } { - const key = kv.k; - const value = kv.v; - if (key === undefined || key === null) { - console.log("Invalid key"); - return undefined; - } - if (value === undefined || value === null) { - console.log("Invalid value for ", key); - return undefined; - } - - if (key.startsWith(" ") || value.startsWith(" ") || value.endsWith(" ") || key.endsWith(" ")) { - console.warn("Tag starts with or ends with a space - trimming anyway") - } - - return {k: key.trim(), v: value.trim()}; + constructor() { + this.isUploading.addCallbackAndRun(u => { + if (u) { + console.trace("Uploading set!") + } + }) } + public static createChangesetFor(csId: string, + allChanges: { + modifiedObjects?: OsmObject[], + newElements?: OsmObject[], + deletedElements?: OsmObject[] + }): string { - addTag(elementId: string, tagsFilter: TagsFilter, - tags?: UIEventSource) { - const eventSource = tags ?? State.state?.allElements.getEventSourceById(elementId); - const elementTags = eventSource.data; - const changes = tagsFilter.asChange(elementTags).map(Changes.checkChange) - if (changes.length == 0) { - return; + const changedElements = allChanges.modifiedObjects ?? [] + const newElements = allChanges.newElements ?? [] + const deletedElements = allChanges.deletedElements ?? [] + + let changes = ``; + if (newElements.length > 0) { + changes += + "\n\n" + + newElements.map(e => e.ChangesetXML(csId)).join("\n") + + ""; + } + if (changedElements.length > 0) { + changes += + "\n\n" + + changedElements.map(e => e.ChangesetXML(csId)).join("\n") + + "\n"; } + if (deletedElements.length > 0) { + changes += + "\n\n" + + deletedElements.map(e => e.ChangesetXML(csId)).join("\n") + + "\n" + } + + changes += ""; + return changes; + } + + private static GetNeededIds(changes: ChangeDescription[]) { + return Utils.Dedup(changes.filter(c => c.id >= 0) + .map(c => c.type + "/" + c.id)) + } + + private static CreateChangesetObjects(changes: ChangeDescription[], downloadedOsmObjects: OsmObject[]): { + newObjects: OsmObject[], + modifiedObjects: OsmObject[] + deletedObjects: OsmObject[] + + } { + const objects: Map = new Map() + const states: Map = new Map(); + + for (const o of downloadedOsmObjects) { + objects.set(o.type + "/" + o.id, o) + states.set(o.type + "/" + o.id, "unchanged") + } + + let changed = false; for (const change of changes) { - if (elementTags[change.k] !== change.v) { - elementTags[change.k] = change.v; - console.log("Applied ", change.k, "=", change.v) - // We use 'elementTags.id' here, as we might have retrieved with the id 'node/-1' as new point, but should use the rewritten id - this.pending.data.push({elementId: elementTags.id, key: change.k, value: change.v}); + const id = change.type + "/" + change.id + if (!objects.has(id)) { + // This is a new object that should be created + states.set(id, "created") + let osmObj: OsmObject = undefined; + switch (change.type) { + case "node": + const n = new OsmNode(change.id) + n.lat = change.changes["lat"] + n.lon = change.changes["lon"] + osmObj = n + break; + case "way": + const w = new OsmWay(change.id) + w.nodes = change.changes["nodes"] + osmObj = w + break; + case "relation": + const r = new OsmRelation(change.id) + r.members = change.changes["members"] + osmObj = r + break; + } + if (osmObj === undefined) { + throw "Hmm? This is a bug" + } + objects.set(id, osmObj) + } + + const state = states.get(id) + if (change.doDelete) { + if (state === "created") { + states.set(id, "unchanged") + } else { + states.set(id, "deleted") + } + } + + const obj = objects.get(id) + // Apply tag changes + for (const kv of change.tags ?? []) { + const k = kv.k + let v = kv.v + + if (v === "") { + v = undefined; + } + + const oldV = obj.type[k] + if (oldV === v) { + continue; + } + + obj.tags[k] = v; + changed = true; + + + } + + if (change.changes !== undefined) { + switch (change.type) { + case "node": + // @ts-ignore + const nlat = change.changes.lat; + // @ts-ignore + const nlon = change.changes.lon; + const n = obj + if (n.lat !== nlat || n.lon !== nlon) { + n.lat = nlat; + n.lon = nlon; + changed = true; + } + break; + case "way": + const nnodes = change.changes["nodes"] + const w = obj + if (!Utils.Identical(nnodes, w.nodes)) { + w.nodes = nnodes + changed = true; + } + break; + case "relation": + const nmembers: { type: "node" | "way" | "relation", ref: number, role: string }[] = change.changes["members"] + const r = obj + if (!Utils.Identical(nmembers, r.members, (a, b) => { + return a.role === b.role && a.type === b.type && a.ref === b.ref + })) { + r.members = nmembers; + changed = true; + } + break; + + } + + } + + if (changed && state === "unchanged") { + states.set(id, "modified") } } - this.pending.ping(); - eventSource.ping(); + + + const result = { + newObjects: [], + modifiedObjects: [], + deletedObjects: [] + + } + objects.forEach((v, id) => { + + const state = states.get(id) + if (state === "created") { + result.newObjects.push(v) + } + if (state === "modified") { + result.modifiedObjects.push(v) + } + if (state === "deleted") { + result.deletedObjects.push(v) + } + + }) + + return result + } + + /** + * Returns a new ID and updates the value for the next ID + */ + public getNewID() { + return Changes._nextId--; } /** @@ -83,121 +227,16 @@ export class Changes implements FeatureSource { * Triggered by the 'PendingChangeUploader'-actor in Actors */ public flushChanges(flushreason: string = undefined) { - if (this.pending.data.length === 0) { + if (this.pendingChanges.data.length === 0) { + console.log("No pending changes") return; } if (flushreason !== undefined) { console.log(flushreason) } - this.uploadAll(); - } - - /** - * Returns a new ID and updates the value for the next ID - */ - public getNewID(){ - return Changes._nextId--; - } - - /** - * Create a new node element at the given lat/long. - * An internal OsmObject is created to upload later on, a geojson represention is returned. - * Note that the geojson version shares the tags (properties) by pointer, but has _no_ id in properties - */ - public createElement(basicTags: Tag[], lat: number, lon: number) { - console.log("Creating a new element with ", basicTags) - - const osmNode = new OsmNode(this.getNewID()); - const properties = {id: osmNode.id}; - const geojson = { - "type": "Feature", - "properties": properties, - "id": properties.id, - "geometry": { - "type": "Point", - "coordinates": [ - lon, - lat - ] - } - } - - // The basictags are COPIED, the id is included in the properties - // The tags are not yet written into the OsmObject, but this is applied onto a - const changes = []; - for (const kv of basicTags) { - if (typeof kv.value !== "string") { - throw "Invalid value: don't use a regex in a preset" - } - properties[kv.key] = kv.value; - changes.push({elementId:properties.id, key: kv.key, value: kv.value}) - } - - console.log("New feature added and pinged") - this.features.data.push({feature: geojson, freshness: new Date()}); - this.features.ping(); - - State.state.allElements.addOrGetElement(geojson).ping(); - - if (State.state.osmConnection.userDetails.data.backend !== OsmConnection.oauth_configs.osm.url) { - properties["_backend"] = State.state.osmConnection.userDetails.data.backend - } - - - this.newObjects.data.push({id: osmNode.id, lat: lat, lon: lon}) - this.pending.data.push(...changes) - this.pending.ping(); - this.newObjects.ping(); - return geojson; - } - - - private uploadChangesWithLatestVersions( - knownElements: OsmObject[]) { - const knownById = new Map(); - knownElements.forEach(knownElement => { - knownById.set(knownElement.type + "/" + knownElement.id, knownElement) - }) - - const newElements: OsmNode [] = this.newObjects.data.map(spec => { - const newElement = new OsmNode(spec.id); - newElement.lat = spec.lat; - newElement.lon = spec.lon; - return newElement - }) - - - // Here, inside the continuation, we know that all 'neededIds' are loaded in 'knownElements', which maps the ids onto the elements - // We apply the changes on them - for (const change of this.pending.data) { - if (parseInt(change.elementId.split("/")[1]) < 0) { - // This is a new element - we should apply this on one of the new elements - for (const newElement of newElements) { - if (newElement.type + "/" + newElement.id === change.elementId) { - newElement.addTag(change.key, change.value); - } - } - } else { - knownById.get(change.elementId).addTag(change.key, change.value); - } - } - - // Small sanity check for duplicate information - let changedElements = []; - for (const elementId in knownElements) { - const element = knownElements[elementId]; - if (element.changed) { - changedElements.push(element); - } - } - if (changedElements.length == 0 && newElements.length == 0) { - console.log("No changes in any object - clearing"); - this.pending.setData([]) - this.newObjects.setData([]) - return; - } if (this.isUploading.data) { + console.log("Is uploading... Abort") return; } this.isUploading.setData(true) @@ -205,75 +244,45 @@ export class Changes implements FeatureSource { console.log("Beginning upload..."); // At last, we build the changeset and upload const self = this; - State.state.osmConnection.UploadChangeset( - State.state.layoutToUse.data, - State.state.allElements, - (csId) => Changes.createChangesetFor(csId,changedElements, newElements ), - () => { - // When done - console.log("Upload successfull!") - self.newObjects.setData([]) - self.pending.setData([]); - self.isUploading.setData(false) - }, - () => self.isUploading.setData(false) // Failed - mark to try again - ); - - - }; - - - public static createChangesetFor(csId: string, changedElements: OsmObject[], newElements: OsmObject[]): string { - - let modifications = ""; - for (const element of changedElements) { - modifications += element.ChangesetXML(csId) + "\n"; - } - - - let creations = ""; - for (const newElement of newElements) { - creations += newElement.ChangesetXML(csId); - } - - - let changes = ``; - - if (creations.length > 0) { - changes += - "\n\n" + - creations + - ""; - } - if (modifications.length > 0) { - changes += - "\n\n" + - modifications + - "\n"; - } - - changes += ""; - return changes; - } - - private uploadAll() { - const self = this; - - const pending = this.pending.data; - let neededIds: string[] = []; - for (const change of pending) { - const id = change.elementId; - if (parseFloat(id.split("/")[1]) < 0) { - // New element - we don't have to download this - } else { - neededIds.push(id); + const pending = self.pendingChanges.data; + const neededIds = Changes.GetNeededIds(pending) + console.log("Needed ids", neededIds) + OsmObject.DownloadAll(neededIds, true).addCallbackAndRunD(osmObjects => { + console.log("Got the fresh objects!", osmObjects, "pending: ", pending) + const changes = Changes.CreateChangesetObjects(pending, osmObjects) + console.log("Changes", changes) + if (changes.newObjects.length + changes.deletedObjects.length + changes.modifiedObjects.length === 0) { + console.log("No changes to be made") + this.pendingChanges.setData([]) + this.isUploading.setData(false) + return; } - } - neededIds = Utils.Dedup(neededIds); - OsmObject.DownloadAll(neededIds).addCallbackAndRunD(knownElements => { - self.uploadChangesWithLatestVersions(knownElements) - }) + + State.state.osmConnection.UploadChangeset( + State.state.layoutToUse.data, + State.state.allElements, + (csId) => { + return Changes.createChangesetFor(csId, changes); + }, + () => { + // When done + console.log("Upload successfull!") + self.pendingChanges.setData([]); + self.isUploading.setData(false) + }, + () => self.isUploading.setData(false) // Failed - mark to try again + ) + + }); + + } + public applyAction(action: OsmChangeAction) { + const changes = action.Perform(this) + console.log("Received changes:", changes) + this.pendingChanges.data.push(...changes); + this.pendingChanges.ping(); + } } \ No newline at end of file diff --git a/Logic/Osm/CreateNewNodeAction.ts b/Logic/Osm/CreateNewNodeAction.ts deleted file mode 100644 index c0d9efba1..000000000 --- a/Logic/Osm/CreateNewNodeAction.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default class CreateNewNodeAction { - -} \ No newline at end of file diff --git a/Logic/Osm/OsmObject.ts b/Logic/Osm/OsmObject.ts index a47d26f85..70a93e906 100644 --- a/Logic/Osm/OsmObject.ts +++ b/Logic/Osm/OsmObject.ts @@ -125,7 +125,7 @@ export abstract class OsmObject { } const splitted = id.split("/"); const type = splitted[0]; - const idN = splitted[1]; + const idN = Number(splitted[1]); const src = new UIEventSource([]); OsmObject.historyCache.set(id, src); Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${type}/${idN}/history`).then(data => { @@ -314,20 +314,6 @@ export abstract class OsmObject { return this; } - public addTag(k: string, v: string): void { - if (k in this.tags) { - const oldV = this.tags[k]; - if (oldV == v) { - return; - } - console.log("Overwriting ", oldV, " with ", v, " for key ", k) - } - this.tags[k] = v; - if (v === undefined || v === "") { - delete this.tags[k]; - } - this.changed = true; - } abstract ChangesetXML(changesetId: string): string; @@ -481,7 +467,11 @@ export class OsmWay extends OsmObject { export class OsmRelation extends OsmObject { - public members; + public members: { + type: "node" | "way" | "relation", + ref: number, + role: string + }[]; constructor(id: number) { super("relation", id); diff --git a/Logic/Osm/RelationSplitlHandler.ts b/Logic/Osm/RelationSplitlHandler.ts deleted file mode 100644 index 7657e094f..000000000 --- a/Logic/Osm/RelationSplitlHandler.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * The logic to handle relations after a way within - */ -export default class RelationSplitlHandler { - - constructor() { - - } - - -} \ No newline at end of file diff --git a/Logic/Osm/SplitAction.ts b/Logic/Osm/SplitAction.ts deleted file mode 100644 index 6153f2bad..000000000 --- a/Logic/Osm/SplitAction.ts +++ /dev/null @@ -1,222 +0,0 @@ -import {OsmNode, OsmObject, OsmRelation, OsmWay} from "./OsmObject"; -import {GeoOperations} from "../GeoOperations"; -import State from "../../State"; -import {UIEventSource} from "../UIEventSource"; -import {Changes} from "./Changes"; - -interface SplitInfo { - originalIndex?: number, // or negative for new elements - lngLat: [number, number], - doSplit: boolean -} - -export default class SplitAction { - private readonly roadObject: any; - - /*** - * - * @param roadObject: the geojson of the road object. Properties.id must be the corresponding OSM-id - */ - constructor(roadObject: any) { - this.roadObject = roadObject; - } - - private static SegmentSplitInfo(splitInfo: SplitInfo[]): SplitInfo[][] { - const wayParts = [] - let currentPart = [] - for (const splitInfoElement of splitInfo) { - currentPart.push(splitInfoElement) - - if (splitInfoElement.doSplit) { - // We have to do a split! - // We add the current index to the currentParts, flush it and add it again - wayParts.push(currentPart) - currentPart = [splitInfoElement] - } - } - wayParts.push(currentPart) - return wayParts - } - - public DoSplit(splitPoints: any[]) { - // We mark the new split points with a new id - for (const splitPoint of splitPoints) { - splitPoint.properties["_is_split_point"] = true - } - - - const self = this; - const id = this.roadObject.properties.id - const osmWay = >OsmObject.DownloadObject(id) - const partOf = OsmObject.DownloadReferencingRelations(id) - osmWay.map(originalElement => { - - if(originalElement === undefined || partOf === undefined){ - return; - } - - const changes = State.state?.changes ?? new Changes(); - // First, calculate splitpoints and remove points close to one another - const splitInfo = self.CalculateSplitCoordinates(splitPoints) - // Now we have a list with e.g. - // [ { originalIndex: 0}, {originalIndex: 1, doSplit: true}, {originalIndex: 2}, {originalIndex: undefined, doSplit: true}, {originalIndex: 3}] - - // Lets change 'originalIndex' to the actual node id first: - for (const element of splitInfo) { - if (element.originalIndex >= 0) { - element.originalIndex = originalElement.nodes[element.originalIndex] - } else { - element.originalIndex = changes.getNewID(); - } - } - - // Next up is creating actual parts from this - const wayParts = SplitAction.SegmentSplitInfo(splitInfo); - - // Allright! At this point, we have our new ways! - // Which one is the longest of them (and can keep the id)? - - let longest = undefined; - for (const wayPart of wayParts) { - if (longest === undefined) { - longest = wayPart; - continue - } - if (wayPart.length > longest.length) { - longest = wayPart - } - } - - const newOsmObjects: OsmObject[] = [] - const modifiedObjects: OsmObject[] = [] - // Let's create the new points as needed - for (const element of splitInfo) { - if (element.originalIndex >= 0) { - continue; - } - const node = new OsmNode(element.originalIndex) - node.lon = element.lngLat[0] - node.lat = element.lngLat[1] - newOsmObjects.push(node) - } - - const newWayIds: number[] = [] - // Lets create OsmWays based on them - for (const wayPart of wayParts) { - - let isOriginal = wayPart === longest - if(isOriginal){ - // We change the actual element! - originalElement.nodes = wayPart.map(p => p.originalIndex); - originalElement.changed = true; - modifiedObjects.push(originalElement) - }else{ - let id = changes.getNewID(); - const way = new OsmWay(id) - way.tags = originalElement.tags; - way.nodes = wayPart.map(p => p.originalIndex); - way.changed = true; - newOsmObjects.push(way) - newWayIds.push(way.id) - } - - } - - // At last, we still have to check that we aren't part of a relation... - // At least, the order of the ways is identical, so we can keep the same roles - - modifiedObjects.push(...SplitAction.UpdateRelations(partOf.data, newWayIds, originalElement)) - // And we have our objects! - // Time to upload - - console.log(Changes.createChangesetFor("123", modifiedObjects, newOsmObjects)) - }, [partOf]) - } - - private static UpdateRelations(data: OsmRelation[], newWayIds: number[], originalElement: OsmWay):OsmRelation[]{ - // TODO - return [] - } - - /** - * Calculates the actual points to split - * If another point is closer then ~5m, we reuse that point - */ - private CalculateSplitCoordinates( - splitPoints: any[], - toleranceInM = 5): SplitInfo[] { - - const allPoints = [...splitPoints]; - // We have a bunch of coordinates here: [ [lat, lon], [lat, lon], ...] ... - const originalPoints: [number, number][] = this.roadObject.geometry.coordinates - // We project them onto the line (which should yield pretty much the same point - for (let i = 0; i < originalPoints.length; i++) { - let originalPoint = originalPoints[i]; - let projected = GeoOperations.nearestPoint(this.roadObject, originalPoint) - projected.properties["_is_split_point"] = false - projected.properties["_original_index"] = i - allPoints.push(projected) - } - // At this point, we have a list of both the split point and the old points, with some properties to discriminate between them - // We sort this list so that the new points are at the same location - allPoints.sort((a, b) => a.properties.location - b.properties.location) - - // When this is done, we check that no now point is too close to an already existing point and no very small segments get created - - for (let i = allPoints.length - 1; i > 0; i--) { - - const point = allPoints[i]; - if (point.properties._original_index !== undefined) { - // This point is already in OSM - we have to keep it! - continue; - } - - if (i != allPoints.length - 1) { - const prevPoint = allPoints[i + 1] - const diff = Math.abs(point.properties.location - prevPoint.properties.location) * 1000 - if (diff <= toleranceInM) { - // To close to the previous point! We delete this point... - allPoints.splice(i, 1) - // ... and mark the previous point as a split point - prevPoint.properties._is_split_point = true - continue; - } - } - - if (i > 0) { - const nextPoint = allPoints[i - 1] - const diff = Math.abs(point.properties.location - nextPoint.properties.location) * 1000 - if (diff <= toleranceInM) { - // To close to the next point! We delete this point... - allPoints.splice(i, 1) - // ... and mark the next point as a split point - nextPoint.properties._is_split_point = true - // noinspection UnnecessaryContinueJS - continue; - } - } - // We don't have to remove this point... - } - - const splitInfo: SplitInfo[] = [] - let nextId = -1 - - for (const p of allPoints) { - let index = p.properties._original_index - if (index === undefined) { - index = nextId; - nextId--; - } - const splitInfoElement = { - originalIndex: index, - lngLat: p.geometry.coordinates, - doSplit: p.properties._is_split_point - } - splitInfo.push(splitInfoElement) - } - - return splitInfo - } - - -} diff --git a/Logic/UIEventSource.ts b/Logic/UIEventSource.ts index d18cec86f..b7b7629c9 100644 --- a/Logic/UIEventSource.ts +++ b/Logic/UIEventSource.ts @@ -2,21 +2,27 @@ import {Utils} from "../Utils"; export class UIEventSource { + private static allSources: UIEventSource[] = UIEventSource.PrepPerf(); public data: T; + public trace: boolean; private readonly tag: string; private _callbacks = []; - - private static allSources : UIEventSource[] = UIEventSource.PrepPerf(); - - static PrepPerf() : UIEventSource[]{ - if(Utils.runningFromConsole){ + + constructor(data: T, tag: string = "") { + this.tag = tag; + this.data = data; + UIEventSource.allSources.push(this); + } + + static PrepPerf(): UIEventSource[] { + if (Utils.runningFromConsole) { return []; } // @ts-ignore window.mapcomplete_performance = () => { console.log(UIEventSource.allSources.length, "uieventsources created"); const copy = [...UIEventSource.allSources]; - copy.sort((a,b) => b._callbacks.length - a._callbacks.length); + copy.sort((a, b) => b._callbacks.length - a._callbacks.length); console.log("Topten is:") for (let i = 0; i < 10; i++) { console.log(copy[i].tag, copy[i]); @@ -25,13 +31,7 @@ export class UIEventSource { } return []; } - - constructor(data: T, tag: string = "") { - this.tag = tag; - this.data = data; - UIEventSource.allSources.push(this); - } - + public static flatten(source: UIEventSource>, possibleSources: UIEventSource[]): UIEventSource { const sink = new UIEventSource(source.data?.data); @@ -68,6 +68,9 @@ export class UIEventSource { // This ^^^ actually works! throw "Don't add console.log directly as a callback - you'll won't be able to find it afterwards. Wrap it in a lambda instead." } + if (this.trace) { + console.trace("Added a callback") + } this._callbacks.push(callback); return this; } @@ -101,12 +104,12 @@ export class UIEventSource { */ public map(f: ((t: T) => J), extraSources: UIEventSource[] = [], - g: ((j:J, t:T) => T) = undefined): UIEventSource { + g: ((j: J, t: T) => T) = undefined): UIEventSource { const self = this; const newSource = new UIEventSource( f(this.data), - "map("+this.tag+")" + "map(" + this.tag + ")" ); const update = function () { @@ -159,9 +162,9 @@ export class UIEventSource { return newSource; } - addCallbackAndRunD(callback: (data :T ) => void) { + addCallbackAndRunD(callback: (data: T) => void) { this.addCallbackAndRun(data => { - if(data !== undefined && data !== null){ + if (data !== undefined && data !== null) { callback(data) } }) diff --git a/State.ts b/State.ts index 8e4322d65..ec3c441da 100644 --- a/State.ts +++ b/State.ts @@ -19,6 +19,7 @@ import TitleHandler from "./Logic/Actors/TitleHandler"; import PendingChangesUploader from "./Logic/Actors/PendingChangesUploader"; import {Relation} from "./Logic/Osm/ExtractRelations"; import OsmApiFeatureSource from "./Logic/FeatureSource/OsmApiFeatureSource"; +import ChangeToElementsActor from "./Logic/Actors/ChangeToElementsActor"; /** * Contains the global state: a bunch of UI-event sources @@ -244,6 +245,9 @@ export default class State { this.allElements = new ElementStorage(); this.changes = new Changes(); + + new ChangeToElementsActor(this.changes, this.allElements) + this.osmApiFeatureSource = new OsmApiFeatureSource() new PendingChangesUploader(this.changes, this.selectedElement); diff --git a/UI/Base/Minimap.ts b/UI/Base/Minimap.ts index 5fe60fff7..82c21ea5d 100644 --- a/UI/Base/Minimap.ts +++ b/UI/Base/Minimap.ts @@ -39,6 +39,7 @@ export default class Minimap extends BaseUIElement { div.style.width = "100%" div.style.minWidth = "40px" div.style.minHeight = "40px" + div.style.position = "relative" const wrapper = document.createElement("div") wrapper.appendChild(div) const self = this; diff --git a/UI/BigComponents/SimpleAddUI.ts b/UI/BigComponents/SimpleAddUI.ts index 9d1fd1475..df29d2b72 100644 --- a/UI/BigComponents/SimpleAddUI.ts +++ b/UI/BigComponents/SimpleAddUI.ts @@ -20,6 +20,7 @@ import LocationInput from "../Input/LocationInput"; import {InputElement} from "../Input/InputElement"; import Loc from "../../Models/Loc"; import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers"; +import CreateNewNodeAction from "../../Logic/Osm/Actions/CreateNewNodeAction"; /* * The SimpleAddUI is a single panel, which can have multiple states: @@ -61,11 +62,6 @@ export default class SimpleAddUI extends Toggle { const selectedPreset = new UIEventSource(undefined); isShown.addCallback(_ => selectedPreset.setData(undefined)) // Clear preset selection when the UI is closed/opened - function createNewPoint(tags: any[], location: { lat: number, lon: number }) { - let feature = State.state.changes.createElement(tags, location.lat, location.lon); - State.state.selectedElement.setData(feature); - } - const presetsOverview = SimpleAddUI.CreateAllPresetsPanel(selectedPreset) const addUi = new VariableUiElement( @@ -75,7 +71,9 @@ export default class SimpleAddUI extends Toggle { } return SimpleAddUI.CreateConfirmButton(preset, (tags, location) => { - createNewPoint(tags, location) + let changes = + State.state.changes.applyAction(new CreateNewNodeAction(tags, location.lat, location.lon)) + State.state.selectedElement.setData(changes.newFeatures[0]); selectedPreset.setData(undefined) }, () => { selectedPreset.setData(undefined) diff --git a/UI/Image/DeleteImage.ts b/UI/Image/DeleteImage.ts index 6f8fbb856..2824c2184 100644 --- a/UI/Image/DeleteImage.ts +++ b/UI/Image/DeleteImage.ts @@ -5,6 +5,7 @@ import Combine from "../Base/Combine"; import State from "../../State"; import Svg from "../../Svg"; import {Tag} from "../../Logic/Tags/Tag"; +import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"; export default class DeleteImage extends Toggle { @@ -15,14 +16,17 @@ export default class DeleteImage extends Toggle { .SetClass("rounded-full p-1") .SetStyle("color:white;background:#ff8c8c") .onClick(() => { - State.state?.changes?.addTag(tags.data.id, new Tag(key, oldValue), tags); + State.state?.changes?. + applyAction(new ChangeTagAction(tags.data.id, new Tag(key, oldValue), tags.data)) }); const deleteButton = Translations.t.image.doDelete.Clone() .SetClass("block w-full pl-4 pr-4") .SetStyle("color:white;background:#ff8c8c; border-top-left-radius:30rem; border-top-right-radius: 30rem;") .onClick(() => { - State.state?.changes?.addTag(tags.data.id, new Tag(key, ""), tags); + State.state?.changes?.applyAction( + new ChangeTagAction( tags.data.id, new Tag(key, ""), tags.data) + ) }); const cancelButton = Translations.t.general.cancel.Clone().SetClass("bg-white pl-4 pr-4").SetStyle("border-bottom-left-radius:30rem; border-bottom-right-radius: 30rem;"); diff --git a/UI/Image/ImageUploadFlow.ts b/UI/Image/ImageUploadFlow.ts index 58d9a3760..d97829609 100644 --- a/UI/Image/ImageUploadFlow.ts +++ b/UI/Image/ImageUploadFlow.ts @@ -11,6 +11,7 @@ import FileSelectorButton from "../Input/FileSelectorButton"; import ImgurUploader from "../../Logic/ImageProviders/ImgurUploader"; import UploadFlowStateUI from "../BigComponents/UploadFlowStateUI"; import LayerConfig from "../../Customizations/JSON/LayerConfig"; +import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"; export class ImageUploadFlow extends Toggle { @@ -28,7 +29,10 @@ export class ImageUploadFlow extends Toggle { key = imagePrefix + ":" + freeIndex; } console.log("Adding image:" + key, url); - State.state.changes.addTag(tags.id, new Tag(key, url), tagsSource); + State.state.changes + .applyAction(new ChangeTagAction( + tags.id, new Tag(key, url), tagsSource.data + )) }) diff --git a/UI/Popup/DeleteWizard.ts b/UI/Popup/DeleteWizard.ts index 1b2ab4b76..146c404d4 100644 --- a/UI/Popup/DeleteWizard.ts +++ b/UI/Popup/DeleteWizard.ts @@ -3,7 +3,7 @@ import State from "../../State"; import Toggle from "../Input/Toggle"; import Translations from "../i18n/Translations"; import Svg from "../../Svg"; -import DeleteAction from "../../Logic/Osm/DeleteAction"; +import DeleteAction from "../../Logic/Osm/Actions/DeleteAction"; import {Tag} from "../../Logic/Tags/Tag"; import {UIEventSource} from "../../Logic/UIEventSource"; import {TagsFilter} from "../../Logic/Tags/TagsFilter"; @@ -19,6 +19,7 @@ import {Changes} from "../../Logic/Osm/Changes"; import {And} from "../../Logic/Tags/And"; import Constants from "../../Models/Constants"; import DeleteConfig from "../../Customizations/JSON/DeleteConfig"; +import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"; export default class DeleteWizard extends Toggle { /** @@ -58,7 +59,9 @@ export default class DeleteWizard extends Toggle { }) } (State.state?.changes ?? new Changes()) - .addTag(id, new And(tagsToApply.map(kv => new Tag(kv.k, kv.v))), tagsSource); + .applyAction(new ChangeTagAction( + id, new And(tagsToApply.map(kv => new Tag(kv.k, kv.v))), tagsSource.data + )) } function doDelete(selected: TagsFilter) { diff --git a/UI/Popup/FeatureInfoBox.ts b/UI/Popup/FeatureInfoBox.ts index f35f73ceb..92a7e27e8 100644 --- a/UI/Popup/FeatureInfoBox.ts +++ b/UI/Popup/FeatureInfoBox.ts @@ -13,6 +13,7 @@ import SharedTagRenderings from "../../Customizations/SharedTagRenderings"; import BaseUIElement from "../BaseUIElement"; import {VariableUiElement} from "../Base/VariableUIElement"; import DeleteWizard from "./DeleteWizard"; +import SplitRoadWizard from "./SplitRoadWizard"; export default class FeatureInfoBox extends ScrollableFullScreen { @@ -66,10 +67,6 @@ export default class FeatureInfoBox extends ScrollableFullScreen { renderings.push(questionBox); } - const hasMinimap = layerConfig.tagRenderings.some(tr => tr.hasMinimap()) - if (!hasMinimap) { - renderings.push(new TagRenderingAnswer(tags, SharedTagRenderings.SharedTagRendering.get("minimap"))) - } if (layerConfig.deletion) { renderings.push( @@ -81,6 +78,19 @@ export default class FeatureInfoBox extends ScrollableFullScreen { )) } + if (layerConfig.allowSplit) { + renderings.push( + new VariableUiElement(tags.map(tags => tags.id).map(id => + new SplitRoadWizard(id)) + )) + } + + + const hasMinimap = layerConfig.tagRenderings.some(tr => tr.hasMinimap()) + if (!hasMinimap) { + renderings.push(new TagRenderingAnswer(tags, SharedTagRenderings.SharedTagRendering.get("minimap"))) + } + renderings.push( new VariableUiElement( State.state.osmConnection.userDetails diff --git a/UI/Popup/SplitRoadWizard.ts b/UI/Popup/SplitRoadWizard.ts index c9cd65e56..60b0527f9 100644 --- a/UI/Popup/SplitRoadWizard.ts +++ b/UI/Popup/SplitRoadWizard.ts @@ -11,7 +11,9 @@ import Combine from "../Base/Combine"; import {Button} from "../Base/Button"; import Translations from "../i18n/Translations"; import LayoutConfig from "../../Customizations/JSON/LayoutConfig"; -import SplitAction from "../../Logic/Osm/SplitAction"; +import SplitAction from "../../Logic/Osm/Actions/SplitAction"; +import {OsmObject, OsmWay} from "../../Logic/Osm/OsmObject"; +import Title from "../Base/Title"; export default class SplitRoadWizard extends Toggle { private static splitLayout = new UIEventSource(SplitRoadWizard.GetSplitLayout()) @@ -26,24 +28,25 @@ export default class SplitRoadWizard extends Toggle { const t = Translations.t.split; // Contains the points on the road that are selected to split on - contains geojson points with extra properties such as 'location' with the distance along the linestring - const splitPoints = new UIEventSource<{feature: any, freshness: Date}[]>([]); + const splitPoints = new UIEventSource<{ feature: any, freshness: Date }[]>([]); + + const hasBeenSplit = new UIEventSource(false) // Toggle variable between show split button and map const splitClicked = new UIEventSource(false); // Minimap on which you can select the points to be splitted - const miniMap = new Minimap({background: State.state.backgroundLayer}); - miniMap.SetStyle("width: 100%; height: 50rem;"); + const miniMap = new Minimap({background: State.state.backgroundLayer, allowMoving: false}); + miniMap.SetStyle("width: 100%; height: 24rem;"); // Define how a cut is displayed on the map // Load the road with given id on the minimap const roadElement = State.state.allElements.ContainingFeatures.get(id) - const splitAction = new SplitAction(roadElement) const roadEventSource = new UIEventSource([{feature: roadElement, freshness: new Date()}]); // Datalayer displaying the road and the cut points (if any) - new ShowDataLayer(roadEventSource, miniMap.leafletMap, State.state.layoutToUse, false, true); - new ShowDataLayer(splitPoints, miniMap.leafletMap, SplitRoadWizard.splitLayout, false, false) + new ShowDataLayer(roadEventSource, miniMap.leafletMap, State.state.layoutToUse, false, true, "splitRoadWay"); + new ShowDataLayer(splitPoints, miniMap.leafletMap, SplitRoadWizard.splitLayout, false, false, "splitRoad: splitpoints") /** * Handles a click on the overleaf map. @@ -60,7 +63,7 @@ export default class SplitRoadWizard extends Toggle { // let the state remember the point, to be able to retrieve it later by id State.state.allElements.addOrGetElement(pointOnRoad); - + // Add it to the list of all points and notify observers splitPoints.data.push({feature: pointOnRoad, freshness: new Date()}); // show the point on the data layer splitPoints.ping(); // not updated using .setData, so manually ping observers @@ -73,7 +76,7 @@ export default class SplitRoadWizard extends Toggle { })) // Toggle between splitmap - const splitButton = new SubtleButton(Svg.scissors_ui(), "Split road"); + const splitButton = new SubtleButton(Svg.scissors_ui(), t.inviteToSplit.Clone()); splitButton.onClick( () => { splitClicked.setData(true) @@ -83,45 +86,63 @@ export default class SplitRoadWizard extends Toggle { // Only show the splitButton if logged in, else show login prompt const splitToggle = new Toggle( splitButton, - t.loginToSplit.Clone().onClick(State.state.osmConnection.AttemptLogin), + t.loginToSplit.Clone().onClick(() => State.state.osmConnection.AttemptLogin()), State.state.osmConnection.isLoggedIn) // Save button - const saveButton = new Button("Split here", () => splitAction.DoSplit(splitPoints.data)); + const saveButton = new Button(t.split.Clone(), () => { + hasBeenSplit.setData(true) + OsmObject.DownloadObject(id).addCallbackAndRunD(way => { + OsmObject.DownloadReferencingRelations(id).addCallbackAndRunD( + partOf => { + const splitAction = new SplitAction( + way, way.asGeoJson(), partOf, splitPoints.data.map(ff => ff.feature) + ) + State.state.changes.applyAction(splitAction) + } + ) + + } + ) + + + }); saveButton.SetClass("block btn btn-primary"); const disabledSaveButton = new Button("Split here", undefined); disabledSaveButton.SetClass("block btn btn-disabled"); // Only show the save button if there are split points defined const saveToggle = new Toggle(disabledSaveButton, saveButton, splitPoints.map((data) => data.length === 0)) - const cancelButton = new Button("Cancel", () => { + const cancelButton = new Button(Translations.t.general.cancel.Clone(), () => { splitClicked.setData(false); splitPoints.setData([]); + splitClicked.setData(false) }); cancelButton.SetClass("block btn btn-secondary"); - const splitTitle = t.splitTitle; - - const mapView = new Combine([splitTitle, miniMap, cancelButton, saveToggle]); - super(mapView, splitToggle, splitClicked); + const splitTitle = new Title(t.splitTitle); + const mapView = new Combine([splitTitle, miniMap, new Combine([cancelButton, saveToggle])]); + mapView.SetClass("question") + const confirm = new Toggle(mapView, splitToggle, splitClicked); + super(t.hasBeenSplit.Clone(), confirm, hasBeenSplit) } private static GetSplitLayout(): LayoutConfig { return new LayoutConfig({ maintainer: "mapcomplete", - language: [], + language: ["en"], startLon: 0, startLat: 0, - description: undefined, + description: "Split points visualisations - built in at SplitRoadWizard.ts", icon: "", startZoom: 0, title: "Split locations", version: "", id: "splitpositions", layers: [{id: "splitpositions", source: {osmTags: "_cutposition=yes"}, icon: "./assets/svg/plus.svg"}] - }, true, "split road wizard layout") + }, true, "(BUILTIN) SplitRoadWizard.ts") } } \ No newline at end of file diff --git a/UI/Popup/TagRenderingQuestion.ts b/UI/Popup/TagRenderingQuestion.ts index 20c0b00d2..8d63325d4 100644 --- a/UI/Popup/TagRenderingQuestion.ts +++ b/UI/Popup/TagRenderingQuestion.ts @@ -25,6 +25,7 @@ import BaseUIElement from "../BaseUIElement"; import {DropDown} from "../Input/DropDown"; import {Unit} from "../../Customizations/JSON/Denomination"; import InputElementWrapper from "../Input/InputElementWrapper"; +import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"; /** * Shows the question element. @@ -56,7 +57,9 @@ export default class TagRenderingQuestion extends Combine { const selection = inputElement.GetValue().data; if (selection) { (State.state?.changes ?? new Changes()) - .addTag(tags.data.id, selection, tags); + .applyAction(new ChangeTagAction( + tags.data.id, selection, tags.data + )) } if (options.afterSave) { diff --git a/UI/ShowDataLayer.ts b/UI/ShowDataLayer.ts index df45af45e..7eb718218 100644 --- a/UI/ShowDataLayer.ts +++ b/UI/ShowDataLayer.ts @@ -22,7 +22,8 @@ export default class ShowDataLayer { leafletMap: UIEventSource, layoutToUse: UIEventSource, enablePopups = true, - zoomToFeatures = false) { + zoomToFeatures = false, + name?:string) { this._leafletMap = leafletMap; this._enablePopups = enablePopups; this._features = features; @@ -60,6 +61,7 @@ export default class ShowDataLayer { } const allFeats = features.data.map(ff => ff.feature); + console.log("Rendering ",allFeats, "features at layer ", name) geoLayer = self.CreateGeojsonLayer(); for (const feat of allFeats) { if (feat === undefined) { @@ -85,9 +87,6 @@ export default class ShowDataLayer { console.error(e) } } - - - State.state.selectedElement.ping(); } features.addCallback(() => update()); diff --git a/UI/SpecialVisualizations.ts b/UI/SpecialVisualizations.ts index 309060b36..1e4864912 100644 --- a/UI/SpecialVisualizations.ts +++ b/UI/SpecialVisualizations.ts @@ -126,6 +126,7 @@ export default class SpecialVisualizations { // This is a list of values idList = JSON.parse(value) } + for (const id of idList) { features.push({ freshness: new Date(), diff --git a/Utils.ts b/Utils.ts index cb8835656..da0672db1 100644 --- a/Utils.ts +++ b/Utils.ts @@ -136,6 +136,19 @@ export class Utils { return newArr; } + public static Identical(t1: T[], t2: T[], eq?: (t: T, t0: T) => boolean): boolean{ + if(t1.length !== t2.length){ + return false + } + eq = (a, b) => a === b + for (let i = 0; i < t1.length ; i++) { + if(!eq(t1[i] ,t2[i])){ + return false + } + } + return true; + } + public static MergeTags(a: any, b: any) { const t = {}; for (const k in a) { diff --git a/assets/themes/cyclestreets/cyclestreets.json b/assets/themes/cyclestreets/cyclestreets.json index 8a5956cd1..da3f5bcc2 100644 --- a/assets/themes/cyclestreets/cyclestreets.json +++ b/assets/themes/cyclestreets/cyclestreets.json @@ -273,7 +273,8 @@ }, "tagRenderings": [ "images" - ] + ], + "allowSplit": true } ] } \ No newline at end of file diff --git a/langs/en.json b/langs/en.json index fed6347da..e632a1378 100644 --- a/langs/en.json +++ b/langs/en.json @@ -30,8 +30,10 @@ "split": { "split": "Split", "cancel": "Cancel", + "inviteToSplit": "Split this road", "loginToSplit": "You must be logged in to split a road", - "splitTitle": "Choose on the map where to split this road" + "splitTitle": "Choose on the map where to split this road", + "hasBeenSplit": "This way has been split" }, "delete": { "delete": "Delete", diff --git a/test.ts b/test.ts index 4dd3a5c71..b47699b0b 100644 --- a/test.ts +++ b/test.ts @@ -1,4 +1,4 @@ -import SplitAction from "./Logic/Osm/SplitAction"; +import SplitAction from "./Logic/Osm/Actions/SplitAction"; import {GeoOperations} from "./Logic/GeoOperations"; const way = { From b55f9a25c660709b06796387e8b20d350b9884f6 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Sun, 18 Jul 2021 14:52:09 +0200 Subject: [PATCH 08/16] More work on refactoring the changes handling --- Logic/Actors/ChangeToElementsActor.ts | 2 +- Logic/Actors/SelectedFeatureHandler.ts | 9 ++- Logic/FeatureSource/ChangeApplicator.ts | 41 ++++++++++--- Logic/FeatureSource/OsmApiFeatureSource.ts | 16 +++-- Logic/Osm/Actions/ChangeTagAction.ts | 2 +- Logic/Osm/Actions/CreateNewNodeAction.ts | 8 ++- Logic/Osm/Actions/OsmChangeAction.ts | 15 +++-- Logic/Osm/Actions/RelationSplitlHandler.ts | 2 +- Logic/Osm/Actions/SplitAction.ts | 5 +- Logic/Osm/Changes.ts | 62 +++++++++++--------- Logic/Osm/OsmObject.ts | 9 ++- Logic/UIEventSource.ts | 27 +++++++-- State.ts | 18 +++--- UI/BigComponents/SimpleAddUI.ts | 24 ++++---- UI/Popup/SplitRoadWizard.ts | 30 ++++++---- UI/ShowDataLayer.ts | 2 +- UI/SubstitutedTranslation.ts | 6 -- UI/i18n/Translation.ts | 6 +- assets/themes/cyclestreets/cyclestreets.json | 2 +- 19 files changed, 181 insertions(+), 105 deletions(-) diff --git a/Logic/Actors/ChangeToElementsActor.ts b/Logic/Actors/ChangeToElementsActor.ts index 2f157866a..9ae5f87ef 100644 --- a/Logic/Actors/ChangeToElementsActor.ts +++ b/Logic/Actors/ChangeToElementsActor.ts @@ -7,7 +7,7 @@ export default class ChangeToElementsActor { for (const change of changes) { const id = change.type + "/" + change.id; if (!allElements.has(id)) { -continue; // Will be picked up later on + continue; // Ignored as the geometryFixer will introduce this } const src = allElements.getEventSourceById(id) diff --git a/Logic/Actors/SelectedFeatureHandler.ts b/Logic/Actors/SelectedFeatureHandler.ts index 3aa5d8e5b..3cfaa079c 100644 --- a/Logic/Actors/SelectedFeatureHandler.ts +++ b/Logic/Actors/SelectedFeatureHandler.ts @@ -13,7 +13,7 @@ export default class SelectedFeatureHandler { private readonly _hash: UIEventSource; private readonly _selectedFeature: UIEventSource; - private static readonly _no_trigger_on = ["welcome","copyright","layers"] + private static readonly _no_trigger_on = ["welcome","copyright","layers","new"] private readonly _osmApiSource: OsmApiFeatureSource; constructor(hash: UIEventSource, @@ -60,7 +60,9 @@ export default class SelectedFeatureHandler { if(hash === undefined || SelectedFeatureHandler._no_trigger_on.indexOf(hash) >= 0){ return; // No valid feature selected } - // We should have a valid osm-ID and zoom to it + // We should have a valid osm-ID and zoom to it... But we wrap it in try-catch to be sure + try{ + OsmObject.DownloadObject(hash).addCallbackAndRunD(element => { const centerpoint = element.centerpoint(); console.log("Zooming to location for select point: ", centerpoint) @@ -68,6 +70,9 @@ export default class SelectedFeatureHandler { location.data.lon = centerpoint[1] location.ping(); }) + }catch(e){ + console.error("Could not download OSM-object with id", hash, " - probably a weird hash") + } } private downloadFeature(hash: string){ diff --git a/Logic/FeatureSource/ChangeApplicator.ts b/Logic/FeatureSource/ChangeApplicator.ts index 58ba5174c..e9aa7ac18 100644 --- a/Logic/FeatureSource/ChangeApplicator.ts +++ b/Logic/FeatureSource/ChangeApplicator.ts @@ -5,6 +5,7 @@ import {ChangeDescription} from "../Osm/Actions/ChangeDescription"; import {Utils} from "../../Utils"; import {OsmNode, OsmRelation, OsmWay} from "../Osm/OsmObject"; + /** * Applies changes from 'Changes' onto a featureSource */ @@ -16,25 +17,41 @@ export default class ChangeApplicator implements FeatureSource { this.name = "ChangesApplied(" + source.name + ")" this.features = source.features - + const seenChanges = new Set(); + const self = this; + let runningUpdate = false; source.features.addCallbackAndRunD(features => { + if(runningUpdate){ + return; // No need to ping again + } ChangeApplicator.ApplyChanges(features, changes.pendingChanges.data) + seenChanges.clear() }) changes.pendingChanges.addCallbackAndRunD(changes => { - ChangeApplicator.ApplyChanges(source.features.data, changes) + runningUpdate = true; + changes = changes.filter(ch => !seenChanges.has(ch)) + changes.forEach(c => seenChanges.add(c)) + console.log("Called back", changes) + ChangeApplicator.ApplyChanges(self.features.data, changes) source.features.ping() + runningUpdate = false; }) } - private static ApplyChanges(features: { feature: any, freshness: Date }[], cs: ChangeDescription[]) { - if (cs.length === 0 || features === undefined) { - return features; + /** + * Returns true if the geometry is changed and the source should be pinged + */ + private static ApplyChanges(features: {feature: any, freshness: Date}[], cs: ChangeDescription[]): boolean { + if (cs.length === 0 || features === undefined ) { + return ; } + console.log("Applying changes ", this.name, cs) + let geometryChanged = false; const changesPerId: Map = new Map() for (const c of cs) { const id = c.type + "/" + c.id @@ -52,6 +69,8 @@ export default class ChangeApplicator implements FeatureSource { feature: feature, freshness: now }) + console.log("Added a new feature: ", feature) + geometryChanged = true; } // First, create the new features - they have a negative ID @@ -61,7 +80,11 @@ export default class ChangeApplicator implements FeatureSource { if (change.id >= 0) { return; // Nothing to do here, already created } - + + if(change.changes === undefined){ + // An update to the object - not the actual created + return; + } try { @@ -93,8 +116,8 @@ export default class ChangeApplicator implements FeatureSource { for (const feature of features) { - const id = feature.feature.properties.id; const f = feature.feature; + const id = f.properties.id; if (!changesPerId.has(id)) { continue; } @@ -118,11 +141,12 @@ export default class ChangeApplicator implements FeatureSource { // Apply other changes to the object if (change.changes !== undefined) { + geometryChanged = true; switch (change.type) { case "node": // @ts-ignore const coor: { lat, lon } = change.changes; - f.geometry.coordinates = [[coor.lon, coor.lat]] + f.geometry.coordinates = [coor.lon, coor.lat] break; case "way": f.geometry.coordinates = change.changes["locations"] @@ -134,5 +158,6 @@ export default class ChangeApplicator implements FeatureSource { } } } + return geometryChanged } } \ No newline at end of file diff --git a/Logic/FeatureSource/OsmApiFeatureSource.ts b/Logic/FeatureSource/OsmApiFeatureSource.ts index ec1c03a74..de90274d2 100644 --- a/Logic/FeatureSource/OsmApiFeatureSource.ts +++ b/Logic/FeatureSource/OsmApiFeatureSource.ts @@ -15,15 +15,19 @@ export default class OsmApiFeatureSource implements FeatureSource { public load(id: string) { - if(id.indexOf("-") >= 0){ + if (id.indexOf("-") >= 0) { // Newly added point - not yet in OSM return; } console.debug("Downloading", id, "from the OSM-API") OsmObject.DownloadObject(id).addCallbackAndRunD(element => { - const geojson = element.asGeoJson(); - geojson.id = geojson.properties.id; - this.features.setData([{feature: geojson, freshness: element.timestamp}]) + try { + const geojson = element.asGeoJson(); + geojson.id = geojson.properties.id; + this.features.setData([{feature: geojson, freshness: element.timestamp}]) + } catch (e) { + console.error(e) + } }) } @@ -58,7 +62,7 @@ export default class OsmApiFeatureSource implements FeatureSource { const bounds = Utils.tile_bounds(z, x, y); console.log("Loading OSM data tile", z, x, y, " with bounds", bounds) OsmObject.LoadArea(bounds, objects => { - const keptGeoJson: {feature:any, freshness: Date}[] = [] + const keptGeoJson: { feature: any, freshness: Date }[] = [] // Which layer does the object match? for (const object of objects) { @@ -69,7 +73,7 @@ export default class OsmApiFeatureSource implements FeatureSource { if (doesMatch) { const geoJson = object.asGeoJson(); geoJson._matching_layer_id = layer.id - keptGeoJson.push({feature: geoJson, freshness: object.timestamp}) + keptGeoJson.push({feature: geoJson, freshness: object.timestamp}) break; } diff --git a/Logic/Osm/Actions/ChangeTagAction.ts b/Logic/Osm/Actions/ChangeTagAction.ts index 1915d1858..36bafcbee 100644 --- a/Logic/Osm/Actions/ChangeTagAction.ts +++ b/Logic/Osm/Actions/ChangeTagAction.ts @@ -37,7 +37,7 @@ export default class ChangeTagAction extends OsmChangeAction { return {k: key.trim(), v: value.trim()}; } - Perform(changes: Changes): ChangeDescription [] { + CreateChangeDescriptions(changes: Changes): ChangeDescription [] { const changedTags: { k: string, v: string }[] = this._tagsFilter.asChange(this._currentTags).map(ChangeTagAction.checkChange) const typeId = this._elementId.split("/") const type = typeId[0] diff --git a/Logic/Osm/Actions/CreateNewNodeAction.ts b/Logic/Osm/Actions/CreateNewNodeAction.ts index 28a656039..692271a12 100644 --- a/Logic/Osm/Actions/CreateNewNodeAction.ts +++ b/Logic/Osm/Actions/CreateNewNodeAction.ts @@ -4,23 +4,27 @@ import {Changes} from "../Changes"; import {ChangeDescription} from "./ChangeDescription"; import {And} from "../../Tags/And"; -export default class CreateNewNodeAction implements OsmChangeAction { +export default class CreateNewNodeAction extends OsmChangeAction { private readonly _basicTags: Tag[]; private readonly _lat: number; private readonly _lon: number; + public newElementId : string = undefined + constructor(basicTags: Tag[], lat: number, lon: number) { + super() this._basicTags = basicTags; this._lat = lat; this._lon = lon; } - Perform(changes: Changes): ChangeDescription[] { + CreateChangeDescriptions(changes: Changes): ChangeDescription[] { const id = changes.getNewID() const properties = { id: "node/" + id } + this.newElementId = "node/"+id for (const kv of this._basicTags) { if (typeof kv.value !== "string") { throw "Invalid value: don't use a regex in a preset" diff --git a/Logic/Osm/Actions/OsmChangeAction.ts b/Logic/Osm/Actions/OsmChangeAction.ts index ecb9f3df8..0308ca8f6 100644 --- a/Logic/Osm/Actions/OsmChangeAction.ts +++ b/Logic/Osm/Actions/OsmChangeAction.ts @@ -7,10 +7,17 @@ import {ChangeDescription} from "./ChangeDescription"; export default abstract class OsmChangeAction { + private isUsed = false + + public Perform(changes: Changes) { + if (this.isUsed) { + throw "This ChangeAction is already used: " + this.constructor.name + } + this.isUsed = true; + return this.CreateChangeDescriptions(changes) + } + + protected abstract CreateChangeDescriptions(changes: Changes): ChangeDescription[] - public abstract Perform(changes: Changes): ChangeDescription[] - - - } \ No newline at end of file diff --git a/Logic/Osm/Actions/RelationSplitlHandler.ts b/Logic/Osm/Actions/RelationSplitlHandler.ts index 601b2d136..215cee840 100644 --- a/Logic/Osm/Actions/RelationSplitlHandler.ts +++ b/Logic/Osm/Actions/RelationSplitlHandler.ts @@ -12,7 +12,7 @@ export default class RelationSplitlHandler extends OsmChangeAction{ super() } - Perform(changes: Changes): ChangeDescription[] { + CreateChangeDescriptions(changes: Changes): ChangeDescription[] { return []; } diff --git a/Logic/Osm/Actions/SplitAction.ts b/Logic/Osm/Actions/SplitAction.ts index c17fd3fde..1e9f94ade 100644 --- a/Logic/Osm/Actions/SplitAction.ts +++ b/Logic/Osm/Actions/SplitAction.ts @@ -42,7 +42,7 @@ export default class SplitAction extends OsmChangeAction { return wayParts.filter(wp => wp.length > 0) } - Perform(changes: Changes): ChangeDescription[] { + CreateChangeDescriptions(changes: Changes): ChangeDescription[] { const splitPoints = this._splitPoints // We mark the new split points with a new id console.log(splitPoints) @@ -72,7 +72,6 @@ export default class SplitAction extends OsmChangeAction { // Next up is creating actual parts from this const wayParts: SplitInfo[][] = SplitAction.SegmentSplitInfo(splitInfo); -console.log("WayParts", wayParts, "by", splitInfo) // Allright! At this point, we have our new ways! // Which one is the longest of them (and can keep the id)? @@ -144,7 +143,7 @@ console.log("WayParts", wayParts, "by", splitInfo) // At last, we still have to check that we aren't part of a relation... // At least, the order of the ways is identical, so we can keep the same roles - changeDescription.push(...new RelationSplitlHandler(partOf, newWayIds, originalNodes).Perform(changes)) + changeDescription.push(...new RelationSplitlHandler(partOf, newWayIds, originalNodes).CreateChangeDescriptions(changes)) // And we have our objects! // Time to upload diff --git a/Logic/Osm/Changes.ts b/Logic/Osm/Changes.ts index 18b67970e..4fb0f0892 100644 --- a/Logic/Osm/Changes.ts +++ b/Logic/Osm/Changes.ts @@ -4,7 +4,6 @@ import {UIEventSource} from "../UIEventSource"; import Constants from "../../Models/Constants"; import OsmChangeAction from "./Actions/OsmChangeAction"; import {ChangeDescription} from "./Actions/ChangeDescription"; -import {LocalStorageSource} from "../Web/LocalStorageSource"; import {Utils} from "../../Utils"; /** @@ -23,25 +22,23 @@ export class Changes { public readonly pendingChanges = new UIEventSource([]) // LocalStorageSource.GetParsed("pending-changes", []) private readonly isUploading = new UIEventSource(false); + + private readonly previouslyCreated : OsmObject[] = [] constructor() { - this.isUploading.addCallbackAndRun(u => { - if (u) { - console.trace("Uploading set!") - } - }) + } - public static createChangesetFor(csId: string, + private static createChangesetFor(csId: string, allChanges: { - modifiedObjects?: OsmObject[], - newElements?: OsmObject[], - deletedElements?: OsmObject[] + modifiedObjects: OsmObject[], + newObjects: OsmObject[], + deletedObjects: OsmObject[] }): string { const changedElements = allChanges.modifiedObjects ?? [] - const newElements = allChanges.newElements ?? [] - const deletedElements = allChanges.deletedElements ?? [] + const newElements = allChanges.newObjects ?? [] + const deletedElements = allChanges.deletedObjects ?? [] let changes = ``; if (newElements.length > 0) { @@ -73,7 +70,7 @@ export class Changes { .map(c => c.type + "/" + c.id)) } - private static CreateChangesetObjects(changes: ChangeDescription[], downloadedOsmObjects: OsmObject[]): { + private CreateChangesetObjects(changes: ChangeDescription[], downloadedOsmObjects: OsmObject[]): { newObjects: OsmObject[], modifiedObjects: OsmObject[] deletedObjects: OsmObject[] @@ -87,12 +84,21 @@ export class Changes { states.set(o.type + "/" + o.id, "unchanged") } + for (const o of this.previouslyCreated) { + objects.set(o.type + "/" + o.id, o) + states.set(o.type + "/" + o.id, "unchanged") + } + let changed = false; for (const change of changes) { const id = change.type + "/" + change.id if (!objects.has(id)) { + if(change.id >= 0){ + throw "Did not get an object that should be known: "+id + } // This is a new object that should be created states.set(id, "created") + console.log("Creating object for changeDescription", change) let osmObj: OsmObject = undefined; switch (change.type) { case "node": @@ -116,6 +122,7 @@ export class Changes { throw "Hmm? This is a bug" } objects.set(id, osmObj) + this.previouslyCreated.push(osmObj) } const state = states.get(id) @@ -195,8 +202,8 @@ export class Changes { newObjects: [], modifiedObjects: [], deletedObjects: [] - } + objects.forEach((v, id) => { const state = states.get(id) @@ -228,20 +235,18 @@ export class Changes { */ public flushChanges(flushreason: string = undefined) { if (this.pendingChanges.data.length === 0) { - console.log("No pending changes") return; } - if (flushreason !== undefined) { - console.log(flushreason) - } - + if (this.isUploading.data) { - console.log("Is uploading... Abort") + console.log("Is already uploading... Abort") return; } + + this.isUploading.setData(true) - - console.log("Beginning upload..."); + + console.log("Beginning upload... "+flushreason ?? ""); // At last, we build the changeset and upload const self = this; const pending = self.pendingChanges.data; @@ -249,8 +254,12 @@ export class Changes { console.log("Needed ids", neededIds) OsmObject.DownloadAll(neededIds, true).addCallbackAndRunD(osmObjects => { console.log("Got the fresh objects!", osmObjects, "pending: ", pending) - const changes = Changes.CreateChangesetObjects(pending, osmObjects) - console.log("Changes", changes) + const changes: { + newObjects: OsmObject[], + modifiedObjects: OsmObject[] + deletedObjects: OsmObject[] + + } = self.CreateChangesetObjects(pending, osmObjects) if (changes.newObjects.length + changes.deletedObjects.length + changes.modifiedObjects.length === 0) { console.log("No changes to be made") this.pendingChanges.setData([]) @@ -262,11 +271,8 @@ export class Changes { State.state.osmConnection.UploadChangeset( State.state.layoutToUse.data, State.state.allElements, - (csId) => { - return Changes.createChangesetFor(csId, changes); - }, + (csId) => Changes.createChangesetFor(csId, changes), () => { - // When done console.log("Upload successfull!") self.pendingChanges.setData([]); self.isUploading.setData(false) diff --git a/Logic/Osm/OsmObject.ts b/Logic/Osm/OsmObject.ts index 70a93e906..fcc3da888 100644 --- a/Logic/Osm/OsmObject.ts +++ b/Logic/Osm/OsmObject.ts @@ -23,7 +23,7 @@ export abstract class OsmObject { this.id = id; this.type = type; this.tags = { - id: id + id: `${this.type}/${id}` } } @@ -52,6 +52,9 @@ export abstract class OsmObject { const splitted = id.split("/"); const type = splitted[0]; const idN = Number(splitted[1]); + if(idN <0){ + return; + } OsmObject.objectCache.set(id, src); const newContinuation = (element: OsmObject) => { @@ -69,7 +72,7 @@ export abstract class OsmObject { new OsmRelation(idN).Download(newContinuation); break; default: - throw "Invalid road type:" + type; + throw "Invalid object type:" + type + id; } return src; @@ -105,7 +108,7 @@ export abstract class OsmObject { if (OsmObject.referencingRelationsCache.has(id)) { return OsmObject.referencingRelationsCache.get(id); } - const relsSrc = new UIEventSource([]) + const relsSrc = new UIEventSource(undefined) OsmObject.referencingRelationsCache.set(id, relsSrc); Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${id}/relations`) .then(data => { diff --git a/Logic/UIEventSource.ts b/Logic/UIEventSource.ts index b7b7629c9..84fa426b6 100644 --- a/Logic/UIEventSource.ts +++ b/Logic/UIEventSource.ts @@ -6,7 +6,7 @@ export class UIEventSource { public data: T; public trace: boolean; private readonly tag: string; - private _callbacks = []; + private _callbacks: ((t: T) => (boolean | void | any)) [] = []; constructor(data: T, tag: string = "") { this.tag = tag; @@ -31,7 +31,7 @@ export class UIEventSource { } return []; } - + public static flatten(source: UIEventSource>, possibleSources: UIEventSource[]): UIEventSource { const sink = new UIEventSource(source.data?.data); @@ -63,7 +63,13 @@ export class UIEventSource { } - public addCallback(callback: ((latestData: T) => void)): UIEventSource { + /** + * Adds a callback + * + * If the result of the callback is 'true', the callback is considered finished and will be removed again + * @param callback + */ + public addCallback(callback: ((latestData: T) => (boolean | void | any))): UIEventSource { if (callback === console.log) { // This ^^^ actually works! throw "Don't add console.log directly as a callback - you'll won't be able to find it afterwards. Wrap it in a lambda instead." @@ -90,8 +96,21 @@ export class UIEventSource { } public ping(): void { + let toDelete = undefined for (const callback of this._callbacks) { - callback(this.data); + if (callback(this.data) === true) { + // This callback wants to be deleted + if (toDelete === undefined) { + toDelete = [callback] + } else { + toDelete.push(callback) + } + } + } + if (toDelete !== undefined) { + for (const toDeleteElement of toDelete) { + this._callbacks.splice(this._callbacks.indexOf(toDeleteElement), 1) + } } } diff --git a/State.ts b/State.ts index 629af8e06..688510949 100644 --- a/State.ts +++ b/State.ts @@ -20,7 +20,7 @@ import PendingChangesUploader from "./Logic/Actors/PendingChangesUploader"; import {Relation} from "./Logic/Osm/ExtractRelations"; import OsmApiFeatureSource from "./Logic/FeatureSource/OsmApiFeatureSource"; import ChangeToElementsActor from "./Logic/Actors/ChangeToElementsActor"; -import FeaturePipeline from "./Logic/FeatureSource/FeaturePipeline"; +import FeatureSource from "./Logic/FeatureSource/FeatureSource"; /** * Contains the global state: a bunch of UI-event sources @@ -32,7 +32,7 @@ export default class State { public static state: State; - public readonly layoutToUse = new UIEventSource(undefined); + public readonly layoutToUse = new UIEventSource(undefined, "layoutToUse"); /** The mapping from id -> UIEventSource @@ -45,7 +45,7 @@ export default class State { /** The leaflet instance of the big basemap */ - public leafletMap = new UIEventSource(undefined); + public leafletMap = new UIEventSource(undefined, "leafletmap"); /** * Background layer id */ @@ -70,7 +70,7 @@ export default class State { }[]> = new UIEventSource<{ readonly isDisplayed: UIEventSource, readonly layerDef: LayerConfig; - }[]>([]) + }[]>([], "filteredLayers") /** @@ -101,13 +101,13 @@ export default class State { public readonly featureSwitchFakeUser: UIEventSource; - public readonly featurePipeline: FeaturePipeline; + public featurePipeline: FeatureSource; /** * The map location: currently centered lat, lon and zoom */ - public readonly locationControl = new UIEventSource(undefined); + public readonly locationControl = new UIEventSource(undefined, "locationControl"); public backgroundLayer; public readonly backgroundLayerId: UIEventSource; @@ -149,11 +149,13 @@ export default class State { .syncWith(LocalStorageSource.Get("lon"))); - this.locationControl = new UIEventSource({ + this.locationControl.setData({ zoom: Utils.asFloat(zoom.data), lat: Utils.asFloat(lat.data), lon: Utils.asFloat(lon.data), - }).addCallback((latlonz) => { + }) + this.locationControl.addCallback((latlonz) => { + // Sync th location controls zoom.setData(latlonz.zoom); lat.setData(latlonz.lat); lon.setData(latlonz.lon); diff --git a/UI/BigComponents/SimpleAddUI.ts b/UI/BigComponents/SimpleAddUI.ts index df29d2b72..220585188 100644 --- a/UI/BigComponents/SimpleAddUI.ts +++ b/UI/BigComponents/SimpleAddUI.ts @@ -21,6 +21,7 @@ import {InputElement} from "../Input/InputElement"; import Loc from "../../Models/Loc"; import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers"; import CreateNewNodeAction from "../../Logic/Osm/Actions/CreateNewNodeAction"; +import Hash from "../../Logic/Web/Hash"; /* * The SimpleAddUI is a single panel, which can have multiple states: @@ -71,10 +72,11 @@ export default class SimpleAddUI extends Toggle { } return SimpleAddUI.CreateConfirmButton(preset, (tags, location) => { - let changes = - State.state.changes.applyAction(new CreateNewNodeAction(tags, location.lat, location.lon)) - State.state.selectedElement.setData(changes.newFeatures[0]); + const newElementAction = new CreateNewNodeAction(tags, location.lat, location.lon) + State.state.changes.applyAction(newElementAction) selectedPreset.setData(undefined) + isShown.setData(false) + Hash.hash.setData(newElementAction.newElementId) }, () => { selectedPreset.setData(undefined) }) @@ -119,16 +121,16 @@ export default class SimpleAddUI extends Toggle { lon: location.data.lon, zoom: 19 }); - + let backgroundLayer = undefined; - if(preset.preciseInput.preferredBackground){ - backgroundLayer= AvailableBaseLayers.SelectBestLayerAccordingTo(locationSrc, new UIEventSource(preset.preciseInput.preferredBackground)) + if (preset.preciseInput.preferredBackground) { + backgroundLayer = AvailableBaseLayers.SelectBestLayerAccordingTo(locationSrc, new UIEventSource(preset.preciseInput.preferredBackground)) } - + preciseInput = new LocationInput({ mapBackground: backgroundLayer, - centerLocation:locationSrc - + centerLocation: locationSrc + }) preciseInput.SetClass("h-32 rounded-xl overflow-hidden border border-gray").SetStyle("height: 12rem;") } @@ -143,7 +145,7 @@ export default class SimpleAddUI extends Toggle { .onClick(() => { confirm(preset.tags, (preciseInput?.GetValue() ?? location).data); }); - + if (preciseInput !== undefined) { confirmButton = new Combine([preciseInput, confirmButton]) } @@ -239,7 +241,7 @@ export default class SimpleAddUI extends Toggle { for (const preset of presets) { const tags = TagUtils.KVtoProperties(preset.tags ?? []); - let icon:() => BaseUIElement = () => layer.layerDef.GenerateLeafletStyle(new UIEventSource(tags), false).icon.html + let icon: () => BaseUIElement = () => layer.layerDef.GenerateLeafletStyle(new UIEventSource(tags), false).icon.html .SetClass("w-12 h-12 block relative"); const presetInfo: PresetInfo = { tags: preset.tags, diff --git a/UI/Popup/SplitRoadWizard.ts b/UI/Popup/SplitRoadWizard.ts index 60b0527f9..962c55a4d 100644 --- a/UI/Popup/SplitRoadWizard.ts +++ b/UI/Popup/SplitRoadWizard.ts @@ -92,18 +92,24 @@ export default class SplitRoadWizard extends Toggle { // Save button const saveButton = new Button(t.split.Clone(), () => { hasBeenSplit.setData(true) - OsmObject.DownloadObject(id).addCallbackAndRunD(way => { - OsmObject.DownloadReferencingRelations(id).addCallbackAndRunD( - partOf => { - const splitAction = new SplitAction( - way, way.asGeoJson(), partOf, splitPoints.data.map(ff => ff.feature) - ) - State.state.changes.applyAction(splitAction) - } - ) - + const way = OsmObject.DownloadObject(id) + const partOfSrc = OsmObject.DownloadReferencingRelations(id); + let hasRun = false + way.map(way => { + const partOf = partOfSrc.data + if(way === undefined || partOf === undefined){ + return; } - ) + if(hasRun){ + return + } + hasRun = true + const splitAction = new SplitAction( + way, way.asGeoJson(), partOf, splitPoints.data.map(ff => ff.feature) + ) + State.state.changes.applyAction(splitAction) + + }, [partOfSrc]) }); @@ -123,7 +129,7 @@ export default class SplitRoadWizard extends Toggle { const splitTitle = new Title(t.splitTitle); - const mapView = new Combine([splitTitle, miniMap, new Combine([cancelButton, saveToggle])]); + const mapView = new Combine([splitTitle, miniMap, new Combine([cancelButton, saveToggle]).SetClass("flex flex-row")]); mapView.SetClass("question") const confirm = new Toggle(mapView, splitToggle, splitClicked); super(t.hasBeenSplit.Clone(), confirm, hasBeenSplit) diff --git a/UI/ShowDataLayer.ts b/UI/ShowDataLayer.ts index 7eb718218..36fb62efc 100644 --- a/UI/ShowDataLayer.ts +++ b/UI/ShowDataLayer.ts @@ -61,7 +61,6 @@ export default class ShowDataLayer { } const allFeats = features.data.map(ff => ff.feature); - console.log("Rendering ",allFeats, "features at layer ", name) geoLayer = self.CreateGeojsonLayer(); for (const feat of allFeats) { if (feat === undefined) { @@ -87,6 +86,7 @@ export default class ShowDataLayer { console.error(e) } } + State.state.selectedElement.ping() } features.addCallback(() => update()); diff --git a/UI/SubstitutedTranslation.ts b/UI/SubstitutedTranslation.ts index 43352aa5b..361540e65 100644 --- a/UI/SubstitutedTranslation.ts +++ b/UI/SubstitutedTranslation.ts @@ -19,7 +19,6 @@ export class SubstitutedTranslation extends VariableUiElement { const extraMappings: SpecialVisualization[] = []; mapping?.forEach((value, key) => { - console.log("KV:", key, value) extraMappings.push( { funcName: key, @@ -73,11 +72,6 @@ export class SubstitutedTranslation extends VariableUiElement { } }[] { - if (extraMappings.length > 0) { - - console.log("Extra mappings are", extraMappings) - } - for (const knownSpecial of SpecialVisualizations.specialVisualizations.concat(extraMappings)) { // Note: the '.*?' in the regex reads as 'any character, but in a non-greedy way' diff --git a/UI/i18n/Translation.ts b/UI/i18n/Translation.ts index e6e2dfba2..f153b1e65 100644 --- a/UI/i18n/Translation.ts +++ b/UI/i18n/Translation.ts @@ -109,9 +109,9 @@ export class Translation extends BaseUIElement { // @ts-ignore const date: Date = el; rtext = date.toLocaleString(); - } else if (el.ConstructElement() === undefined) { - console.error("InnerREnder is not defined", el); - throw "Hmmm, el.InnerRender is not defined?" + } else if (el.ConstructElement === undefined) { + console.error("ConstructElement is not defined", el); + throw "ConstructElement is not defined, you are working with a "+(typeof el)+":"+(el.constructor.name) } else { Translation.forcedLanguage = lang; // This is a very dirty hack - it'll bite me one day rtext = el.ConstructElement().innerHTML; diff --git a/assets/themes/cyclestreets/cyclestreets.json b/assets/themes/cyclestreets/cyclestreets.json index da3f5bcc2..ea8026cb3 100644 --- a/assets/themes/cyclestreets/cyclestreets.json +++ b/assets/themes/cyclestreets/cyclestreets.json @@ -33,7 +33,7 @@ "startZoom": 14, "startLon": 3.2228, "maintainer": "MapComplete", - "widenfactor": 0.05, + "widenfactor": 0.01, "roamingRenderings": [ { "question": { From 9f91d301475a844108826e095296e8fdb8fda8c3 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Sun, 18 Jul 2021 17:50:35 +0200 Subject: [PATCH 09/16] Add flag to generate new items only once --- Logic/FeatureSource/ChangeApplicator.ts | 91 +++++++++++++------------ Logic/FeatureSource/FeaturePipeline.ts | 2 +- 2 files changed, 47 insertions(+), 46 deletions(-) diff --git a/Logic/FeatureSource/ChangeApplicator.ts b/Logic/FeatureSource/ChangeApplicator.ts index e9aa7ac18..5e87d80ff 100644 --- a/Logic/FeatureSource/ChangeApplicator.ts +++ b/Logic/FeatureSource/ChangeApplicator.ts @@ -13,7 +13,9 @@ export default class ChangeApplicator implements FeatureSource { public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>; public readonly name: string; - constructor(source: FeatureSource, changes: Changes) { + constructor(source: FeatureSource, changes: Changes, mode?: { + generateNewGeometries: boolean + }) { this.name = "ChangesApplied(" + source.name + ")" this.features = source.features @@ -21,10 +23,10 @@ export default class ChangeApplicator implements FeatureSource { const self = this; let runningUpdate = false; source.features.addCallbackAndRunD(features => { - if(runningUpdate){ + if (runningUpdate) { return; // No need to ping again } - ChangeApplicator.ApplyChanges(features, changes.pendingChanges.data) + ChangeApplicator.ApplyChanges(features, changes.pendingChanges.data, mode) seenChanges.clear() }) @@ -33,7 +35,7 @@ export default class ChangeApplicator implements FeatureSource { changes = changes.filter(ch => !seenChanges.has(ch)) changes.forEach(c => seenChanges.add(c)) console.log("Called back", changes) - ChangeApplicator.ApplyChanges(self.features.data, changes) + ChangeApplicator.ApplyChanges(self.features.data, changes, mode) source.features.ping() runningUpdate = false; }) @@ -45,9 +47,9 @@ export default class ChangeApplicator implements FeatureSource { /** * Returns true if the geometry is changed and the source should be pinged */ - private static ApplyChanges(features: {feature: any, freshness: Date}[], cs: ChangeDescription[]): boolean { - if (cs.length === 0 || features === undefined ) { - return ; + private static ApplyChanges(features: { feature: any; freshness: Date }[], cs: ChangeDescription[], mode: { generateNewGeometries: boolean }): boolean { + if (cs.length === 0 || features === undefined) { + return; } console.log("Applying changes ", this.name, cs) @@ -75,45 +77,47 @@ export default class ChangeApplicator implements FeatureSource { // First, create the new features - they have a negative ID // We don't set the properties yet though - changesPerId.forEach(cs => { - cs.forEach(change => { - if (change.id >= 0) { - return; // Nothing to do here, already created - } - - if(change.changes === undefined){ - // An update to the object - not the actual created - return; - } + if (mode?.generateNewGeometries) { + changesPerId.forEach(cs => { + cs + .forEach(change => { + if (change.id >= 0) { + return; // Nothing to do here, already created + } - try { + if (change.changes === undefined) { + // An update to the object - not the actual created + return; + } - switch (change.type) { - case "node": - const n = new OsmNode(change.id) - n.lat = change.changes["lat"] - n.lon = change.changes["lon"] - const geojson = n.asGeoJson() - add(geojson) - break; - case "way": - const w = new OsmWay(change.id) - w.nodes = change.changes["nodes"] - add(w.asGeoJson()) - break; - case "relation": - const r = new OsmRelation(change.id) - r.members = change.changes["members"] - add(r.asGeoJson()) - break; - } + try { - } catch (e) { - console.error(e) - } + switch (change.type) { + case "node": + const n = new OsmNode(change.id) + n.lat = change.changes["lat"] + n.lon = change.changes["lon"] + const geojson = n.asGeoJson() + add(geojson) + break; + case "way": + const w = new OsmWay(change.id) + w.nodes = change.changes["nodes"] + add(w.asGeoJson()) + break; + case "relation": + const r = new OsmRelation(change.id) + r.members = change.changes["members"] + add(r.asGeoJson()) + break; + } + + } catch (e) { + console.error(e) + } + }) }) - }) - + } for (const feature of features) { const f = feature.feature; @@ -133,9 +137,6 @@ export default class ChangeApplicator implements FeatureSource { // Apply tag changes and ping the consumers const k = kv.k let v = kv.v - if (v === "") { - v = undefined; - } f.properties[k] = v; } diff --git a/Logic/FeatureSource/FeaturePipeline.ts b/Logic/FeatureSource/FeaturePipeline.ts index 6e77e0efd..4e34200a9 100644 --- a/Logic/FeatureSource/FeaturePipeline.ts +++ b/Logic/FeatureSource/FeaturePipeline.ts @@ -43,7 +43,7 @@ export default class FeaturePipeline implements FeatureSource { new FeatureDuplicatorPerLayer(flayers, new RegisteringFeatureSource( new ChangeApplicator( - updater, changes + updater, changes, {generateNewGeometries: true} )) )), layout)); From 248ea78b17c9cf3fa11140e32155c06ed82b1aa9 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Sun, 18 Jul 2021 21:37:14 +0200 Subject: [PATCH 10/16] First working version of split road functionality --- Logic/FeatureSource/ChangeApplicator.ts | 6 +- Logic/FeatureSource/FeaturePipeline.ts | 9 +- Logic/FeatureSource/FilteringFeatureSource.ts | 1 - Logic/Osm/Actions/SplitAction.ts | 97 ++++++++++--------- Logic/Tags/Tag.ts | 7 ++ Models/Constants.ts | 2 +- UI/BigComponents/SimpleAddUI.ts | 7 +- UI/Input/LocationInput.ts | 1 - UI/ShowDataLayer.ts | 15 +-- UI/SpecialVisualizations.ts | 7 +- package.json | 2 +- 11 files changed, 88 insertions(+), 66 deletions(-) diff --git a/Logic/FeatureSource/ChangeApplicator.ts b/Logic/FeatureSource/ChangeApplicator.ts index 5e87d80ff..9b4f2271d 100644 --- a/Logic/FeatureSource/ChangeApplicator.ts +++ b/Logic/FeatureSource/ChangeApplicator.ts @@ -34,7 +34,6 @@ export default class ChangeApplicator implements FeatureSource { runningUpdate = true; changes = changes.filter(ch => !seenChanges.has(ch)) changes.forEach(c => seenChanges.add(c)) - console.log("Called back", changes) ChangeApplicator.ApplyChanges(self.features.data, changes, mode) source.features.ping() runningUpdate = false; @@ -67,6 +66,7 @@ export default class ChangeApplicator implements FeatureSource { const now = new Date() function add(feature) { + feature.id = feature.properties.id features.push({ feature: feature, freshness: now @@ -135,9 +135,7 @@ export default class ChangeApplicator implements FeatureSource { for (const change of changesPerId.get(id)) { for (const kv of change.tags ?? []) { // Apply tag changes and ping the consumers - const k = kv.k - let v = kv.v - f.properties[k] = v; + f.properties[kv.k] = kv.v; } // Apply other changes to the object diff --git a/Logic/FeatureSource/FeaturePipeline.ts b/Logic/FeatureSource/FeaturePipeline.ts index 4e34200a9..a52bcbc8d 100644 --- a/Logic/FeatureSource/FeaturePipeline.ts +++ b/Logic/FeatureSource/FeaturePipeline.ts @@ -43,7 +43,7 @@ export default class FeaturePipeline implements FeatureSource { new FeatureDuplicatorPerLayer(flayers, new RegisteringFeatureSource( new ChangeApplicator( - updater, changes, {generateNewGeometries: true} + updater, changes )) )), layout)); @@ -65,7 +65,12 @@ export default class FeaturePipeline implements FeatureSource { const amendedOsmApiSource = new RememberingSource( new MetaTaggingFeatureSource(allLoadedFeatures, new FeatureDuplicatorPerLayer(flayers, - new RegisteringFeatureSource(new ChangeApplicator(fromOsmApi, changes))))); + new RegisteringFeatureSource(new ChangeApplicator(fromOsmApi, changes, + { + // We lump in the new points here + generateNewGeometries: true + } + ))))); const merged = new FeatureSourceMerger([ diff --git a/Logic/FeatureSource/FilteringFeatureSource.ts b/Logic/FeatureSource/FilteringFeatureSource.ts index 043f2ea3a..ff8883d07 100644 --- a/Logic/FeatureSource/FilteringFeatureSource.ts +++ b/Logic/FeatureSource/FilteringFeatureSource.ts @@ -76,7 +76,6 @@ export default class FilteringFeatureSource implements FeatureSource { return false; }); - console.log("Filtering layer source: input: ", upstream.features.data?.length, "output:", newFeatures.length) self.features.setData(newFeatures); if (missingLayers.size > 0) { console.error("Some layers were not found: ", Array.from(missingLayers)) diff --git a/Logic/Osm/Actions/SplitAction.ts b/Logic/Osm/Actions/SplitAction.ts index 1e9f94ade..8ee1731e4 100644 --- a/Logic/Osm/Actions/SplitAction.ts +++ b/Logic/Osm/Actions/SplitAction.ts @@ -95,8 +95,8 @@ export default class SplitAction extends OsmChangeAction { changeDescription.push({ type: "node", id: element.originalIndex, - changes:{ - lon: element.lngLat[0], + changes: { + lon: element.lngLat[0], lat: element.lngLat[1] } }) @@ -110,31 +110,34 @@ export default class SplitAction extends OsmChangeAction { if (isOriginal) { // We change the actual element! changeDescription.push({ - type:"way", + type: "way", id: originalElement.id, - changes:{ + changes: { locations: wayPart.map(p => p.lngLat), - nodes: wayPart.map(p => p.originalIndex) + nodes: wayPart.map(p => p.originalIndex) } }) } else { let id = changes.getNewID(); newWayIds.push(id) - + const kv = [] for (const k in originalElement.tags) { - if(!originalElement.tags.hasOwnProperty(k)){ + if (!originalElement.tags.hasOwnProperty(k)) { continue } - kv .push({k: k, v: originalElement.tags[k]}) + if (k.startsWith("_") || k === "id") { + continue; + } + kv.push({k: k, v: originalElement.tags[k]}) } changeDescription.push({ - type:"way", - id:id, + type: "way", + id: id, tags: kv, - changes:{ + changes: { locations: wayPart.map(p => p.lngLat), - nodes: wayPart.map(p => p.originalIndex) + nodes: wayPart.map(p => p.originalIndex) } }) } @@ -148,7 +151,7 @@ export default class SplitAction extends OsmChangeAction { // And we have our objects! // Time to upload - return changeDescription + return changeDescription } /** @@ -176,40 +179,40 @@ export default class SplitAction extends OsmChangeAction { // When this is done, we check that no now point is too close to an already existing point and no very small segments get created - /* for (let i = allPoints.length - 1; i > 0; i--) { - - const point = allPoints[i]; - if (point.properties._original_index !== undefined) { - // This point is already in OSM - we have to keep it! - continue; - } - - if (i != allPoints.length - 1) { - const prevPoint = allPoints[i + 1] - const diff = Math.abs(point.properties.location - prevPoint.properties.location) * 1000 - if (diff <= toleranceInM) { - // To close to the previous point! We delete this point... - allPoints.splice(i, 1) - // ... and mark the previous point as a split point - prevPoint.properties._is_split_point = true - continue; - } - } - - if (i > 0) { - const nextPoint = allPoints[i - 1] - const diff = Math.abs(point.properties.location - nextPoint.properties.location) * 1000 - if (diff <= toleranceInM) { - // To close to the next point! We delete this point... - allPoints.splice(i, 1) - // ... and mark the next point as a split point - nextPoint.properties._is_split_point = true - // noinspection UnnecessaryContinueJS - continue; - } - } - // We don't have to remove this point... - }*/ + /* for (let i = allPoints.length - 1; i > 0; i--) { + + const point = allPoints[i]; + if (point.properties._original_index !== undefined) { + // This point is already in OSM - we have to keep it! + continue; + } + + if (i != allPoints.length - 1) { + const prevPoint = allPoints[i + 1] + const diff = Math.abs(point.properties.location - prevPoint.properties.location) * 1000 + if (diff <= toleranceInM) { + // To close to the previous point! We delete this point... + allPoints.splice(i, 1) + // ... and mark the previous point as a split point + prevPoint.properties._is_split_point = true + continue; + } + } + + if (i > 0) { + const nextPoint = allPoints[i - 1] + const diff = Math.abs(point.properties.location - nextPoint.properties.location) * 1000 + if (diff <= toleranceInM) { + // To close to the next point! We delete this point... + allPoints.splice(i, 1) + // ... and mark the next point as a split point + nextPoint.properties._is_split_point = true + // noinspection UnnecessaryContinueJS + continue; + } + } + // We don't have to remove this point... + }*/ const splitInfo: SplitInfo[] = [] let nextId = -1 diff --git a/Logic/Tags/Tag.ts b/Logic/Tags/Tag.ts index c16833ae5..d252010c9 100644 --- a/Logic/Tags/Tag.ts +++ b/Logic/Tags/Tag.ts @@ -25,12 +25,19 @@ export class Tag extends TagsFilter { matchesProperties(properties: any): boolean { for (const propertiesKey in properties) { + if(!properties.hasOwnProperty(propertiesKey)){ + continue + } if (this.key === propertiesKey) { const value = properties[propertiesKey]; + if(value === undefined){ + continue + } return value === this.value; } } // The tag was not found + if (this.value === "") { // and it shouldn't be found! return true; diff --git a/Models/Constants.ts b/Models/Constants.ts index 75423405e..a7218f2bd 100644 --- a/Models/Constants.ts +++ b/Models/Constants.ts @@ -2,7 +2,7 @@ import { Utils } from "../Utils"; export default class Constants { - public static vNumber = "0.8.4-rc3"; + public static vNumber = "0.8.5"; // The user journey states thresholds when a new feature gets unlocked public static userJourney = { diff --git a/UI/BigComponents/SimpleAddUI.ts b/UI/BigComponents/SimpleAddUI.ts index 220585188..62ea506bb 100644 --- a/UI/BigComponents/SimpleAddUI.ts +++ b/UI/BigComponents/SimpleAddUI.ts @@ -76,7 +76,12 @@ export default class SimpleAddUI extends Toggle { State.state.changes.applyAction(newElementAction) selectedPreset.setData(undefined) isShown.setData(false) - Hash.hash.setData(newElementAction.newElementId) + State.state.selectedElement.setData(State.state.allElements.ContainingFeatures.get( + newElementAction.newElementId + )) + console.log("Did set selected element to",State.state.allElements.ContainingFeatures.get( + newElementAction.newElementId + )) }, () => { selectedPreset.setData(undefined) }) diff --git a/UI/Input/LocationInput.ts b/UI/Input/LocationInput.ts index d568e4443..d54af6791 100644 --- a/UI/Input/LocationInput.ts +++ b/UI/Input/LocationInput.ts @@ -42,7 +42,6 @@ export default class LocationInput extends InputElement { } ) map.leafletMap.addCallbackAndRunD(leaflet => { - console.log(leaflet.getBounds(), leaflet.getBounds().pad(0.15)) leaflet.setMaxBounds( leaflet.getBounds().pad(0.15) ) diff --git a/UI/ShowDataLayer.ts b/UI/ShowDataLayer.ts index 36fb62efc..70d7e5203 100644 --- a/UI/ShowDataLayer.ts +++ b/UI/ShowDataLayer.ts @@ -23,7 +23,7 @@ export default class ShowDataLayer { layoutToUse: UIEventSource, enablePopups = true, zoomToFeatures = false, - name?:string) { + name?: string) { this._leafletMap = leafletMap; this._enablePopups = enablePopups; this._features = features; @@ -130,6 +130,7 @@ export default class ShowDataLayer { }) }); } + private postProcessFeature(feature, leafletLayer: L.Layer) { const layer: LayerConfig = this._layerDict[feature._matching_layer_id]; if (layer === undefined) { @@ -157,7 +158,7 @@ export default class ShowDataLayer { leafletLayer.on("popupopen", () => { State.state.selectedElement.setData(feature) - + if (infobox === undefined) { const tags = State.state.allElements.getEventSourceById(feature.properties.id); infobox = new FeatureInfoBox(tags, layer); @@ -172,7 +173,7 @@ export default class ShowDataLayer { infobox.AttachTo(id) - infobox.Activate(); + infobox.Activate(); }); const self = this; State.state.selectedElement.addCallbackAndRunD(selected => { @@ -185,11 +186,13 @@ export default class ShowDataLayer { if (selected.properties.id === feature.properties.id) { // A small sanity check to prevent infinite loops: if (selected.geometry.type === feature.geometry.type // If a feature is rendered both as way and as point, opening one popup might trigger the other to open, which might trigger the one to open again - - && feature.id === feature.properties.id // the feature might have as id 'node/-1' and as 'feature.properties.id' = 'the newly assigned id'. That is no good too - ) { + && feature.id === feature.properties.id // the feature might have as id 'node/-1' and as 'feature.properties.id' = 'the newly assigned id'. That is no good too + ) { leafletLayer.openPopup() } + if(feature.id !== feature.properties.id){ + console.log("Not opening the popup for", feature) + } } }) diff --git a/UI/SpecialVisualizations.ts b/UI/SpecialVisualizations.ts index 1e4864912..8b9475c32 100644 --- a/UI/SpecialVisualizations.ts +++ b/UI/SpecialVisualizations.ts @@ -55,9 +55,12 @@ export default class SpecialVisualizations { if (!tags.hasOwnProperty(key)) { continue; } - parts.push(key + "=" + tags[key]); + parts.push([key , tags[key] ?? "undefined" ]); } - return parts.join("
") + return new Table( + ["key","value"], + parts + ) })).SetStyle("border: 1px solid black; border-radius: 1em;padding:1em;display:block;") }) }, diff --git a/package.json b/package.json index 6f2603450..57e7b8070 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "main": "index.js", "scripts": { "increase-memory": "export NODE_OPTIONS=--max_old_space_size=4096", - "start": "ts-node scripts/generateLayerOverview.ts --no-fail && npm run increase-memory && parcel *.html UI/** Logic/** assets/*.json assets/generated/* assets/layers/*/*.svg assets/tagRendering/*.json assets/themes/*/*.svg assets/themes/*/*.png vendor/* vendor/*/*", + "start": "ts-node scripts/generateLayerOverview.ts --no-fail && npm run increase-memory && parcel *.html UI/** Logic/** assets/*.json assets/svg/* assets/generated/* assets/layers/*/*.svg assets/tagRendering/*.json assets/themes/*/*.svg assets/themes/*/*.png vendor/* vendor/*/*", "test": "ts-node test/TestAll.ts", "init": "npm ci && npm run generate && npm run generate:editor-layer-index && npm run generate:layouts && npm run clean", "add-weblate-upstream": "git remote add weblate-layers https://hosted.weblate.org/git/mapcomplete/layer-translations/ ; git remote update weblate-layers", From ac621b28a7213c3e5e087095a702fa6a939d28a1 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Sun, 18 Jul 2021 21:50:27 +0200 Subject: [PATCH 11/16] Set version to rc --- Models/Constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Models/Constants.ts b/Models/Constants.ts index a7218f2bd..f3a8be661 100644 --- a/Models/Constants.ts +++ b/Models/Constants.ts @@ -2,7 +2,7 @@ import { Utils } from "../Utils"; export default class Constants { - public static vNumber = "0.8.5"; + public static vNumber = "0.8.5-rc1"; // The user journey states thresholds when a new feature gets unlocked public static userJourney = { From 3afc91b3a5c0307a74945140121d91d7210decaa Mon Sep 17 00:00:00 2001 From: Arno Deceuninck Date: Mon, 19 Jul 2021 11:47:17 +0200 Subject: [PATCH 12/16] Small styling fixes --- Svg.ts | 2 +- UI/Popup/SplitRoadWizard.ts | 27 ++++++++-------- assets/svg/license_info.json | 10 ++++++ assets/svg/scissors.svg | 63 +++++++++++++++++++++++++++++++++++- 4 files changed, 87 insertions(+), 15 deletions(-) diff --git a/Svg.ts b/Svg.ts index ee1eb7955..f1d208983 100644 --- a/Svg.ts +++ b/Svg.ts @@ -274,7 +274,7 @@ export default class Svg { public static ring_svg() { return new Img(Svg.ring, true);} public static ring_ui() { return new FixedUiElement(Svg.ring_img);} - public static scissors = "Created by basith ibrahimfrom the Noun Project" + public static scissors = " image/svg+xml" public static scissors_img = Img.AsImageElement(Svg.scissors) public static scissors_svg() { return new Img(Svg.scissors, true);} public static scissors_ui() { return new FixedUiElement(Svg.scissors_img);} diff --git a/UI/Popup/SplitRoadWizard.ts b/UI/Popup/SplitRoadWizard.ts index 962c55a4d..f445263e3 100644 --- a/UI/Popup/SplitRoadWizard.ts +++ b/UI/Popup/SplitRoadWizard.ts @@ -84,10 +84,10 @@ export default class SplitRoadWizard extends Toggle { ) // Only show the splitButton if logged in, else show login prompt - const splitToggle = new Toggle( - splitButton, - t.loginToSplit.Clone().onClick(() => State.state.osmConnection.AttemptLogin()), - State.state.osmConnection.isLoggedIn) + const loginBtn = t.loginToSplit.Clone() + .onClick(() => State.state.osmConnection.AttemptLogin()) + .SetClass("login-button-friendly"); + const splitToggle = new Toggle(splitButton, loginBtn, State.state.osmConnection.isLoggedIn) // Save button const saveButton = new Button(t.split.Clone(), () => { @@ -113,19 +113,20 @@ export default class SplitRoadWizard extends Toggle { }); - saveButton.SetClass("block btn btn-primary"); - const disabledSaveButton = new Button("Split here", undefined); - disabledSaveButton.SetClass("block btn btn-disabled"); + saveButton.SetClass("btn btn-primary mr-3"); + const disabledSaveButton = new Button("Split", undefined); + disabledSaveButton.SetClass("btn btn-disabled mr-3"); // Only show the save button if there are split points defined const saveToggle = new Toggle(disabledSaveButton, saveButton, splitPoints.map((data) => data.length === 0)) - const cancelButton = new Button(Translations.t.general.cancel.Clone(), () => { - splitClicked.setData(false); - splitPoints.setData([]); - splitClicked.setData(false) - }); + const cancelButton = Translations.t.general.cancel.Clone() // Not using Button() element to prevent full width button + .SetClass("btn btn-secondary mr-3") + .onClick(() => { + splitPoints.setData([]); + splitClicked.setData(false); + }); - cancelButton.SetClass("block btn btn-secondary"); + cancelButton.SetClass("btn btn-secondary block"); const splitTitle = new Title(t.splitTitle); diff --git a/assets/svg/license_info.json b/assets/svg/license_info.json index 2d0848c43..4a1b0099d 100644 --- a/assets/svg/license_info.json +++ b/assets/svg/license_info.json @@ -604,5 +604,15 @@ "sources": [ "https://commons.wikimedia.org/wiki/File:Media-floppy.svg" ] + }, + { + "authors": [ + "The noun project - Basith Ibrahi" + ], + "path": "scissors.svg", + "license": "CC-BY 3.0", + "sources": [ + "https://commons.wikimedia.org/wiki/File:Media-floppy.svg" + ] } ] \ No newline at end of file diff --git a/assets/svg/scissors.svg b/assets/svg/scissors.svg index be55cb476..6868fe515 100644 --- a/assets/svg/scissors.svg +++ b/assets/svg/scissors.svg @@ -1 +1,62 @@ -Created by basith ibrahimfrom the Noun Project \ No newline at end of file + +image/svg+xml \ No newline at end of file From 3fbfbfdd6becd9c7875ebd14586932c097e53438 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Thu, 22 Jul 2021 14:25:23 +0200 Subject: [PATCH 13/16] Enable local storage for changes --- Logic/Osm/Changes.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Logic/Osm/Changes.ts b/Logic/Osm/Changes.ts index 4fb0f0892..4595d31a4 100644 --- a/Logic/Osm/Changes.ts +++ b/Logic/Osm/Changes.ts @@ -5,6 +5,7 @@ import Constants from "../../Models/Constants"; import OsmChangeAction from "./Actions/OsmChangeAction"; import {ChangeDescription} from "./Actions/ChangeDescription"; import {Utils} from "../../Utils"; +import {LocalStorageSource} from "../Web/LocalStorageSource"; /** * Handles all changes made to OSM. @@ -20,7 +21,7 @@ export class Changes { */ public features = new UIEventSource<{ feature: any, freshness: Date }[]>([]); - public readonly pendingChanges = new UIEventSource([]) // LocalStorageSource.GetParsed("pending-changes", []) + public readonly pendingChanges = LocalStorageSource.GetParsed("pending-changes", []) private readonly isUploading = new UIEventSource(false); private readonly previouslyCreated : OsmObject[] = [] From 5cc963513a75e34b46656f8612251745d8211bd4 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Mon, 26 Jul 2021 15:03:07 +0200 Subject: [PATCH 14/16] Small optimazations --- Logic/Osm/Changes.ts | 3 ++- Logic/Osm/ChangesetHandler.ts | 3 +++ Logic/UIEventSource.ts | 2 +- Models/Constants.ts | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Logic/Osm/Changes.ts b/Logic/Osm/Changes.ts index 4595d31a4..47d82ec35 100644 --- a/Logic/Osm/Changes.ts +++ b/Logic/Osm/Changes.ts @@ -265,7 +265,7 @@ export class Changes { console.log("No changes to be made") this.pendingChanges.setData([]) this.isUploading.setData(false) - return; + return true; // Unregister the callback } @@ -280,6 +280,7 @@ export class Changes { }, () => self.isUploading.setData(false) // Failed - mark to try again ) + return true; }); diff --git a/Logic/Osm/ChangesetHandler.ts b/Logic/Osm/ChangesetHandler.ts index 8fba43803..8a3845680 100644 --- a/Logic/Osm/ChangesetHandler.ts +++ b/Logic/Osm/ChangesetHandler.ts @@ -53,6 +53,9 @@ export class ChangesetHandler { element.ping(); } + + const newVersion = parseInt(node.attributes.new_id.value); + } } diff --git a/Logic/UIEventSource.ts b/Logic/UIEventSource.ts index 84fa426b6..486d4b6e5 100644 --- a/Logic/UIEventSource.ts +++ b/Logic/UIEventSource.ts @@ -184,7 +184,7 @@ export class UIEventSource { addCallbackAndRunD(callback: (data: T) => void) { this.addCallbackAndRun(data => { if (data !== undefined && data !== null) { - callback(data) + return callback(data) } }) } diff --git a/Models/Constants.ts b/Models/Constants.ts index f3a8be661..7070de141 100644 --- a/Models/Constants.ts +++ b/Models/Constants.ts @@ -2,7 +2,7 @@ import { Utils } from "../Utils"; export default class Constants { - public static vNumber = "0.8.5-rc1"; + public static vNumber = "0.8.5-rc2"; // The user journey states thresholds when a new feature gets unlocked public static userJourney = { From 4f2881269a65fd810481221cd7b2ce946ca7e29f Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Mon, 26 Jul 2021 16:25:57 +0200 Subject: [PATCH 15/16] Remove obsolete console.log --- UI/Popup/TagRenderingQuestion.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/UI/Popup/TagRenderingQuestion.ts b/UI/Popup/TagRenderingQuestion.ts index 2a6e5c939..eb6312f7a 100644 --- a/UI/Popup/TagRenderingQuestion.ts +++ b/UI/Popup/TagRenderingQuestion.ts @@ -198,9 +198,7 @@ export default class TagRenderingQuestion extends Combine { oppositeTags.push(notSelected); } tags.push(TagUtils.FlattenMultiAnswer(oppositeTags)); - const actualTags = TagUtils.FlattenMultiAnswer(tags); - console.log("Converted ", indices.join(","), "into", actualTags.asHumanString(false, false, {}), "with elems", elements) - return actualTags; + return TagUtils.FlattenMultiAnswer(tags); }, (tags: TagsFilter) => { // {key --> values[]} From 1be8e6e1d13cee145c5c7f6eeb5b943a5e188c6b Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Mon, 26 Jul 2021 16:51:57 +0200 Subject: [PATCH 16/16] Attempt to fix empty changes --- Logic/Osm/Changes.ts | 9 ++++++--- Logic/Osm/ChangesetHandler.ts | 1 - UI/ShowDataLayer.ts | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Logic/Osm/Changes.ts b/Logic/Osm/Changes.ts index 47d82ec35..806af3ba7 100644 --- a/Logic/Osm/Changes.ts +++ b/Logic/Osm/Changes.ts @@ -263,8 +263,8 @@ export class Changes { } = self.CreateChangesetObjects(pending, osmObjects) if (changes.newObjects.length + changes.deletedObjects.length + changes.modifiedObjects.length === 0) { console.log("No changes to be made") - this.pendingChanges.setData([]) - this.isUploading.setData(false) + self.pendingChanges.setData([]) + self.isUploading.setData(false) return true; // Unregister the callback } @@ -278,7 +278,10 @@ export class Changes { self.pendingChanges.setData([]); self.isUploading.setData(false) }, - () => self.isUploading.setData(false) // Failed - mark to try again + () => { + console.log("Upload failed - trying again later") + return self.isUploading.setData(false); + } // Failed - mark to try again ) return true; diff --git a/Logic/Osm/ChangesetHandler.ts b/Logic/Osm/ChangesetHandler.ts index 8a3845680..019d0efb2 100644 --- a/Logic/Osm/ChangesetHandler.ts +++ b/Logic/Osm/ChangesetHandler.ts @@ -54,7 +54,6 @@ export class ChangesetHandler { } - const newVersion = parseInt(node.attributes.new_id.value); } } diff --git a/UI/ShowDataLayer.ts b/UI/ShowDataLayer.ts index b8809800c..335826628 100644 --- a/UI/ShowDataLayer.ts +++ b/UI/ShowDataLayer.ts @@ -193,7 +193,7 @@ export default class ShowDataLayer { leafletLayer.openPopup() } if(feature.id !== feature.properties.id){ - console.log("Not opening the popup for", feature) + console.trace("Not opening the popup for", feature) } }