diff --git a/README.md b/README.md index 24fb20280..f46539d61 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,10 @@ Vespucci utilizes a number of independent, separately maintained, projects. The * [OpeningHoursFragment](https://github.com/simonpoole/OpeningHoursFragment) opening hours user interface * [Name Suggestion Index](https://github.com/osmlab/name-suggestion-index) name/brand-related tag suggestions database * [iD tagging schema](https://github.com/openstreetmap/id-tagging-schema) for synonyms used for searching presets +* [geocontext](https://github.com/simonpoole/geocontext) country/region specific speed limits and similar +* [osm-area-tags](https://github.com/simonpoole/osm-area-tags) OSM tags that imply area semantics + +You can update both the imagery and preset configuration from within the app, the other configuration files are updated when necessary in the APK. ## License and trademarks diff --git a/build.gradle b/build.gradle index bb1a73326..b95b3d00d 100644 --- a/build.gradle +++ b/build.gradle @@ -138,6 +138,14 @@ task updateGeocontext(type: Download) { updateGeocontext.group = 'vespucci' updateGeocontext.description = 'Update the geocontext configuration' +task updateAreaTags(type: Download) { + acceptAnyCertificate true + src 'https://raw.githubusercontent.com/simonpoole/osm-area-tags/master/area-tags.json' + dest new File(projectDir.getPath() + '/src/main/assets/area-tags.json') +} +updateAreaTags.group = 'vespucci' +updateAreaTags.description = 'Update the area tags configuration' + task updateNameSuggestionIndex(type: Download) { acceptAnyCertificate true src 'https://raw.githubusercontent.com/osmlab/name-suggestion-index/main/dist/nsi.min.json' diff --git a/documentation/docs/help/en/Multiselect.md b/documentation/docs/help/en/Multiselect.md index 057d88c33..96952e466 100644 --- a/documentation/docs/help/en/Multiselect.md +++ b/documentation/docs/help/en/Multiselect.md @@ -32,7 +32,13 @@ Remove the objects from the data. Merge multiple selected ways resulting in a single way. Ways will be reversed if necessary. This option will only be available if only ways with common start/end nodes are selected, or the selection is two closed ways (polygons), in the later case if the polygons do not have common nodes a multi-polygon relation will be created and the ways added as members. If post-merge tag conflicts are detected you will be alerted. -### Add node at intersection +### Extract segment + +If you have selected exactly two nodes on the same way, you can extract the segment of the way between the two nodes. If the way is closed the segment extracted will between the first and 2nd node selected in the winding direction (clockwise or counterclockwise) of the way. + +If the way has _highway_ or _waterway_ tagging a number of shortcuts will be displayed, for example to change a _footway_ in to _steps_. + +### Add node at intersectionn If two or more ways are selected and they intersect without a common node, a new node will be added at the first intersection found. diff --git a/src/androidTest/java/de/blau/android/easyedit/ExtendedSelectionTest.java b/src/androidTest/java/de/blau/android/easyedit/ExtendedSelectionTest.java index 76af84c6a..111aad266 100644 --- a/src/androidTest/java/de/blau/android/easyedit/ExtendedSelectionTest.java +++ b/src/androidTest/java/de/blau/android/easyedit/ExtendedSelectionTest.java @@ -2,9 +2,9 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -271,4 +271,37 @@ public void undoInsertion() { TestUtils.clickText(device, false, context.getString(R.string.okay), true); // click away tip assertTrue(TestUtils.findText(device, false, context.getResources().getQuantityString(R.plurals.actionmode_object_count, 1, 2))); } + + /** + * Select node, select 2nd node, extract segment + */ + @Test + public void extractSegment() { + TestUtils.loadTestData(main, "test2.osm"); + TestUtils.zoomToLevel(device, main, 20); // if we are zoomed in too far we might not get the selection popups + map.getDataLayer().setVisible(true); + TestUtils.unlock(device); + TestUtils.sleep(2000); + TestUtils.clickAtCoordinates(device, map, 8.3894224, 47.3891963, true); + TestUtils.clickText(device, true, context.getString(R.string.okay), true, false); // Tip + assertTrue(TestUtils.findText(device, false, context.getString(R.string.actionmode_nodeselect))); + assertTrue(TestUtils.clickOverflowButton(device)); + assertTrue(TestUtils.clickText(device, false, context.getString(R.string.menu_extend_selection), true, false)); + assertTrue(TestUtils.findText(device, false, context.getString(R.string.actionmode_multiselect))); + TestUtils.clickAtCoordinates(device, map, 8.389856, 47.3891991, true); + + assertTrue(TestUtils.findText(device, false, context.getResources().getQuantityString(R.plurals.actionmode_object_count, 2, 2), 5000)); + List nodes = new ArrayList<>(App.getLogic().getSelectedNodes()); + assertTrue(TestUtils.clickOverflowButton(device)); + assertTrue(TestUtils.clickText(device, false, context.getString(R.string.menu_extract_segment), true, false)); + + assertTrue(TestUtils.findText(device, false, context.getString(R.string.actionmode_wayselect))); + + Way way = App.getLogic().getSelectedWay(); + final List wayNodes = way.getNodes(); + assertEquals(2, wayNodes.size()); + assertTrue(wayNodes.contains(nodes.get(0))); + assertTrue(wayNodes.contains(nodes.get(1))); + } + } diff --git a/src/androidTest/java/de/blau/android/easyedit/RelationTest.java b/src/androidTest/java/de/blau/android/easyedit/RelationTest.java index 141279b0b..522c41381 100644 --- a/src/androidTest/java/de/blau/android/easyedit/RelationTest.java +++ b/src/androidTest/java/de/blau/android/easyedit/RelationTest.java @@ -197,6 +197,10 @@ public void rotateMultipolygon() { TestUtils.clickAtCoordinates(device, map, 8.3881251, 47.3885077, true); assertTrue(TestUtils.findText(device, false, context.getString(R.string.actionmode_closed_way_split_2))); TestUtils.clickAtCoordinates(device, map, 8.3881577, 47.3886924, true); + // click away issue + assertTrue(TestUtils.findText(device, false, context.getString(R.string.tag_conflict_title))); + assertTrue(TestUtils.clickText(device, false, context.getString(R.string.Done), true, false)); + assertTrue(TestUtils.findText(device, false, context.getString(R.string.actionmode_multiselect))); assertTrue(TestUtils.clickOverflowButton(device)); @@ -264,6 +268,11 @@ public void createAndAddToMultiPolygon() { TestUtils.clickAtCoordinates(device, map, 8.3881251, 47.3885077, true); assertTrue(TestUtils.findText(device, false, context.getString(R.string.actionmode_closed_way_split_2))); TestUtils.clickAtCoordinates(device, map, 8.3881577, 47.3886924, true); + + // click away issue + assertTrue(TestUtils.findText(device, false, context.getString(R.string.tag_conflict_title))); + assertTrue(TestUtils.clickText(device, false, context.getString(R.string.Done), true, false)); + assertTrue(TestUtils.findText(device, false, context.getString(R.string.actionmode_multiselect))); TestUtils.clickUp(device); // diff --git a/src/androidTest/java/de/blau/android/osm/GeometryEditsTest.java b/src/androidTest/java/de/blau/android/osm/GeometryEditsTest.java index a8887461a..895c86224 100644 --- a/src/androidTest/java/de/blau/android/osm/GeometryEditsTest.java +++ b/src/androidTest/java/de/blau/android/osm/GeometryEditsTest.java @@ -324,10 +324,10 @@ public void closedWaySplit() { final Node n5 = nList1.get(4); assertEquals(n1, n5); assertTrue(w1.isClosed()); - Way[] ways = logic.performClosedWaySplit(main, w1, n2, n4, false); - assertEquals(2, ways.length); - assertEquals(3, ways[0].getNodes().size()); - assertEquals(3, ways[1].getNodes().size()); + List results = logic.performClosedWaySplit(main, w1, n2, n4, false); + assertEquals(2, results.size()); + assertEquals(3, ((Way) results.get(0).getElement()).getNodes().size()); + assertEquals(3, ((Way) results.get(1).getElement()).getNodes().size()); } catch (Exception igit) { fail(igit.getMessage()); } @@ -361,12 +361,14 @@ public void closedWaySplitToPolygons() { final Node n5 = nList1.get(4); assertEquals(n1, n5); assertTrue(w1.isClosed()); - Way[] ways = logic.performClosedWaySplit(main, w1, n1, n3, true); - assertEquals(2, ways.length); - assertEquals(4, ways[0].getNodes().size()); - assertTrue(ways[0].isClosed()); - assertEquals(4, ways[1].getNodes().size()); - assertTrue(ways[1].isClosed()); + List results = logic.performClosedWaySplit(main, w1, n1, n3, true); + assertEquals(2, results.size()); + final Way way0 = (Way) results.get(0).getElement(); + assertEquals(4, way0.getNodes().size()); + assertTrue(way0.isClosed()); + final Way way1 = (Way) results.get(1).getElement(); + assertEquals(4, way1.getNodes().size()); + assertTrue(way1.isClosed()); } catch (Exception igit) { fail(igit.getMessage()); } diff --git a/src/main/assets/area-tags.json b/src/main/assets/area-tags.json new file mode 100644 index 000000000..d2492b659 --- /dev/null +++ b/src/main/assets/area-tags.json @@ -0,0 +1,289 @@ +{ + "areaKeys": { + "advertising": { + "default": true, + "values": { + "billboard": false + } + }, + "aerialway": { + "default": true, + "values": { + "cable_car": false, + "chair_lift": false, + "drag_lift": false, + "gondola": false, + "goods": false, + "j-bar": false, + "magic_carpet": false, + "mixed_lift": false, + "platter": false, + "rope_tow": false, + "t-bar": false, + "zip_line": false + } + }, + "aeroway": { + "default": true, + "values": { + "jet_bridge": false, + "parking_position": false, + "runway": false, + "taxiway": false + } + }, + "allotments": { + "default": true + }, + "amenity": { + "default": true, + "values": { + "bench": false, + "weighbridge": false + } + }, + "area:highway": { + "default": true + }, + "attraction": { + "default": true, + "values": { + "dark_ride": false, + "river_rafting": false, + "summer_toboggan": false, + "train": false, + "water_slide": false + } + }, + "bridge:support": { + "default": true + }, + "building": { + "default": true + }, + "building:part": { + "default": true + }, + "cemetery": { + "default": true + }, + "club": { + "default": true + }, + "craft": { + "default": true + }, + "demolished:building": { + "default": true + }, + "disused:amenity": { + "default": true + }, + "disused:railway": { + "default": true + }, + "disused:shop": { + "default": true + }, + "emergency": { + "default": true, + "values": { + "designated": false, + "destination": false, + "no": false, + "official": false, + "private": false, + "yes": false + } + }, + "golf": { + "default": true, + "values": { + "cartpath": false, + "hole": false, + "path": false + } + }, + "healthcare": { + "default": true + }, + "highway": { + "default": false, + "values": { + "elevator": true, + "rest_area": true, + "services": true + } + }, + "historic": { + "default": true + }, + "indoor": { + "default": true, + "values": { + "wall": false + } + }, + "industrial": { + "default": true + }, + "internet_access": { + "default": true + }, + "junction": { + "default": true + }, + "landuse": { + "default": true + }, + "leisure": { + "default": true, + "values": { + "slipway": false, + "track": false + } + }, + "man_made": { + "default": true, + "values": { + "yes": false, + "breakwater": false, + "carpet_hanger": false, + "crane": false, + "cutline": false, + "dyke": false, + "embankment": false, + "goods_conveyor": false, + "groyne": false, + "pier": false, + "pipeline": false, + "torii": false, + "video_wall": false + } + }, + "military": { + "default": true, + "values": { + "trench": false + } + }, + "natural": { + "default": true, + "values": { + "bay": false, + "cliff": false, + "coastline": false, + "ridge": false, + "strait": false, + "tree_row": false, + "valley": false + } + }, + "office": { + "default": true + }, + "piste:type": { + "default": true, + "values": { + "downhill": false, + "hike": false, + "ice_skate": false, + "nordic": false, + "skitour": false, + "sled": false, + "sleigh": false + } + }, + "place": { + "default": true + }, + "playground": { + "default": true, + "values": { + "activitypanel": false, + "balancebeam": false, + "basketswing": false, + "bridge": false, + "climbingwall": false, + "hopscotch": false, + "horizontal_bar": false, + "seesaw": false, + "slide": false, + "structure": false, + "swing": false, + "tunnel_tube": false, + "water": false, + "zipwire": false + } + }, + "police": { + "default": true + }, + "polling_station": { + "default": true + }, + "power": { + "default": true, + "values": { + "cable": false, + "line": false, + "minor_line": false + } + }, + "public_transport": { + "default": false, + "values": { + "platform": true + } + }, + "railway": { + "default": false, + "values": { + "platform": true, + "roundhouse": true, + "station": true, + "traverser": true, + "turntable": true, + "wash": true, + "ventilation_shaft": true + } + }, + "seamark:type": { + "default": true + }, + "shop": { + "default": true + }, + "telecom": { + "default": true + }, + "tourism": { + "default": true, + "values": { + "artwork": false, + "attraction": false + } + }, + "traffic_calming": { + "default": true, + "values": { + "yes": false, + "bump": false, + "chicane": false, + "choker": false, + "cushion": false, + "dip": false, + "hump": false, + "island": false, + "mini_bumps": false, + "rumble_strip": false + } + }, + "waterway": { + "default": false, + "values": { + "dam": true + } + } + } +} diff --git a/src/main/assets/help/en/Multiselect.html b/src/main/assets/help/en/Multiselect.html index c93e766a1..c631aa21d 100644 --- a/src/main/assets/help/en/Multiselect.html +++ b/src/main/assets/help/en/Multiselect.html @@ -22,6 +22,9 @@

Delete Delete

Remove the objects from the data.

Merge Merge ways

Merge multiple selected ways resulting in a single way. Ways will be reversed if necessary. This option will only be available if only ways with common start/end nodes are selected, or the selection is two closed ways (polygons), in the later case if the polygons do not have common nodes a multi-polygon relation will be created and the ways added as members. If post-merge tag conflicts are detected you will be alerted.

+

Extract segment

+

If you have selected exactly two nodes on the same way, you can extract the segment of the way between the two nodes. If the way is closed the segment extracted will between the first and 2nd node selected in the winding direction (clockwise or counterclockwise) of the way.

+

If the way has highway or waterway tagging a number of shortcuts will be displayed, for example to change a footway in to steps.

Add node at intersection

If two or more ways are selected and they intersect without a common node, a new node will be added at the first intersection found.

Create circle

diff --git a/src/main/java/de/blau/android/App.java b/src/main/java/de/blau/android/App.java index cca4b9ade..d818e67e0 100644 --- a/src/main/java/de/blau/android/App.java +++ b/src/main/java/de/blau/android/App.java @@ -57,6 +57,7 @@ import de.blau.android.resources.DataStyle; import de.blau.android.services.util.MapTileFilesystemProvider; import de.blau.android.tasks.TaskStorage; +import de.blau.android.util.AreaTags; import de.blau.android.util.FileUtil; import de.blau.android.util.GeoContext; import de.blau.android.util.NotificationCache; @@ -187,6 +188,12 @@ public class App extends Application implements android.app.Application.Activity private static DataStyle dataStyle; private static final Object dataStyleLock = new Object(); + /** + * Implied area tags + */ + private static AreaTags areaTags; + private static final Object areaTagsLock = new Object(); + private static Configuration configuration = null; private static boolean propertyEditorRunning; @@ -784,6 +791,22 @@ public static DataStyle getDataStyle(@NonNull Context ctx) { } } + /** + * Get the AreaTags object + * + * @param ctx am Android Context + * @return an AreaTags object + */ + @NonNull + public static AreaTags getAreaTags(@NonNull Context ctx) { + synchronized (areaTagsLock) { + if (areaTags == null) { + areaTags = new AreaTags(ctx); + } + return areaTags; + } + } + /** * Get the userAgent string for this version of the app * diff --git a/src/main/java/de/blau/android/Logic.java b/src/main/java/de/blau/android/Logic.java index 6f7a2fcbb..5fd9c8e4d 100644 --- a/src/main/java/de/blau/android/Logic.java +++ b/src/main/java/de/blau/android/Logic.java @@ -101,6 +101,7 @@ import de.blau.android.osm.ReplaceIssue; import de.blau.android.osm.Result; import de.blau.android.osm.Server; +import de.blau.android.osm.SplitIssue; import de.blau.android.osm.Storage; import de.blau.android.osm.StorageDelegator; import de.blau.android.osm.Tags; @@ -2144,19 +2145,23 @@ public synchronized List performSplit(@Nullable final FragmentActivity a * @param node1 first split point * @param node2 second split point * @param createPolygons create polygons by closing the split ways if true - * @return null if the split fails, the two ways otherwise + * @return a List of Result objects containing the original Way in the 1st element and the new Way in the 2ndand any + * issues * @throws OsmIllegalOperationException if the operation failed * @throws StorageException if we ran out of memory */ @NonNull - public synchronized Way[] performClosedWaySplit(@Nullable FragmentActivity activity, @NonNull Way way, @NonNull Node node1, @NonNull Node node2, + public synchronized List performClosedWaySplit(@Nullable FragmentActivity activity, @NonNull Way way, @NonNull Node node1, @NonNull Node node2, boolean createPolygons) { createCheckpoint(activity, R.string.undo_action_split_way); try { displayAttachedObjectWarning(activity, way); - Way[] result = getDelegator().splitAtNodes(way, node1, node2, createPolygons); + List results = getDelegator().splitAtNodes(way, node1, node2, createPolygons); + if (!createPolygons) { + checkForArea(activity, way, results); + } invalidateMap(); - return result; + return results; } catch (OsmIllegalOperationException | StorageException ex) { handleDelegatorException(activity, ex); throw ex; // rethrow @@ -2167,10 +2172,10 @@ public synchronized Way[] performClosedWaySplit(@Nullable FragmentActivity activ * Extract a segment from a way (the way between two nodes of the same way) * * @param activity activity we were called fron - * @param way Unclosed Way to split + * @param way Way to split * @param node1 first split point * @param node2 second split point - * @return null if the split fails, the segment otherwise + * @return the segment in the 1st Result if successful, otherwise the results contain issues */ @NonNull public synchronized List performExtractSegment(@Nullable FragmentActivity activity, @NonNull Way way, @NonNull Node node1, @NonNull Node node2) { @@ -2178,15 +2183,18 @@ public synchronized List performExtractSegment(@Nullable FragmentActivit try { displayAttachedObjectWarning(activity, way); List result = null; - if (way.isEndNode(node1)) { + if (way.isClosed()) { + // extracted segment is in the 2nd result + result = getDelegator().splitAtNodes(way, node1, node2, false); + result = result.subList(1, result.size()); + checkForArea(activity, way, result); + return result; + } else if (way.isEndNode(node1)) { result = extractSegmentAtEnd(way, node1, node2); } else if (way.isEndNode(node2)) { result = extractSegmentAtEnd(way, node2, node1); } else { result = getDelegator().splitAtNode(way, node1, true); - if (result.isEmpty()) { - throw new OsmIllegalOperationException("Splitting way " + way.getOsmId() + " at node " + node1.getOsmId() + " failed"); - } Result first = result.get(0); boolean splitOriginal = way.hasNode(node2); Way newWay = (Way) first.getElement(); @@ -2202,6 +2210,19 @@ public synchronized List performExtractSegment(@Nullable FragmentActivit } } + /** + * If Way has implied area semantics or an explicit area=yes, add an issue to the result + * + * @param activity an Activity + * @param way the Way + * @param results a list of Result + */ + private void checkForArea(@Nullable FragmentActivity activity, @NonNull Way way, @NonNull List results) { + if (way.hasTag(Tags.KEY_AREA, Tags.VALUE_YES) || ( activity != null && App.getAreaTags(activity).isImpliedArea(way.getTags()))) { + results.get(0).addIssue(SplitIssue.SPLIT_AREA); + } + } + /** * Extract a segment at the end of a way * diff --git a/src/main/java/de/blau/android/easyedit/ClosedWaySplittingActionModeCallback.java b/src/main/java/de/blau/android/easyedit/ClosedWaySplittingActionModeCallback.java index c53a43f2e..a86d95cd9 100644 --- a/src/main/java/de/blau/android/easyedit/ClosedWaySplittingActionModeCallback.java +++ b/src/main/java/de/blau/android/easyedit/ClosedWaySplittingActionModeCallback.java @@ -7,10 +7,12 @@ import android.util.Log; import androidx.annotation.NonNull; +import de.blau.android.dialogs.ElementIssueDialog; import de.blau.android.exception.OsmIllegalOperationException; import de.blau.android.exception.StorageException; import de.blau.android.osm.Node; import de.blau.android.osm.OsmElement; +import de.blau.android.osm.Result; import de.blau.android.osm.Way; import de.blau.android.util.SerializableState; @@ -88,17 +90,24 @@ public boolean handleElementClick(OsmElement element) { // NOSONAR super.handleElementClick(element); try { if (element instanceof Node) { - Way[] result = logic.performClosedWaySplit(main, way, node, (Node) element, createPolygons); - if (result.length == 2) { - logic.setSelectedNode(null); - logic.setSelectedRelation(null); - logic.setSelectedWay(result[0]); - logic.addSelectedWay(result[1]); - List selection = new ArrayList<>(); - selection.addAll(logic.getSelectedWays()); - main.startSupportActionMode(new MultiSelectWithGeometryActionModeCallback(manager, selection)); - return true; + List results = logic.performClosedWaySplit(main, way, node, (Node) element, createPolygons); + logic.setSelectedNode(null); + logic.setSelectedRelation(null); + logic.setSelectedWay((Way) results.get(0).getElement()); + logic.addSelectedWay((Way) results.get(1).getElement()); + List selection = new ArrayList<>(); + selection.addAll(logic.getSelectedWays()); + main.startSupportActionMode(new MultiSelectWithGeometryActionModeCallback(manager, selection)); + List resultsWithIssue = new ArrayList<>(); + for (Result r : results) { + if (r.hasIssue()) { + resultsWithIssue.add(r); + } } + if (!resultsWithIssue.isEmpty()) { + ElementIssueDialog.showTagConflictDialog(main, resultsWithIssue); + } + return true; } } catch (OsmIllegalOperationException | StorageException ex) { // toast has already been displayed diff --git a/src/main/java/de/blau/android/easyedit/EasyEditActionModeCallback.java b/src/main/java/de/blau/android/easyedit/EasyEditActionModeCallback.java index e40b6aa1e..fe31d3df1 100644 --- a/src/main/java/de/blau/android/easyedit/EasyEditActionModeCallback.java +++ b/src/main/java/de/blau/android/easyedit/EasyEditActionModeCallback.java @@ -34,6 +34,8 @@ import de.blau.android.R; import de.blau.android.dialogs.ElementIssueDialog; import de.blau.android.dialogs.ErrorAlert; +import de.blau.android.exception.OsmIllegalOperationException; +import de.blau.android.exception.StorageException; import de.blau.android.osm.Node; import de.blau.android.osm.OsmElement; import de.blau.android.osm.RelationUtils; @@ -553,6 +555,45 @@ public void onError(@Nullable AsyncResult result) { } } + /** + * Extract a segment(s) from a List of Way given two nodes on all the Ways + * + * @param ways a List of Ways + * @param n1 1st Node + * @param n2 2nd Node + * @return a Runnable that will actually do the work + */ + @NonNull + protected Runnable extractSegment(@NonNull final List ways, @NonNull final Node n1, @NonNull final Node n2) { + return () -> { + try { + List segments = new ArrayList<>(); + for (Way way : ways) { + List result = logic.performExtractSegment(main, way, n1, n2); + checkSplitResult(way, result); + Way segment = newWayFromSplitResult(result); + if (segment == null) { + throw new OsmIllegalOperationException("null segment"); + } + segments.add(segment); + } + if (segments.size() == 1) { + Way segment = (Way) segments.get(0); + if (segment.hasTagKey(Tags.KEY_HIGHWAY) || segment.hasTagKey(Tags.KEY_WATERWAY)) { + main.startSupportActionMode(new WaySegmentModifyActionModeCallback(manager, segment)); + } else { + main.startSupportActionMode(new WaySelectionActionModeCallback(manager, segment)); + } + } else { + main.startSupportActionMode(new MultiSelectActionModeCallback(manager, segments)); + } + } catch (OsmIllegalOperationException | StorageException ex) { + // toast has already been displayed + manager.finish(); + } + }; + } + /** * De-select elements * diff --git a/src/main/java/de/blau/android/easyedit/MultiSelectActionModeCallback.java b/src/main/java/de/blau/android/easyedit/MultiSelectActionModeCallback.java index 891173d3e..373e5fc52 100644 --- a/src/main/java/de/blau/android/easyedit/MultiSelectActionModeCallback.java +++ b/src/main/java/de/blau/android/easyedit/MultiSelectActionModeCallback.java @@ -222,39 +222,40 @@ public boolean elementsOnly() { @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { - if (!super.onActionItemClicked(mode, item)) { - switch (item.getItemId()) { - case ElementSelectionActionModeCallback.MENUITEM_TAG: - main.performTagEdit(selection, false, false); - break; - case MENUITEM_ZOOM_TO_SELECTION: - main.zoomTo(selection); - main.invalidateMap(); - break; - case MENUITEM_SEARCH_OBJECTS: - Search.search(main); - break; - case MENUITEM_ADD_TO_TODO: - ElementSelectionActionModeCallback.addToTodoList(main, manager, selection); - break; - case MENUITEM_UPLOAD: - main.descheduleAutoLock(); - main.confirmUpload(ElementSelectionActionModeCallback.addRequiredElements(main, new ArrayList<>(selection))); - break; - case ElementSelectionActionModeCallback.MENUITEM_PREFERENCES: - PrefEditor.start(main); - break; - case ElementSelectionActionModeCallback.MENUITEM_JS_CONSOLE: - Main.showJsConsole(main); - break; - case R.id.undo_action: - // should not happen - Log.d(DEBUG_TAG, "menu undo clicked"); - undoListener.onClick(null); - break; - default: - return false; - } + if (super.onActionItemClicked(mode, item)) { + return true; + } + switch (item.getItemId()) { + case ElementSelectionActionModeCallback.MENUITEM_TAG: + main.performTagEdit(selection, false, false); + break; + case MENUITEM_ZOOM_TO_SELECTION: + main.zoomTo(selection); + main.invalidateMap(); + break; + case MENUITEM_SEARCH_OBJECTS: + Search.search(main); + break; + case MENUITEM_ADD_TO_TODO: + ElementSelectionActionModeCallback.addToTodoList(main, manager, selection); + break; + case MENUITEM_UPLOAD: + main.descheduleAutoLock(); + main.confirmUpload(ElementSelectionActionModeCallback.addRequiredElements(main, new ArrayList<>(selection))); + break; + case ElementSelectionActionModeCallback.MENUITEM_PREFERENCES: + PrefEditor.start(main); + break; + case ElementSelectionActionModeCallback.MENUITEM_JS_CONSOLE: + Main.showJsConsole(main); + break; + case R.id.undo_action: + // should not happen + Log.d(DEBUG_TAG, "menu undo clicked"); + undoListener.onClick(null); + break; + default: + return false; } return true; } diff --git a/src/main/java/de/blau/android/easyedit/MultiSelectWithGeometryActionModeCallback.java b/src/main/java/de/blau/android/easyedit/MultiSelectWithGeometryActionModeCallback.java index f96ec9aaf..d36eb98d1 100644 --- a/src/main/java/de/blau/android/easyedit/MultiSelectWithGeometryActionModeCallback.java +++ b/src/main/java/de/blau/android/easyedit/MultiSelectWithGeometryActionModeCallback.java @@ -14,8 +14,10 @@ import androidx.appcompat.app.AlertDialog; import androidx.appcompat.view.ActionMode; import de.blau.android.App; +import de.blau.android.DisambiguationMenu; import de.blau.android.Map; import de.blau.android.R; +import de.blau.android.DisambiguationMenu.Type; import de.blau.android.dialogs.ElementIssueDialog; import de.blau.android.exception.OsmIllegalOperationException; import de.blau.android.osm.Node; @@ -23,6 +25,7 @@ import de.blau.android.osm.OsmElement.ElementType; import de.blau.android.osm.Relation; import de.blau.android.osm.Result; +import de.blau.android.osm.Storage; import de.blau.android.osm.StorageDelegator; import de.blau.android.osm.Tags; import de.blau.android.osm.Way; @@ -46,11 +49,13 @@ public class MultiSelectWithGeometryActionModeCallback extends MultiSelectAction private static final int MENUITEM_INTERSECT = ElementSelectionActionModeCallback.LAST_REGULAR_MENUITEM + 5; private static final int MENUITEM_CREATE_CIRCLE = ElementSelectionActionModeCallback.LAST_REGULAR_MENUITEM + 6; private static final int MENUITEM_ROTATE = ElementSelectionActionModeCallback.LAST_REGULAR_MENUITEM + 7; + private static final int MENUITEM_EXTRACT_SEGMENT = ElementSelectionActionModeCallback.LAST_REGULAR_MENUITEM + 8; private MenuItem mergeItem; private MenuItem orthogonalizeItem; private MenuItem intersectItem; private MenuItem createCircleItem; + private MenuItem extractSegmentItem; /** * Construct an Multi-Select actionmode from a List of OsmElements @@ -87,6 +92,8 @@ public boolean onCreateActionMode(@NonNull ActionMode mode, @NonNull Menu menu) mergeItem = menu.add(Menu.NONE, MENUITEM_MERGE, Menu.NONE, R.string.menu_merge).setIcon(ThemeUtils.getResIdFromAttribute(main, R.attr.menu_merge)); + extractSegmentItem = menu.add(Menu.NONE, MENUITEM_EXTRACT_SEGMENT, Menu.NONE, R.string.menu_extract_segment); + menu.add(Menu.NONE, MENUITEM_RELATION, Menu.CATEGORY_SYSTEM, R.string.menu_relation) .setIcon(ThemeUtils.getResIdFromAttribute(main, R.attr.menu_relation)); @@ -123,6 +130,11 @@ public boolean onPrepareActionMode(@NonNull ActionMode mode, @NonNull Menu menu) updated |= ElementSelectionActionModeCallback.setItemVisibility(countType(ElementType.NODE) >= StorageDelegator.MIN_NODES_CIRCLE, createCircleItem, false); + if (selection.size() == 2 && selection.get(0) instanceof Node && selection.get(1) instanceof Node) { + List commonWays = getWaysForNodes((Node) selection.get(0), (Node) selection.get(1)); + updated |= ElementSelectionActionModeCallback.setItemVisibility(!commonWays.isEmpty(), extractSegmentItem, true); + } + if (updated) { arrangeMenu(menu); } @@ -176,60 +188,96 @@ private boolean canMergePolygons(@NonNull List selection) { @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { - if (!super.onActionItemClicked(mode, item)) { - switch (item.getItemId()) { - case ElementSelectionActionModeCallback.MENUITEM_DELETE: - menuDelete(false); - break; - case ElementSelectionActionModeCallback.MENUITEM_COPY: - logic.copyToClipboard(selection); - mode.finish(); - break; - case ElementSelectionActionModeCallback.MENUITEM_CUT: - logic.cutToClipboard(main, selection); - mode.finish(); - break; - case MENUITEM_RELATION: - ElementSelectionActionModeCallback.buildPresetSelectDialog(main, - p -> main.startSupportActionMode(new EditRelationMembersActionModeCallback(manager, - p != null ? p.getPath(App.getCurrentRootPreset(main).getRootGroup()) : null, selection)), - ElementType.RELATION, R.string.select_relation_type_title, Tags.KEY_TYPE, null).show(); - break; - case MENUITEM_ADD_RELATION_MEMBERS: - ElementSelectionActionModeCallback.buildRelationSelectDialog(main, r -> { - Relation relation = (Relation) App.getDelegator().getOsmElement(Relation.NAME, r); - if (relation != null) { - main.startSupportActionMode(new EditRelationMembersActionModeCallback(manager, relation, selection)); - } - }, -1, R.string.select_relation_title, null, null, selection).show(); - break; - case MENUITEM_ORTHOGONALIZE: - orthogonalizeWays(); - break; - case MENUITEM_MERGE: - if (canMergePolygons(selection)) { - mergePolygons(); - } else { - mergeWays(); + if (super.onActionItemClicked(mode, item)) { + return true; + } + switch (item.getItemId()) { + case ElementSelectionActionModeCallback.MENUITEM_DELETE: + menuDelete(false); + break; + case ElementSelectionActionModeCallback.MENUITEM_COPY: + logic.copyToClipboard(selection); + mode.finish(); + break; + case ElementSelectionActionModeCallback.MENUITEM_CUT: + logic.cutToClipboard(main, selection); + mode.finish(); + break; + case MENUITEM_RELATION: + ElementSelectionActionModeCallback.buildPresetSelectDialog(main, + p -> main.startSupportActionMode(new EditRelationMembersActionModeCallback(manager, + p != null ? p.getPath(App.getCurrentRootPreset(main).getRootGroup()) : null, selection)), + ElementType.RELATION, R.string.select_relation_type_title, Tags.KEY_TYPE, null).show(); + break; + case MENUITEM_ADD_RELATION_MEMBERS: + ElementSelectionActionModeCallback.buildRelationSelectDialog(main, r -> { + Relation relation = (Relation) App.getDelegator().getOsmElement(Relation.NAME, r); + if (relation != null) { + main.startSupportActionMode(new EditRelationMembersActionModeCallback(manager, relation, selection)); } - break; - case MENUITEM_INTERSECT: - intersectWays(); - break; - case MENUITEM_CREATE_CIRCLE: - createCircle(); - break; - case MENUITEM_ROTATE: - deselectOnExit = false; - main.startSupportActionMode(new RotationActionModeCallback(manager)); - break; - default: - return false; + }, -1, R.string.select_relation_title, null, null, selection).show(); + break; + case MENUITEM_ORTHOGONALIZE: + orthogonalizeWays(); + break; + case MENUITEM_MERGE: + if (canMergePolygons(selection)) { + mergePolygons(); + } else { + mergeWays(); } + break; + case MENUITEM_INTERSECT: + intersectWays(); + break; + case MENUITEM_CREATE_CIRCLE: + createCircle(); + break; + case MENUITEM_ROTATE: + deselectOnExit = false; + main.startSupportActionMode(new RotationActionModeCallback(manager)); + break; + case MENUITEM_EXTRACT_SEGMENT: + extractSegment(); + break; + default: + return false; } return true; } + /** + * Extract a segment from way(s) between two nodes + */ + private void extractSegment() { + if (selection.size() == 2 && selection.get(0) instanceof Node && selection.get(1) instanceof Node) { + final Node node1 = (Node) selection.get(0); + final Node node2 = (Node) selection.get(1); + List commonWays = getWaysForNodes(node1, node2); + if (!commonWays.isEmpty()) { + if (commonWays.size() == 1) { + splitSafe(commonWays, extractSegment(commonWays, node1, node2)); + } else { + DisambiguationMenu menu = new DisambiguationMenu(main.getMap()); + menu.setHeaderTitle(R.string.select_way_to_extract_from); + int id = 0; + menu.add(id, Type.WAY, main.getString(R.string.split_all_ways), + (int position) -> splitSafe(commonWays, extractSegment(commonWays, node1, node2))); + id++; + for (Way w : commonWays) { + menu.add(id, Type.WAY, w.getDescription(main), + (int position) -> splitSafe(Util.wrapInList(w), extractSegment(Util.wrapInList(w), node1, node2))); + id++; + } + menu.show(); + } + return; + } + } + Log.e(DEBUG_TAG, "extractSegment called but selection is invalid"); + + } + /** * Check if the current selection are ways that can be intersected * @@ -338,6 +386,32 @@ private void mergePolygons() { } } + /** + * Get a list of all the Ways common to the two given Nodes. + * + * @param node1 the 1st Node + * @param node2 the 2nd Node + * @return A list of all Ways connected to both Nodes + */ + @NonNull + public List getWaysForNodes(@NonNull final Node node1, @NonNull final Node node2) { + List result = new ArrayList<>(); + final Storage currentStorage = App.getDelegator().getCurrentStorage(); + List ways1 = currentStorage.getWays(node1); + List ways2 = currentStorage.getWays(node2); + if (ways1.size() < ways2.size()) { + List temp = ways2; + ways2 = ways1; + ways1 = temp; + } + for (Way w : ways1) { + if (ways2.contains(w)) { + result.add(w); + } + } + return result; + } + /** * Delete action * diff --git a/src/main/java/de/blau/android/easyedit/WaySegmentActionModeCallback.java b/src/main/java/de/blau/android/easyedit/WaySegmentActionModeCallback.java index d901f6a9f..f32b2fd3f 100644 --- a/src/main/java/de/blau/android/easyedit/WaySegmentActionModeCallback.java +++ b/src/main/java/de/blau/android/easyedit/WaySegmentActionModeCallback.java @@ -9,12 +9,8 @@ import de.blau.android.App; import de.blau.android.Logic; import de.blau.android.R; -import de.blau.android.exception.OsmIllegalOperationException; -import de.blau.android.exception.StorageException; import de.blau.android.osm.Node; import de.blau.android.osm.OsmElement; -import de.blau.android.osm.Result; -import de.blau.android.osm.Tags; import de.blau.android.osm.Way; import de.blau.android.util.Geometry; import de.blau.android.util.SerializableState; @@ -117,21 +113,8 @@ public boolean handleElementClick(OsmElement element) { // due to clickableEleme if (segmentNodes.length == 2) { final Node n1 = segmentNodes[0]; final Node n2 = segmentNodes[1]; - splitSafe(Util.wrapInList(way), () -> { - try { - List result = logic.performExtractSegment(main, way, n1, n2); - checkSplitResult(way, result); - Way segment = newWayFromSplitResult(result); - if (segment.hasTagKey(Tags.KEY_HIGHWAY) || segment.hasTagKey(Tags.KEY_WATERWAY)) { - main.startSupportActionMode(new WaySegmentModifyActionModeCallback(manager, segment)); - } else { - main.startSupportActionMode(new WaySelectionActionModeCallback(manager, segment)); - } - } catch (OsmIllegalOperationException | StorageException ex) { - // toast has already been displayed - manager.finish(); - } - }); + final List wayList = Util.wrapInList(way); + splitSafe(wayList, extractSegment(wayList, n1, n2)); } return true; } diff --git a/src/main/java/de/blau/android/easyedit/WaySegmentModifyActionModeCallback.java b/src/main/java/de/blau/android/easyedit/WaySegmentModifyActionModeCallback.java index 3c74a5158..901e0962f 100644 --- a/src/main/java/de/blau/android/easyedit/WaySegmentModifyActionModeCallback.java +++ b/src/main/java/de/blau/android/easyedit/WaySegmentModifyActionModeCallback.java @@ -1,11 +1,14 @@ package de.blau.android.easyedit; +import static de.blau.android.contract.Constants.LOG_TAG_LEN; + import java.util.HashMap; import android.util.Log; import android.view.Menu; import android.view.MenuItem; import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; import androidx.appcompat.view.ActionMode; import de.blau.android.R; import de.blau.android.osm.Tags; @@ -14,7 +17,9 @@ import de.blau.android.util.ThemeUtils; public class WaySegmentModifyActionModeCallback extends NonSimpleActionModeCallback { - private static final String DEBUG_TAG = WaySegmentModifyActionModeCallback.class.getSimpleName().substring(0, Math.min(23, WaySegmentModifyActionModeCallback.class.getSimpleName().length())); + + private static final int TAG_LEN = Math.min(LOG_TAG_LEN, WaySegmentModifyActionModeCallback.class.getSimpleName().length()); + private static final String DEBUG_TAG = WaySegmentModifyActionModeCallback.class.getSimpleName().substring(0, TAG_LEN); private static final int MENUITEM_BRIDGE = 24; private static final int MENUITEM_TUNNEL = 25; @@ -112,6 +117,20 @@ public boolean onActionItemClicked(ActionMode mode, MenuItem item) { return true; } + @Override + protected void onCloseClicked() { + onBackPressed(); + } + + @Override + public boolean onBackPressed() { + new AlertDialog.Builder(main).setTitle(R.string.abort_action_title).setPositiveButton(R.string.yes, (dialog, which) -> { + logic.rollback(); + super.onBackPressed(); + }).setNeutralButton(R.string.cancel, null).show(); + return false; + } + @Override public void saveState(SerializableState state) { state.putLong(WAY_ID_KEY, way.getOsmId()); diff --git a/src/main/java/de/blau/android/easyedit/WaySelectionActionModeCallback.java b/src/main/java/de/blau/android/easyedit/WaySelectionActionModeCallback.java index 3dfa1aa96..a7cc173f4 100644 --- a/src/main/java/de/blau/android/easyedit/WaySelectionActionModeCallback.java +++ b/src/main/java/de/blau/android/easyedit/WaySelectionActionModeCallback.java @@ -200,7 +200,7 @@ public boolean onPrepareActionMode(ActionMode mode, Menu menu) { updated |= setItemVisibility(joined, unjoinItem, false); updated |= setItemVisibility(joined, unjoinDissimilarItem, false); - updated |= setItemVisibility(size >= 3 && !closed, extractSegmentItem, false); + updated |= setItemVisibility(size >= 3, extractSegmentItem, false); if (updated) { arrangeMenu(menu); diff --git a/src/main/java/de/blau/android/easyedit/turnrestriction/RestrictionClosedWaySplittingActionModeCallback.java b/src/main/java/de/blau/android/easyedit/turnrestriction/RestrictionClosedWaySplittingActionModeCallback.java index da1c7b4c3..50cf803d7 100644 --- a/src/main/java/de/blau/android/easyedit/turnrestriction/RestrictionClosedWaySplittingActionModeCallback.java +++ b/src/main/java/de/blau/android/easyedit/turnrestriction/RestrictionClosedWaySplittingActionModeCallback.java @@ -1,5 +1,7 @@ package de.blau.android.easyedit.turnrestriction; +import static de.blau.android.contract.Constants.LOG_TAG_LEN; + import java.util.HashSet; import java.util.List; import java.util.Map; @@ -24,10 +26,13 @@ * */ public class RestrictionClosedWaySplittingActionModeCallback extends AbstractClosedWaySplittingActionModeCallback { - private static final String DEBUG_TAG = RestrictionClosedWaySplittingActionModeCallback.class.getSimpleName().substring(0, Math.min(23, RestrictionClosedWaySplittingActionModeCallback.class.getSimpleName().length())); - private final Way way; - private final Node node; - private final Way fromWay; + + private static final int TAG_LEN = Math.min(LOG_TAG_LEN, RestrictionClosedWaySplittingActionModeCallback.class.getSimpleName().length()); + private static final String DEBUG_TAG = RestrictionClosedWaySplittingActionModeCallback.class.getSimpleName().substring(0, TAG_LEN); + + private final Way way; + private final Node node; + private final Way fromWay; /** * Construct a new callback for splitting a closed way/polygon as part of a turn restriction @@ -58,22 +63,23 @@ public boolean handleElementClick(OsmElement element) { // NOSONAR super.handleElementClick(element); try { if (element instanceof Node) { - Way[] result = logic.performClosedWaySplit(main, way, node, (Node) element, false); - if (result.length == 2) { - if (fromWay == null) { - Set candidates = new HashSet<>(); - candidates.add(result[0]); - candidates.add(result[1]); - main.startSupportActionMode(new RestartFromElementActionModeCallback(manager, candidates, candidates, savedResults)); - } else { - Way viaWay = result[0]; - if (fromWay.hasCommonNode(result[1])) { - viaWay = result[1]; - } - main.startSupportActionMode(new ViaElementActionModeCallback(manager, fromWay, viaWay, savedResults)); + List results = logic.performClosedWaySplit(main, way, node, (Node) element, false); + // FIXME we currently don't display any issues as that would be confusing + Way way0 = (Way) results.get(0).getElement(); + Way way1 = (Way) results.get(1).getElement(); + if (fromWay == null) { + Set candidates = new HashSet<>(); + candidates.add(way0); + candidates.add(way1); + main.startSupportActionMode(new RestartFromElementActionModeCallback(manager, candidates, candidates, savedResults)); + } else { + Way viaWay = way0; + if (fromWay.hasCommonNode(way1)) { + viaWay = way1; } - return true; + main.startSupportActionMode(new ViaElementActionModeCallback(manager, fromWay, viaWay, savedResults)); } + return true; } } catch (OsmIllegalOperationException | StorageException ex) { // toast has already been displayed diff --git a/src/main/java/de/blau/android/osm/Result.java b/src/main/java/de/blau/android/osm/Result.java index 2bfae7b82..f3f6218b0 100644 --- a/src/main/java/de/blau/android/osm/Result.java +++ b/src/main/java/de/blau/android/osm/Result.java @@ -106,10 +106,20 @@ public Collection getIssues() { * * @return the element */ + @Nullable public OsmElement getElement() { return element; } + /** + * Check if this result contains an OsmElement + * + * @return true if an element is present + */ + public boolean hasElement() { + return element != null; + } + /** * Set the stored OsmElement * diff --git a/src/main/java/de/blau/android/osm/SplitIssue.java b/src/main/java/de/blau/android/osm/SplitIssue.java index b4b2a278b..0b1b97d48 100644 --- a/src/main/java/de/blau/android/osm/SplitIssue.java +++ b/src/main/java/de/blau/android/osm/SplitIssue.java @@ -10,7 +10,7 @@ * */ public enum SplitIssue implements Issue { - SPLIT_METRIC, SPLIT_ROUTE_ORDERING; + SPLIT_METRIC, SPLIT_ROUTE_ORDERING, SPLIT_AREA; @Override public String toTranslatedString(Context context) { @@ -18,6 +18,8 @@ public String toTranslatedString(Context context) { return context.getString(R.string.issue_split_metric); } else if (SPLIT_ROUTE_ORDERING.equals(this)) { return context.getString(R.string.issue_split_route_ordering); + } else if (SPLIT_AREA.equals(this)) { + return context.getString(R.string.issue_split_area); } else { return ""; } diff --git a/src/main/java/de/blau/android/osm/StorageDelegator.java b/src/main/java/de/blau/android/osm/StorageDelegator.java index 3992926c1..f0a50fc74 100755 --- a/src/main/java/de/blau/android/osm/StorageDelegator.java +++ b/src/main/java/de/blau/android/osm/StorageDelegator.java @@ -11,6 +11,7 @@ import java.io.Serializable; import java.net.ProtocolException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -53,6 +54,7 @@ import de.blau.android.util.SavingHelper.Exportable; import de.blau.android.util.ScreenMessage; import de.blau.android.util.Util; +import de.blau.android.util.Winding; import de.blau.android.util.collections.LongHashSet; import de.blau.android.util.collections.LongOsmElementMap; import de.blau.android.util.collections.MultiHashMap; @@ -65,7 +67,8 @@ public class StorageDelegator implements Serializable, Exportable, DataStorage { private static final long serialVersionUID = 11L; - public static final int MIN_NODES_CIRCLE = 3; + public static final int MIN_NODES_CIRCLE = 3; + private static final int MINIMUN_NODES_FOR_WAY_SPLIT = 3; private Storage currentStorage; @@ -1250,105 +1253,158 @@ public void removeNode(@NonNull final Node node) { } /** - * Split a (closed) way at two points + * Split a closed way at two points * * @param way way to split * @param node1 first node to split at * @param node2 second node to split at * @param createPolygons split in to two polygons - * @return null if split failed or wasn't possible, the two resulting ways otherwise + * @return the original Way in the 1st Result, the new Way in the 2nd Result, issues if not successful */ @NonNull - public Way[] splitAtNodes(@NonNull Way way, @NonNull Node node1, @NonNull Node node2, boolean createPolygons) { + public List splitAtNodes(@NonNull Way way, @NonNull Node node1, @NonNull Node node2, boolean createPolygons) { Log.d(DEBUG_TAG, "splitAtNodes way " + way.getOsmId() + " node1 " + node1.getOsmId() + " node2 " + node2.getOsmId()); + Result resultOrig = new Result(); + Result resultNew = new Result(); // undo - old way is saved here, new way is saved at insert dirty = true; undo.save(way); List nodes = way.getNodes(); - if (nodes.size() < 3) { + if (nodes.size() < MINIMUN_NODES_FOR_WAY_SPLIT) { throw new OsmIllegalOperationException("Closed way with less than three nodes cannot be split"); } + int winding = Winding.winding(nodes); + int pos1 = nodes.indexOf(node1); + int pos2 = nodes.indexOf(node2); + validateRelationMemberCount(way.getParentRelations(), 1); + List metricKeys = getMetricKeys(way); + double originalLength = getWayLength(way, metricKeys); + /* * convention iterate over list, copy everything between first split node found and 2nd split node found if 2nd * split node found first the same */ - List nodesForNewWay = new LinkedList<>(); - List nodesForOldWay1 = new LinkedList<>(); - List nodesForOldWay2 = new LinkedList<>(); + List nodesExtracted = new LinkedList<>(); + List nodesEnd1 = new LinkedList<>(); + List nodesEnd2 = new LinkedList<>(); boolean found1 = false; boolean found2 = false; - for (Iterator it = way.getNodeIterator(); it.hasNext();) { - Node wayNode = it.next(); - if (!found1 && wayNode.getOsmId() == node1.getOsmId()) { + final long node1Id = node1.getOsmId(); + final long node2Id = node2.getOsmId(); + for (Node wayNode : nodes) { + if (!found1 && wayNode.getOsmId() == node1Id) { found1 = true; - nodesForNewWay.add(wayNode); + nodesExtracted.add(wayNode); if (!found2) { - nodesForOldWay1.add(wayNode); + nodesEnd1.add(wayNode); } else { - nodesForOldWay2.add(wayNode); + nodesEnd2.add(wayNode); } - } else if (!found2 && wayNode.getOsmId() == node2.getOsmId()) { + } else if (!found2 && wayNode.getOsmId() == node2Id) { found2 = true; - nodesForNewWay.add(wayNode); + nodesExtracted.add(wayNode); if (!found1) { - nodesForOldWay1.add(wayNode); + nodesEnd1.add(wayNode); } else { - nodesForOldWay2.add(wayNode); + nodesEnd2.add(wayNode); } } else if ((found1 && !found2) || (!found1 && found2)) { - nodesForNewWay.add(wayNode); + nodesExtracted.add(wayNode); } else if (!found1) { - nodesForOldWay1.add(wayNode); + nodesEnd1.add(wayNode); } else { - nodesForOldWay2.add(wayNode); - } - } - - // shuffle the nodes around for the original way so that they are in sequence and the way isn't closed - Log.d(DEBUG_TAG, "nodesForNewWay " + nodesForNewWay.size() + " oldNodes1 " + nodesForOldWay1.size() + " oldNodes2 " + nodesForOldWay2.size()); - List oldNodes = way.getNodes(); - oldNodes.clear(); - if (nodesForOldWay1.isEmpty()) { - oldNodes.addAll(nodesForOldWay2); - } else if (nodesForOldWay2.isEmpty()) { - oldNodes.addAll(nodesForOldWay1); - } else if (nodesForOldWay1.get(0) == nodesForOldWay2.get(nodesForOldWay2.size() - 1)) { - oldNodes.addAll(nodesForOldWay2); - nodesForOldWay1.remove(0); - oldNodes.addAll(nodesForOldWay1); + nodesEnd2.add(wayNode); + } + } + + // shuffle the nodes around from the original way so that they are in sequence and the way isn't closed + Log.d(DEBUG_TAG, "nodesForNewWay " + nodesExtracted.size() + " oldNodes1 " + nodesEnd1.size() + " oldNodes2 " + nodesEnd2.size()); + + // create the new way + Way newWay = factory.createWayWithNewId(); + newWay.addTags(way.getTags()); + + // enforce behaviour that first and second node are relative to the winding of the ring + if ((winding == Winding.CLOCKWISE && pos2 > pos1) || (winding == Winding.COUNTERCLOCKWISE && pos1 < pos2)) { + addEndSegmentsToWay(way, nodesEnd1, nodesEnd2); + newWay.getNodes().clear(); + newWay.addNodes(nodesExtracted, false); } else { - oldNodes.addAll(nodesForOldWay1); - nodesForOldWay2.remove(0); - oldNodes.addAll(nodesForOldWay2); + addEndSegmentsToWay(newWay, nodesEnd1, nodesEnd2); + way.getNodes().clear(); + way.addNodes(nodesExtracted, false); } - List changedElements = new ArrayList<>(); - if (createPolygons && way.nodeCount() > Way.MINIMUM_NODES_IN_WAY) { // close the original way now - way.addNode(way.getFirstNode()); + + if (createPolygons) { + closeWay(way); + closeWay(newWay); } + + List changedElements = new ArrayList<>(); way.updateState(OsmElement.STATE_MODIFIED); apiStorage.insertElementSafe(way); changedElements.add(way); - // create the new way - Way newWay = factory.createWayWithNewId(); - newWay.addTags(way.getTags()); - newWay.addNodes(nodesForNewWay, false); - if (createPolygons && newWay.nodeCount() > Way.MINIMUM_NODES_IN_WAY) { // close the new way now - newWay.addNode(newWay.getFirstNode()); - } insertElementUnsafe(newWay); - addSplitWayToRelations(way, true, newWay, changedElements); + if (!metricKeys.isEmpty() && originalLength != 0) { + resultOrig.addIssue(SplitIssue.SPLIT_METRIC); + resultNew.addIssue(SplitIssue.SPLIT_METRIC); + for (String key : metricKeys) { + distributeMetric(key, originalLength, way); + distributeMetric(key, originalLength, newWay); + } + } + + List relationResults = addSplitWayToRelations(way, true, newWay, changedElements); onElementChanged(null, changedElements); - Way[] result = new Way[2]; - result[0] = way; - result[1] = newWay; - return result; + + resultOrig.setElement(way); + resultNew.setElement(newWay); + List resultList = Arrays.asList(resultOrig, resultNew); + resultList.addAll(relationResults); + return resultList; + } + + /** + * Add way nodes from two end segments of a (formerly) existing way to a way + * + * @param way the Way + * @param nodesEndSegment1 nodes from 1st segment + * @param nodesForEndSegment2 nodes from 2nd segment + */ + private void addEndSegmentsToWay(@NonNull Way way, @NonNull List nodesEndSegment1, @NonNull List nodesForEndSegment2) { + List nodes = way.getNodes(); + nodes.clear(); + if (nodesEndSegment1.isEmpty()) { + nodes.addAll(nodesForEndSegment2); + } else if (nodesForEndSegment2.isEmpty()) { + nodes.addAll(nodesEndSegment1); + } else if (nodesEndSegment1.get(0) == nodesForEndSegment2.get(nodesForEndSegment2.size() - 1)) { + nodes.addAll(nodesForEndSegment2); + nodesEndSegment1.remove(0); + nodes.addAll(nodesEndSegment1); + } else { + nodes.addAll(nodesEndSegment1); + nodesForEndSegment2.remove(0); + nodes.addAll(nodesForEndSegment2); + } + } + + /** + * Close a way by adding the 1st node to the end + * + * @param way the Way + */ + private void closeWay(@NonNull Way way) { + if (way.nodeCount() > Way.MINIMUM_NODES_IN_WAY) { // close the original way now + way.addNode(way.getFirstNode()); + } } /** @@ -1377,18 +1433,8 @@ public List splitAtNode(@NonNull final Way way, @NonNull final Node node } validateRelationMemberCount(way.getParentRelations(), 1); - // check tags for problematic keys - List metricKeys = new ArrayList<>(); - for (String key : way.getTags().keySet()) { - if (Tags.isWayMetric(key)) { - metricKeys.add(key); - } - } - // determine the length before we remove nodes - double originalLength = 1D; - if (!metricKeys.isEmpty()) { - originalLength = way.length(); - } + List metricKeys = getMetricKeys(way); + double originalLength = getWayLength(way, metricKeys); // we assume this node is only contained in the way once. // else the user needs to split the remaining way again. @@ -1424,6 +1470,40 @@ public List splitAtNode(@NonNull final Way way, @NonNull final Node node return resultList; } + /** + * Returnn the length of the way is thaere are metric keys + * + * @param way the way + * @param metricKeys a list of relevant keys + * @return the length + */ + private double getWayLength(@NonNull final Way way, @NonNull List metricKeys) { + // determine the length before we remove nodes + double originalLength = 1D; + if (!metricKeys.isEmpty()) { + originalLength = way.length(); + } + return originalLength; + } + + /** + * Get a list of length dependent keys that thw Way has + * + * @param way the Way + * @return a list of keys + */ + @NonNull + private List getMetricKeys(@NonNull final Way way) { + // check tags for problematic keys + List metricKeys = new ArrayList<>(); + for (String key : way.getTags().keySet()) { + if (Tags.isWayMetric(key)) { + metricKeys.add(key); + } + } + return metricKeys; + } + /** * Get nodes for the split of way keeping the initial ones * diff --git a/src/main/java/de/blau/android/util/AreaTags.java b/src/main/java/de/blau/android/util/AreaTags.java new file mode 100644 index 000000000..8ce985080 --- /dev/null +++ b/src/main/java/de/blau/android/util/AreaTags.java @@ -0,0 +1,127 @@ +package de.blau.android.util; + +import static de.blau.android.contract.Constants.LOG_TAG_LEN; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; + +import com.google.gson.stream.JsonReader; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.AssetManager; +import android.util.Log; +import androidx.annotation.NonNull; + +/** + * Class to determine if an OSM tag implies area semantics, see https://github.com/simonpoole/osm-area-tags + * + * @author simon + * + */ +public class AreaTags { + + private static final int TAG_LEN = Math.min(LOG_TAG_LEN, AreaTags.class.getSimpleName().length()); + private static final String DEBUG_TAG = AreaTags.class.getSimpleName().substring(0, TAG_LEN); + + private static final String DEFAULT = "default"; + private static final String AREA_KEYS = "areaKeys"; + private static final String VALUES = "values"; + + private static final String AREA_TAGS_JSON = "area-tags.json"; + + private final Map tagMap; + + /** + * Implicit assumption that the data will be short and that it is OK to read in synchronously which may not be true + * any longer + * + * @param context Android Context + */ + public AreaTags(@NonNull Context context) { + Log.d(DEBUG_TAG, "Initalizing"); + tagMap = getTagMap(context.getAssets(), AREA_TAGS_JSON); + } + + /** + * Read a Json file from assets conforming to https://github.com/simonpoole/osm-area-tags/schema.json + * + * @param assetManager an AssetManager + * @param fileName the name of the file + * @return a Map + */ + @SuppressLint("NewApi") // StandardCharsets is desugared for APIs < 19. + @NonNull + private Map getTagMap(@NonNull AssetManager assetManager, @NonNull String fileName) { + Map result = new HashMap<>(); + try (InputStream is = assetManager.open(fileName); JsonReader reader = new JsonReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + reader.beginObject(); + while (reader.hasNext()) { + if (!AREA_KEYS.equals(reader.nextName())) { + reader.skipValue(); + } + reader.beginObject(); + while (reader.hasNext()) { + String key = reader.nextName(); + reader.beginObject(); + while (reader.hasNext()) { + String values = reader.nextName(); + if (DEFAULT.equals(values)) { + result.put(key, reader.nextBoolean()); + } else if (VALUES.equals(values)) { + reader.beginObject(); + while (reader.hasNext()) { + String value = reader.nextName(); + result.put(key + " " + value, reader.nextBoolean()); + } + reader.endObject(); + } + } + reader.endObject(); + } + reader.endObject(); + } + reader.endObject(); + Log.d(DEBUG_TAG, "Found " + result.size() + " entries."); + } catch (IOException e) { + Log.d(DEBUG_TAG, "Reading " + fileName + " " + e.getMessage()); + } + return result; + } + + /** + * Check if a tag implies area semantics + * + * @param key the key + * @param value the value + * @return true if the tag implies area semantics + */ + public boolean isImpliedArea(@NonNull String key, @NonNull String value) { + Boolean result = tagMap.get(key + " " + value); + if (result != null) { + return result; + } + result = tagMap.get(key); + return result != null && result; + } + + /** + * Check if a set of tags implies area semantics + * + * @param tags a Map containing the tags + * @return true if the tags implies area semantics + */ + public boolean isImpliedArea(@NonNull Map tags) { + for (Entry tag : tags.entrySet()) { + if (isImpliedArea(tag.getKey(), tag.getValue())) { + return true; + } + } + return false; + } +} diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 41f05c2e9..c655f609e 100755 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1223,6 +1223,7 @@ Snap Follow way Select way to follow + Select way to extract segment from Add node Tap the screen position @@ -1683,6 +1684,7 @@ Length dependent value was merged Length dependent value was split Route may need manual reordering + Area split, may need to be converted to a multipolygon Relation member count exceeded Tagged node extracted from way Member element replaced diff --git a/src/test/java/de/blau/android/osm/RelationUtilTest.java b/src/test/java/de/blau/android/osm/RelationUtilTest.java index 53e23b5a7..70ca7ffa0 100644 --- a/src/test/java/de/blau/android/osm/RelationUtilTest.java +++ b/src/test/java/de/blau/android/osm/RelationUtilTest.java @@ -25,7 +25,7 @@ import de.blau.android.util.Util; @RunWith(RobolectricTestRunner.class) -@Config(sdk=33) +@Config(sdk = 33) @LargeTest public class RelationUtilTest { @@ -49,12 +49,16 @@ public void setMultiPolygonRolesTest() { Way w10 = getWay(d, -10L); Relation mp = d.createAndInsertRelation(Arrays.asList(w1, w5, w4, w10)); assertNotNull(mp); - Way[] newWays = d.splitAtNodes(w5, w5.getFirstNode(), w5.getNodes().get(2), false); - assertEquals(2, newWays.length); + List results = d.splitAtNodes(w5, w5.getFirstNode(), w5.getNodes().get(2), false); + assertEquals(2, results.size()); + Way newWay0 = (Way) results.get(0).getElement(); + assertNotNull(newWay0); + Way newWay1 = (Way) results.get(1).getElement(); + assertNotNull(newWay1); RelationUtils.setMultipolygonRoles(null, mp.getMembers(), false); assertEquals(Tags.ROLE_OUTER, mp.getMember(w1).getRole()); - assertEquals(Tags.ROLE_OUTER, mp.getMember(newWays[0]).getRole()); - assertEquals(Tags.ROLE_OUTER, mp.getMember(newWays[1]).getRole()); + assertEquals(Tags.ROLE_OUTER, mp.getMember(newWay0).getRole()); + assertEquals(Tags.ROLE_OUTER, mp.getMember(newWay1).getRole()); assertEquals(Tags.ROLE_INNER, mp.getMember(w4).getRole()); assertEquals("", mp.getMember(w10).getRole()); } diff --git a/src/test/java/de/blau/android/osm/StorageDelegatorTest.java b/src/test/java/de/blau/android/osm/StorageDelegatorTest.java index fd4555543..0ad688f7b 100644 --- a/src/test/java/de/blau/android/osm/StorageDelegatorTest.java +++ b/src/test/java/de/blau/android/osm/StorageDelegatorTest.java @@ -1030,13 +1030,17 @@ public void splitClosed1() { assertNotNull(temp); Node n1 = w.getNodes().get(1); Node n2 = w.getNodes().get(2); - Way[] newWays = d.splitAtNodes(w, n1, n2, false); - assertNotNull(newWays); - assertEquals(2, newWays.length); - assertTrue(newWays[0].hasNode(n1)); - assertTrue(newWays[0].hasNode(n2)); - assertTrue(newWays[1].hasNode(n1)); - assertTrue(newWays[1].hasNode(n2)); + List results = d.splitAtNodes(w, n1, n2, false); + assertNotNull(results); + assertEquals(2, results.size()); + Way newWay0 = (Way) results.get(0).getElement(); + assertNotNull(newWay0); + assertTrue(newWay0.hasNode(n1)); + assertTrue(newWay0.hasNode(n2)); + Way newWay1 = (Way) results.get(1).getElement(); + assertNotNull(newWay1); + assertTrue(newWay1.hasNode(n1)); + assertTrue(newWay1.hasNode(n2)); } /** @@ -1051,15 +1055,19 @@ public void splitClosed2() { assertNotNull(temp); Node n1 = w.getNodes().get(1); Node n2 = w.getNodes().get(3); - Way[] newWays = d.splitAtNodes(w, n1, n2, true); - assertNotNull(newWays); - assertEquals(2, newWays.length); - assertTrue(newWays[0].hasNode(n1)); - assertTrue(newWays[0].hasNode(n2)); - assertTrue(newWays[1].hasNode(n1)); - assertTrue(newWays[1].hasNode(n2)); - assertTrue(newWays[0].isClosed()); - assertTrue(newWays[1].isClosed()); + List results = d.splitAtNodes(w, n1, n2, true); + assertNotNull(results); + assertEquals(2, results.size()); + Way newWay0 = (Way) results.get(0).getElement(); + assertNotNull(newWay0); + Way newWay1 = (Way) results.get(1).getElement(); + assertNotNull(newWay1); + assertTrue(newWay0.hasNode(n1)); + assertTrue(newWay0.hasNode(n2)); + assertTrue(newWay1.hasNode(n1)); + assertTrue(newWay1.hasNode(n2)); + assertTrue(newWay0.isClosed()); + assertTrue(newWay1.isClosed()); } /** diff --git a/src/test/java/de/blau/android/util/AreaTagsTest.java b/src/test/java/de/blau/android/util/AreaTagsTest.java new file mode 100644 index 000000000..9d20a390f --- /dev/null +++ b/src/test/java/de/blau/android/util/AreaTagsTest.java @@ -0,0 +1,35 @@ +package de.blau.android.util; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.filters.LargeTest; +import de.blau.android.osm.Tags; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk=33) +@LargeTest +public class AreaTagsTest { + + /** + * + */ + @Test + public void buildingTest() { + AreaTags at = new AreaTags(ApplicationProvider.getApplicationContext()); + assertTrue(at.isImpliedArea(Tags.KEY_BUILDING, "something")); + } + + @Test + public void highwayTest() { + AreaTags at = new AreaTags(ApplicationProvider.getApplicationContext()); + assertFalse(at.isImpliedArea(Tags.KEY_HIGHWAY, "residential")); + assertTrue(at.isImpliedArea(Tags.KEY_HIGHWAY, "rest_area")); + } +} \ No newline at end of file