From 058f7ba5c84ade98547333437d3ca7bb601bd2d6 Mon Sep 17 00:00:00 2001 From: Emma Simon Date: Thu, 28 Mar 2024 10:10:17 -0400 Subject: [PATCH] feat: Refine stop display (#105) * feat: Distinguish between stop and stations icons Bus/ferry stops are now displayed separately from rail stations, with a different icon, and different behavior at different zoom levels. This also includes quite a bit of refactoring around how the sources and layers are being created, they're each moved out into a class, plus another class for adding sources, layers, and images to the map. * test: Add tests for source generators * test: Add tests for layer generators * test: Fix android tests * refactor: Use Mapbox expression for stop icons * test: Fix test * fix: Remove irrelevant layer property --- iosApp/iosApp.xcodeproj/project.pbxproj | 68 +++++- .../bus-stop-small.imageset/Contents.json | 21 ++ .../bus-stop-small.svg | 10 + .../Contents.json | 2 +- .../bus-stop.imageset/bus-stop.svg | 12 + .../t-logo.imageset/mbta-logo-t.svg | 7 - .../t-station.imageset/Contents.json | 21 ++ .../t-station.imageset/t-station.svg | 17 ++ iosApp/iosApp/Pages/Map/HomeMapView.swift | 210 ++++-------------- iosApp/iosApp/Pages/Map/MapLayerManager.swift | 78 +++++++ iosApp/iosApp/Pages/Map/RecenterButton.swift | 24 ++ .../Pages/Map/RouteLayerGenerator.swift | 45 ++++ .../Pages/Map/RouteSourceGenerator.swift | 85 +++++++ .../iosApp/Pages/Map/StopLayerGenerator.swift | 59 +++++ .../Pages/Map/StopSourceGenerator.swift | 87 ++++++++ .../NearbyTransit/NearbyTransitView.swift | 2 + iosApp/iosApp/Utils/StopExtension.swift | 23 ++ iosApp/iosApp/ViewportProvider.swift | 2 +- .../Pages/Map/MapTestDataHelper.swift | 109 +++++++++ .../Pages/Map/RouteLayerGeneratorTests.swift | 29 +++ .../Pages/Map/RouteSourceGeneratorTests.swift | 49 ++++ .../Pages/Map/StopLayerGeneratorTests.swift | 29 +++ .../Pages/Map/StopSourceGeneratorTests.swift | 195 ++++++++++++++++ .../Views/NearbyTransitViewTests.swift | 2 +- .../mbta/tid/mbta_app/model/LocationType.kt | 13 ++ .../mbta_app/model/ObjectCollectionBuilder.kt | 15 +- .../com/mbta/tid/mbta_app/model/Shape.kt | 3 +- .../com/mbta/tid/mbta_app/model/Stop.kt | 1 + .../com/mbta/tid/mbta_app/BackendTest.kt | 5 + 29 files changed, 1043 insertions(+), 180 deletions(-) create mode 100644 iosApp/iosApp/Assets.xcassets/bus-stop-small.imageset/Contents.json create mode 100644 iosApp/iosApp/Assets.xcassets/bus-stop-small.imageset/bus-stop-small.svg rename iosApp/iosApp/Assets.xcassets/{t-logo.imageset => bus-stop.imageset}/Contents.json (87%) create mode 100644 iosApp/iosApp/Assets.xcassets/bus-stop.imageset/bus-stop.svg delete mode 100644 iosApp/iosApp/Assets.xcassets/t-logo.imageset/mbta-logo-t.svg create mode 100644 iosApp/iosApp/Assets.xcassets/t-station.imageset/Contents.json create mode 100644 iosApp/iosApp/Assets.xcassets/t-station.imageset/t-station.svg create mode 100644 iosApp/iosApp/Pages/Map/MapLayerManager.swift create mode 100644 iosApp/iosApp/Pages/Map/RecenterButton.swift create mode 100644 iosApp/iosApp/Pages/Map/RouteLayerGenerator.swift create mode 100644 iosApp/iosApp/Pages/Map/RouteSourceGenerator.swift create mode 100644 iosApp/iosApp/Pages/Map/StopLayerGenerator.swift create mode 100644 iosApp/iosApp/Pages/Map/StopSourceGenerator.swift create mode 100644 iosApp/iosApp/Utils/StopExtension.swift create mode 100644 iosApp/iosAppTests/Pages/Map/MapTestDataHelper.swift create mode 100644 iosApp/iosAppTests/Pages/Map/RouteLayerGeneratorTests.swift create mode 100644 iosApp/iosAppTests/Pages/Map/RouteSourceGeneratorTests.swift create mode 100644 iosApp/iosAppTests/Pages/Map/StopLayerGeneratorTests.swift create mode 100644 iosApp/iosAppTests/Pages/Map/StopSourceGeneratorTests.swift create mode 100644 shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/LocationType.kt diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index c7d49e5b2..d711957fa 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -49,6 +49,17 @@ 9A4E8E592B7EC4B90066B936 /* RoutePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4E8E582B7EC4B90066B936 /* RoutePill.swift */; }; 9A5830562BA3A2CE0039876E /* ViewportExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A5830552BA3A2CE0039876E /* ViewportExtension.swift */; }; 9A5830582BA4A1A30039876E /* ViewportProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A5830572BA4A1A30039876E /* ViewportProvider.swift */; }; + 9A5B27522BB1EF45009A6FC6 /* StopSourceGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A5B27512BB1EF45009A6FC6 /* StopSourceGenerator.swift */; }; + 9A5B27542BB1EF53009A6FC6 /* RouteSourceGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A5B27532BB1EF53009A6FC6 /* RouteSourceGenerator.swift */; }; + 9A5B27562BB221C1009A6FC6 /* RouteLayerGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A5B27552BB221C1009A6FC6 /* RouteLayerGenerator.swift */; }; + 9A5B27582BB22BF9009A6FC6 /* MapLayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A5B27572BB22BF8009A6FC6 /* MapLayerManager.swift */; }; + 9A5B275A2BB22D91009A6FC6 /* StopLayerGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A5B27592BB22D91009A6FC6 /* StopLayerGenerator.swift */; }; + 9A5B275C2BB237DE009A6FC6 /* RecenterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A5B275B2BB237DE009A6FC6 /* RecenterButton.swift */; }; + 9A5B27602BB31178009A6FC6 /* StopSourceGeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A5B275F2BB31178009A6FC6 /* StopSourceGeneratorTests.swift */; }; + 9A5B27622BB32621009A6FC6 /* RouteSourceGeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A5B27612BB32621009A6FC6 /* RouteSourceGeneratorTests.swift */; }; + 9A5B27642BB3621F009A6FC6 /* RouteLayerGeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A5B27632BB3621F009A6FC6 /* RouteLayerGeneratorTests.swift */; }; + 9A5B27662BB3631F009A6FC6 /* MapTestDataHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A5B27652BB3631F009A6FC6 /* MapTestDataHelper.swift */; }; + 9A5B27682BB36A23009A6FC6 /* StopLayerGeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A5B27672BB36A23009A6FC6 /* StopLayerGeneratorTests.swift */; }; 9A60E8E72B8501BD008A8D5C /* RoutePillTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A60E8E62B8501BD008A8D5C /* RoutePillTests.swift */; }; 9A635D1F2B99103200A43C51 /* EmptyWhenModifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A635D1E2B99103200A43C51 /* EmptyWhenModifierTests.swift */; }; 9A69D4902B99212400235125 /* NearbyFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A69D48F2B99212300235125 /* NearbyFetcherTests.swift */; }; @@ -69,6 +80,7 @@ 9AD1D1FE2BA4D5C600182060 /* ViewportProviderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD1D1FD2BA4D5C600182060 /* ViewportProviderTest.swift */; }; 9ADB849D2BAD05BC006581CE /* Inspection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ADB849C2BAD05BC006581CE /* Inspection.swift */; }; 9ADB84A02BAD1B84006581CE /* DebouncerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ADB849F2BAD1B84006581CE /* DebouncerTests.swift */; }; + 9ADB84A22BAE37C0006581CE /* StopExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ADB84A12BAE37C0006581CE /* StopExtension.swift */; }; 9AF88E052B48913C00E08C7C /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 9AF88E042B48913C00E08C7C /* Localizable.xcstrings */; }; A430D45FE0676C73075AB85B /* Pods_iosAppTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = ED1649D982654BA7A4D2F2DC /* Pods_iosAppTests.framework */; }; A55C5596CDC797ED68F79279 /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1C6BD892027AC258EE8F408D /* Pods_iosApp.framework */; }; @@ -156,6 +168,17 @@ 9A4E8E582B7EC4B90066B936 /* RoutePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutePill.swift; sourceTree = ""; }; 9A5830552BA3A2CE0039876E /* ViewportExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewportExtension.swift; sourceTree = ""; }; 9A5830572BA4A1A30039876E /* ViewportProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ViewportProvider.swift; path = iosApp/ViewportProvider.swift; sourceTree = SOURCE_ROOT; }; + 9A5B27512BB1EF45009A6FC6 /* StopSourceGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopSourceGenerator.swift; sourceTree = ""; }; + 9A5B27532BB1EF53009A6FC6 /* RouteSourceGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteSourceGenerator.swift; sourceTree = ""; }; + 9A5B27552BB221C1009A6FC6 /* RouteLayerGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteLayerGenerator.swift; sourceTree = ""; }; + 9A5B27572BB22BF8009A6FC6 /* MapLayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLayerManager.swift; sourceTree = ""; }; + 9A5B27592BB22D91009A6FC6 /* StopLayerGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopLayerGenerator.swift; sourceTree = ""; }; + 9A5B275B2BB237DE009A6FC6 /* RecenterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecenterButton.swift; sourceTree = ""; }; + 9A5B275F2BB31178009A6FC6 /* StopSourceGeneratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopSourceGeneratorTests.swift; sourceTree = ""; }; + 9A5B27612BB32621009A6FC6 /* RouteSourceGeneratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteSourceGeneratorTests.swift; sourceTree = ""; }; + 9A5B27632BB3621F009A6FC6 /* RouteLayerGeneratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteLayerGeneratorTests.swift; sourceTree = ""; }; + 9A5B27652BB3631F009A6FC6 /* MapTestDataHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTestDataHelper.swift; sourceTree = ""; }; + 9A5B27672BB36A23009A6FC6 /* StopLayerGeneratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopLayerGeneratorTests.swift; sourceTree = ""; }; 9A60E8E62B8501BD008A8D5C /* RoutePillTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutePillTests.swift; sourceTree = ""; }; 9A635D1E2B99103200A43C51 /* EmptyWhenModifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyWhenModifierTests.swift; sourceTree = ""; }; 9A69D48F2B99212300235125 /* NearbyFetcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NearbyFetcherTests.swift; sourceTree = ""; }; @@ -175,6 +198,7 @@ 9AD1D1FD2BA4D5C600182060 /* ViewportProviderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewportProviderTest.swift; sourceTree = ""; }; 9ADB849C2BAD05BC006581CE /* Inspection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Inspection.swift; sourceTree = ""; }; 9ADB849F2BAD1B84006581CE /* DebouncerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebouncerTests.swift; sourceTree = ""; }; + 9ADB84A12BAE37C0006581CE /* StopExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopExtension.swift; sourceTree = ""; }; 9AF88E042B48913C00E08C7C /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; ED1649D982654BA7A4D2F2DC /* Pods_iosAppTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosAppTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; F71252D2B68FF131F8E6BDE2 /* Pods-iosAppTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosAppTests.release.xcconfig"; path = "Target Support Files/Pods-iosAppTests/Pods-iosAppTests.release.xcconfig"; sourceTree = ""; }; @@ -249,6 +273,7 @@ 6EED5E8D2B3DC69F0052A1B8 /* iosAppTests */ = { isa = PBXGroup; children = ( + 9A5B275D2BB242EF009A6FC6 /* Pages */, 6EF50F4F2B988BC500833070 /* Fetchers */, 6EE745822B965B8D0052227E /* Phoenix */, 6E4EACFA2B7A829C0011AB8B /* Mocks */, @@ -331,11 +356,11 @@ 9A9E05F22B6D6D9F0086B437 /* Fetchers */, 058557BA273AAA24004C7B11 /* Assets.xcassets */, 9AF88E042B48913C00E08C7C /* Localizable.xcstrings */, - 7555FF82242A565900829871 /* ContentView.swift */, 7555FF8C242A565B00829871 /* Info.plist */, 2152FB032600AC8F00CF470E /* IOSApp.swift */, - 058557D7273AAEEB004C7B11 /* Preview Content */, + 7555FF82242A565900829871 /* ContentView.swift */, 8CC1BB3F2B59D1F6005386FE /* LocationDataManager.swift */, + 058557D7273AAEEB004C7B11 /* Preview Content */, ); path = iosApp; sourceTree = ""; @@ -388,6 +413,12 @@ isa = PBXGroup; children = ( 6E35D4CF2B72C7B700A2BF95 /* HomeMapView.swift */, + 9A5B27572BB22BF8009A6FC6 /* MapLayerManager.swift */, + 9A5B275B2BB237DE009A6FC6 /* RecenterButton.swift */, + 9A5B27552BB221C1009A6FC6 /* RouteLayerGenerator.swift */, + 9A5B27532BB1EF53009A6FC6 /* RouteSourceGenerator.swift */, + 9A5B27592BB22D91009A6FC6 /* StopLayerGenerator.swift */, + 9A5B27512BB1EF45009A6FC6 /* StopSourceGenerator.swift */, ); path = Map; sourceTree = ""; @@ -409,6 +440,26 @@ path = ComponentViews; sourceTree = ""; }; + 9A5B275D2BB242EF009A6FC6 /* Pages */ = { + isa = PBXGroup; + children = ( + 9A5B275E2BB24326009A6FC6 /* Map */, + ); + path = Pages; + sourceTree = ""; + }; + 9A5B275E2BB24326009A6FC6 /* Map */ = { + isa = PBXGroup; + children = ( + 9A5B27612BB32621009A6FC6 /* RouteSourceGeneratorTests.swift */, + 9A5B275F2BB31178009A6FC6 /* StopSourceGeneratorTests.swift */, + 9A5B27632BB3621F009A6FC6 /* RouteLayerGeneratorTests.swift */, + 9A5B27672BB36A23009A6FC6 /* StopLayerGeneratorTests.swift */, + 9A5B27652BB3631F009A6FC6 /* MapTestDataHelper.swift */, + ); + path = Map; + sourceTree = ""; + }; 9A9E05F22B6D6D9F0086B437 /* Fetchers */ = { isa = PBXGroup; children = ( @@ -438,6 +489,7 @@ 9A37F3042BACCC40001714FE /* DoubleRoundedExtension.swift */, 9A37F3062BACCCA5001714FE /* CoordinateExtension.swift */, 9ADB849C2BAD05BC006581CE /* Inspection.swift */, + 9ADB84A12BAE37C0006581CE /* StopExtension.swift */, ); path = Utils; sourceTree = ""; @@ -724,16 +776,21 @@ files = ( 9AD1D1FE2BA4D5C600182060 /* ViewportProviderTest.swift in Sources */, 9A887D592B698EF1006F5B80 /* SearchResultViewTests.swift in Sources */, + 9A5B27602BB31178009A6FC6 /* StopSourceGeneratorTests.swift in Sources */, 6EE745842B965B9C0052227E /* SocketTests.swift in Sources */, + 9A5B27682BB36A23009A6FC6 /* StopLayerGeneratorTests.swift in Sources */, 8CEA10272BA4C83D001C6EB9 /* AlertsFetcherTests.swift in Sources */, 6EF50F482B9889D600833070 /* MockSocket.swift in Sources */, + 9A5B27662BB3631F009A6FC6 /* MapTestDataHelper.swift in Sources */, 9ADB84A02BAD1B84006581CE /* DebouncerTests.swift in Sources */, 6EF50F512B988BF600833070 /* PredictionsFetcherTests.swift in Sources */, + 9A5B27622BB32621009A6FC6 /* RouteSourceGeneratorTests.swift in Sources */, 9A7B7CA92B98E41B0045214F /* NonNilModifierTests.swift in Sources */, 6EED5EAB2B3E1B550052A1B8 /* ContentViewTests.swift in Sources */, 9A60E8E72B8501BD008A8D5C /* RoutePillTests.swift in Sources */, 6E35D4D32B72CD3900A2BF95 /* HomeMapViewTests.swift in Sources */, 9A69D4902B99212400235125 /* NearbyFetcherTests.swift in Sources */, + 9A5B27642BB3621F009A6FC6 /* RouteLayerGeneratorTests.swift in Sources */, 9A635D1F2B99103200A43C51 /* EmptyWhenModifierTests.swift in Sources */, 8C7FA86F2B5EEA34009B699D /* LocationDataManagerTests.swift in Sources */, 6EED5E8F2B3DC6A00052A1B8 /* IosAppTests.swift in Sources */, @@ -758,10 +815,13 @@ buildActionMask = 2147483647; files = ( 8CEA10232BA0F3C6001C6EB9 /* ScheduleFetcher.swift in Sources */, + 9A5B275C2BB237DE009A6FC6 /* RecenterButton.swift in Sources */, 6E99CBB72B9892C80047E78D /* SocketProvider.swift in Sources */, 9A6DDF912B976FDF004D141A /* EmptyWhenModifier.swift in Sources */, 9ADB849D2BAD05BC006581CE /* Inspection.swift in Sources */, + 9A5B27542BB1EF53009A6FC6 /* RouteSourceGenerator.swift in Sources */, 6EE7457E2B965ADE0052227E /* Socket.swift in Sources */, + 9A5B27582BB22BF9009A6FC6 /* MapLayerManager.swift in Sources */, 9A03F3662BA9E68500DA40DC /* Debouncer.swift in Sources */, 9A8B34AD2B88E5090018412C /* RailRouteShapeFetcher.swift in Sources */, 9A2005CB2B97B68700F562E1 /* UpcomingTripView.swift in Sources */, @@ -770,18 +830,21 @@ 9A9E05F62B6D6EF70086B437 /* NearbyFetcher.swift in Sources */, 9A2005C92B97B65900F562E1 /* NearbyStopRoutePatternView.swift in Sources */, 9A4E8E592B7EC4B90066B936 /* RoutePill.swift in Sources */, + 9A5B27562BB221C1009A6FC6 /* RouteLayerGenerator.swift in Sources */, 9A9E05F42B6D6DEA0086B437 /* SearchResultFetcher.swift in Sources */, 9A3B09362B967CEC00691427 /* NonNilModifier.swift in Sources */, 2152FB042600AC8F00CF470E /* IOSApp.swift in Sources */, 8CD1F8CD2B7164C100F419D4 /* PredictionsFetcher.swift in Sources */, 9A887D572B683103006F5B80 /* SearchResultView.swift in Sources */, 8CEA10252BA4B179001C6EB9 /* AlertsFetcher.swift in Sources */, + 9A5B275A2BB22D91009A6FC6 /* StopLayerGenerator.swift in Sources */, 6E35D4D02B72C7B700A2BF95 /* HomeMapView.swift in Sources */, 8CC1BB402B59D1F6005386FE /* LocationDataManager.swift in Sources */, 8C7FA8732B5F36D6009B699D /* Backend.swift in Sources */, 9A2005C72B97B63300F562E1 /* NearbyStopView.swift in Sources */, 9AC4FDF12BACE216004479BF /* NearbyTransitPageView.swift in Sources */, 7555FF83242A565900829871 /* ContentView.swift in Sources */, + 9ADB84A22BAE37C0006581CE /* StopExtension.swift in Sources */, 9AB44A112B8FC43E00E8FFB3 /* IconCard.swift in Sources */, 9A1631E62B76CAB400F667F4 /* GlobalFetcher.swift in Sources */, 9A37F3072BACCCA5001714FE /* CoordinateExtension.swift in Sources */, @@ -791,6 +854,7 @@ 9A2005C52B97B5EA00F562E1 /* NearbyRouteView.swift in Sources */, 9A5830562BA3A2CE0039876E /* ViewportExtension.swift in Sources */, 9A5830582BA4A1A30039876E /* ViewportProvider.swift in Sources */, + 9A5B27522BB1EF45009A6FC6 /* StopSourceGenerator.swift in Sources */, 9AC4FDEF2BACE1EC004479BF /* NearbyTransitLocationProvider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/iosApp/iosApp/Assets.xcassets/bus-stop-small.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/bus-stop-small.imageset/Contents.json new file mode 100644 index 000000000..6146a2475 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/bus-stop-small.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "filename": "bus-stop-small.svg", + "idiom": "universal", + "scale": "1x" + }, + { + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/bus-stop-small.imageset/bus-stop-small.svg b/iosApp/iosApp/Assets.xcassets/bus-stop-small.imageset/bus-stop-small.svg new file mode 100644 index 000000000..686f4a412 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/bus-stop-small.imageset/bus-stop-small.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/iosApp/iosApp/Assets.xcassets/t-logo.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/bus-stop.imageset/Contents.json similarity index 87% rename from iosApp/iosApp/Assets.xcassets/t-logo.imageset/Contents.json rename to iosApp/iosApp/Assets.xcassets/bus-stop.imageset/Contents.json index bd180d5d2..5efad9448 100644 --- a/iosApp/iosApp/Assets.xcassets/t-logo.imageset/Contents.json +++ b/iosApp/iosApp/Assets.xcassets/bus-stop.imageset/Contents.json @@ -1,7 +1,7 @@ { "images": [ { - "filename": "mbta-logo-t.svg", + "filename": "bus-stop.svg", "idiom": "universal", "scale": "1x" }, diff --git a/iosApp/iosApp/Assets.xcassets/bus-stop.imageset/bus-stop.svg b/iosApp/iosApp/Assets.xcassets/bus-stop.imageset/bus-stop.svg new file mode 100644 index 000000000..7f02918e2 --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/bus-stop.imageset/bus-stop.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/iosApp/iosApp/Assets.xcassets/t-logo.imageset/mbta-logo-t.svg b/iosApp/iosApp/Assets.xcassets/t-logo.imageset/mbta-logo-t.svg deleted file mode 100644 index 891abc75f..000000000 --- a/iosApp/iosApp/Assets.xcassets/t-logo.imageset/mbta-logo-t.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - diff --git a/iosApp/iosApp/Assets.xcassets/t-station.imageset/Contents.json b/iosApp/iosApp/Assets.xcassets/t-station.imageset/Contents.json new file mode 100644 index 000000000..cef123c3e --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/t-station.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "filename": "t-station.svg", + "idiom": "universal", + "scale": "1x" + }, + { + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/iosApp/iosApp/Assets.xcassets/t-station.imageset/t-station.svg b/iosApp/iosApp/Assets.xcassets/t-station.imageset/t-station.svg new file mode 100644 index 000000000..8bf43fabd --- /dev/null +++ b/iosApp/iosApp/Assets.xcassets/t-station.imageset/t-station.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/iosApp/iosApp/Pages/Map/HomeMapView.swift b/iosApp/iosApp/Pages/Map/HomeMapView.swift index 818608038..b1e90ba73 100644 --- a/iosApp/iosApp/Pages/Map/HomeMapView.swift +++ b/iosApp/iosApp/Pages/Map/HomeMapView.swift @@ -13,23 +13,17 @@ import SwiftUI @_spi(Experimental) import MapboxMaps struct HomeMapView: View { - static let stopZoomThreshold: CGFloat = ViewportProvider.defaultZoom - 0.25 - private let cameraDebouncer = Debouncer(delay: 0.25) - - private let routeLayerId = "route-layer" - private let routeSourceId = "route-source" - private let stopLayerId = "stop-layer" - private let stopSourceId = "stop-source" - private let stopIconId = "t-logo" + private let layerInitDispatchGroup = DispatchGroup() @ObservedObject var globalFetcher: GlobalFetcher @ObservedObject var nearbyFetcher: NearbyFetcher @ObservedObject var railRouteShapeFetcher: RailRouteShapeFetcher @ObservedObject var viewportProvider: ViewportProvider + @State private var layerManager: MapLayerManager? @StateObject private var locationDataManager: LocationDataManager - @State var recenterButton: ViewAnnotation? + @State private var recenterButton: ViewAnnotation? init( globalFetcher: GlobalFetcher, @@ -60,18 +54,16 @@ struct HomeMapView: View { } .gestureOptions(.init(rotateEnabled: false, pitchEnabled: false)) .mapStyle(.light) - .onCameraChanged { change in handleCameraChange(proxy.map, change) } + .onCameraChanged { change in handleCameraChange(change) } .ornamentOptions(.init(scaleBar: .init(visibility: .hidden))) - .onLayerTapGesture(stopLayerId) { _, _ in + .onLayerTapGesture(StopLayerGenerator.getStopLayerId(.stop)) { _, _ in // Each stop feature has the stop ID as the identifier // We can also set arbitrary JSON properties if we need to // print(feature.feature.identifier) true } .accessibilityIdentifier("transitMap") - .onAppear { handleAppear(location: proxy.location) } - .onChange(of: globalFetcher.stops) { stops in handleGlobalStops(proxy.map, stops) } - .onChange(of: railRouteShapeFetcher.response) { response in handleRouteResponse(proxy.map, response) } + .onAppear { handleAppear(location: proxy.location, map: proxy.map) } .onChange(of: locationDataManager.authorizationStatus) { status in if status == .authorizedAlways || status == .authorizedWhenInUse { Task { viewportProvider.follow(animation: .easeInOut(duration: 0)) } @@ -87,184 +79,70 @@ struct HomeMapView: View { var didAppear: ((Self) -> Void)? - func createRouteLayer(route: Route) -> Layer { - var routeLayer = LineLayer(id: getRouteLayerId(route.id), source: getRouteSourceId(route.id)) - routeLayer.lineWidth = .constant(4.0) - routeLayer.lineColor = .constant(StyleColor(UIColor(hex: route.color))) - routeLayer.lineBorderWidth = .constant(1.0) - routeLayer.lineBorderColor = .constant(StyleColor(.white)) - routeLayer.lineJoin = .constant(.round) - routeLayer.lineCap = .constant(.round) - return routeLayer - } - - func createRouteSourceData(route: Route, routesResponse: RouteResponse) -> GeoJSONSourceData { - let routeFeatures: [Feature] = route.routePatternIds! - .map { patternId -> RoutePattern? in - routesResponse.routePatterns[patternId] - } - .filter { pattern in - pattern?.typicality == .typical - } - .compactMap { pattern in - guard let pattern, - let representativeTrip = routesResponse.trips[pattern.representativeTripId], - let shapeId = representativeTrip.shapeId, - let shape = routesResponse.shapes[shapeId] else { return nil } - let polyline = Polyline(encodedPolyline: shape.polyline!) - return Feature(geometry: LineString(polyline.coordinates!)) - } - return .featureCollection(FeatureCollection(features: routeFeatures)) - } - - func createStopLayer() -> Layer { - var stopLayer = SymbolLayer(id: stopLayerId, source: stopSourceId) - stopLayer.iconImage = .constant(.name(stopIconId)) - stopLayer.iconAllowOverlap = .constant(true) - stopLayer.minZoom = HomeMapView.stopZoomThreshold - 0.25 - stopLayer.iconOpacity = .constant(0) - stopLayer.iconOpacityTransition = StyleTransition(duration: 1, delay: 0) - - return stopLayer - } - - func createStopSourceData(stops: [Stop]) -> GeoJSONSourceData { - let stopFeatures = stops - .filter { stop in - stop.parentStationId == nil - } - .map { stop in - var stopFeature = Feature( - geometry: Point(CLLocationCoordinate2D(latitude: stop.latitude, longitude: stop.longitude)) - ) - stopFeature.identifier = FeatureIdentifier(stop.id) - return stopFeature - } - - return .featureCollection(FeatureCollection(features: stopFeatures)) - } - - func getRouteSourceId(_ routeId: String) -> String { "\(routeSourceId)-\(routeId)" } - func getRouteLayerId(_ routeId: String) -> String { "\(routeLayerId)-\(routeId)" } + func handleAppear(location: LocationManager?, map: MapboxMap?) { + // Wait for routes and stops to both load before initializing layers + layerInitDispatchGroup.enter() + Task { + try await globalFetcher.getGlobalData() + layerInitDispatchGroup.leave() + } + layerInitDispatchGroup.enter() + Task { + try await railRouteShapeFetcher.getRailRouteShapes() + layerInitDispatchGroup.leave() + } + layerInitDispatchGroup.notify(queue: .main) { + guard let map, + let globalResponse = globalFetcher.response, + let routeResponse = railRouteShapeFetcher.response + else { return } + handleLayerInit(map, globalResponse.stops, routeResponse) + } - func handleAppear(location: LocationManager?) { + // Set MapBox to use the current location to display puck location?.override(locationProvider: locationDataManager.$currentLocation.map { if let location = $0 { [Location(clLocation: location)] } else { [] } }.eraseToSignal()) + // If location data is provided, follow the user's location Task { if locationDataManager.currentLocation != nil { - viewportProvider.follow(animation: .default(maxDuration: 0)) + viewportProvider.follow(animation: .easeInOut(duration: .zero)) } } - Task { - try await globalFetcher.getGlobalData() - } - Task { - try await railRouteShapeFetcher.getRailRouteShapes() - } didAppear?(self) } - func handleCameraChange(_ possibleMap: MapboxMap?, _ change: CameraChanged) { + func handleCameraChange(_ change: CameraChanged) { + guard let layerManager else { return } + layerManager.updateStopLayers(change.cameraState.zoom) cameraDebouncer.debounce { - guard let map = possibleMap else { return } viewportProvider.cameraState = change.cameraState - updateStopOpacity( - map: map, - opacity: change.cameraState.zoom > HomeMapView.stopZoomThreshold ? 1 : 0 - ) } } - func handleGlobalStops(_ possibleMap: MapboxMap?, _ stops: [Stop]) { - guard let map = possibleMap else { return } - if map.sourceExists(withId: stopSourceId) { - // Don't create a new source if one already exists - map.updateGeoJSONSource( - withId: stopSourceId, - data: createStopSourceData(stops: stops) - ) - } else { - // Create a GeoJSON data source for markers - var stopSource = GeoJSONSource(id: stopSourceId) - stopSource.data = createStopSourceData(stops: stops) - try? map.addSource(stopSource) - // Add marker image to the map - try? map.addImage(UIImage(named: "t-logo")!, id: stopIconId) - // Create a symbol layer for markers - try? map.addLayer(createStopLayer()) - } - } + func handleLayerInit(_ map: MapboxMap, _ stops: [Stop], _ routeResponse: RouteResponse) { + let layerManager = MapLayerManager(map: map) - func handleRouteResponse(_ possibleMap: MapboxMap?, _ response: RouteResponse?) { - guard let map = possibleMap else { return } - guard let routesResponse = response else { return } - // Reverse sort routes so lowest sorted ones are placed lowest on the map - let sortedRoutes = routesResponse.routes.sorted { aRoute, bRoute in - aRoute.sortOrder >= bRoute.sortOrder - } - for route in sortedRoutes { - if map.sourceExists(withId: getRouteSourceId(route.id)) { - // Don't create new sources if they already exist - map.updateGeoJSONSource( - withId: getRouteSourceId(route.id), - data: createRouteSourceData(route: route, routesResponse: routesResponse) - ) - } else { - // Create a GeoJSON data source for each typical route pattern shape in this route - var routeSource = GeoJSONSource(id: getRouteSourceId(route.id)) - routeSource.data = createRouteSourceData(route: route, routesResponse: routesResponse) - do { - try map.addSource(routeSource) - } catch { - let id = getRouteSourceId(route.id) - Logger().error("Failed to add route source \(id)\n\(error)") - } + let routeSourceGenerator = RouteSourceGenerator(routeData: routeResponse) + let stopSourceGenerator = StopSourceGenerator( + stops: stops, + routeSourceDetails: routeSourceGenerator.routeSourceDetails + ) + layerManager.addSources(sources: routeSourceGenerator.routeSources + stopSourceGenerator.stopSources) - do { - // Create a line layer for each route - if map.layerExists(withId: "puck") { - try map.addLayer(createRouteLayer(route: route), layerPosition: .below("puck")) - } else { - try map.addLayer(createRouteLayer(route: route)) - } - } catch { - let id = getRouteLayerId(route.id) - Logger().error("Failed to add route layer \(id)\n\(error)") - } - } - } + let routeLayerGenerator = RouteLayerGenerator(routeData: routeResponse) + let stopLayerGenerator = StopLayerGenerator(stopLayerTypes: MapLayerManager.stopLayerTypes) + layerManager.addLayers(layers: routeLayerGenerator.routeLayers + stopLayerGenerator.stopLayers) + + self.layerManager = layerManager } func isNearbyNotFollowing() -> Bool { nearbyFetcher.loadedLocation != nil && nearbyFetcher.loadedLocation != locationDataManager.currentLocation?.coordinate } - - func updateStopOpacity(map: MapboxMap?, opacity: Double) { - try? map?.updateLayer(withId: stopLayerId, type: SymbolLayer.self) { layer in - if layer.iconOpacity != .constant(opacity) { - layer.iconOpacity = .constant(opacity) - } - } - } -} - -struct RecenterButton: View { - var perform: () -> Void - var body: some View { - Image(systemName: "location") - .frame(width: 50, height: 50) - .foregroundColor(.white) - .background(.gray.opacity(0.8)) - .clipShape(Circle()) - .padding(20) - .onTapGesture(perform: perform) - .transition(AnyTransition.opacity.animation(.linear(duration: 0.25))) - .accessibilityIdentifier("mapRecenterButton") - } } diff --git a/iosApp/iosApp/Pages/Map/MapLayerManager.swift b/iosApp/iosApp/Pages/Map/MapLayerManager.swift new file mode 100644 index 000000000..112b347ed --- /dev/null +++ b/iosApp/iosApp/Pages/Map/MapLayerManager.swift @@ -0,0 +1,78 @@ +// +// MapLayerManager.swift +// iosApp +// +// Created by Simon, Emma on 3/25/24. +// Copyright © 2024 MBTA. All rights reserved. +// + +import os +import shared +import SwiftUI +@_spi(Experimental) import MapboxMaps + +class MapLayerManager { + let map: MapboxMap + + static let stopZoomThreshold: Double = 13.0 + static let tombstoneZoomThreshold: Double = 16.0 + + static let stationIconId = "t-station" + static let stopIconId = "bus-stop" + static let stopIconSmallId = "bus-stop-small" + static let stopIcons: [String] = [stationIconId, stopIconId, stopIconSmallId] + + static let stopLayerTypes: [LocationType] = [.stop, .station] + + init(map: MapboxMap) { + self.map = map + + for iconId in Self.stopIcons { + do { + try map.addImage(UIImage(named: iconId)!, id: iconId) + } catch { + Logger().error("Failed to add map stop icon image \(iconId)") + } + } + } + + func addSources(sources: [GeoJSONSource]) { + for source in sources { + do { + try map.addSource(source) + } catch { + Logger().error("Failed to add source \(source.id)\n\(error)") + } + } + } + + func addLayers(layers: [Layer]) { + for layer in layers { + do { + if map.layerExists(withId: "puck") { + try map.addLayer(layer, layerPosition: .below("puck")) + } else { + try map.addLayer(layer) + } + } catch { + Logger().error("Failed to add layer \(layer.id)\n\(error)") + } + } + } + + func updateStopLayers(_ zoomLevel: CGFloat) { + let opacity = zoomLevel > Self.stopZoomThreshold ? 1.0 : 0.0 + for layerType: LocationType in Self.stopLayerTypes { + let layerId = StopLayerGenerator.getStopLayerId(layerType) + do { + try map.updateLayer(withId: layerId, type: SymbolLayer.self) { layer in + if layer.iconOpacity != .constant(opacity) { + layer.iconOpacity = .constant(opacity) + } + } + } catch { + Logger().error("Failed to update layer \(layerId)\n\(error)") + } + } + } +} diff --git a/iosApp/iosApp/Pages/Map/RecenterButton.swift b/iosApp/iosApp/Pages/Map/RecenterButton.swift new file mode 100644 index 000000000..173b68e07 --- /dev/null +++ b/iosApp/iosApp/Pages/Map/RecenterButton.swift @@ -0,0 +1,24 @@ +// +// RecenterButton.swift +// iosApp +// +// Created by Simon, Emma on 3/25/24. +// Copyright © 2024 MBTA. All rights reserved. +// + +import SwiftUI + +struct RecenterButton: View { + var perform: () -> Void + var body: some View { + Image(systemName: "location") + .frame(width: 50, height: 50) + .foregroundColor(.white) + .background(.gray.opacity(0.8)) + .clipShape(Circle()) + .padding(20) + .onTapGesture(perform: perform) + .transition(AnyTransition.opacity.animation(.linear(duration: 0.25))) + .accessibilityIdentifier("mapRecenterButton") + } +} diff --git a/iosApp/iosApp/Pages/Map/RouteLayerGenerator.swift b/iosApp/iosApp/Pages/Map/RouteLayerGenerator.swift new file mode 100644 index 000000000..a7d93a975 --- /dev/null +++ b/iosApp/iosApp/Pages/Map/RouteLayerGenerator.swift @@ -0,0 +1,45 @@ +// +// RouteLayerGenerator.swift +// iosApp +// +// Created by Simon, Emma on 3/25/24. +// Copyright © 2024 MBTA. All rights reserved. +// + +import shared +import SwiftUI +@_spi(Experimental) import MapboxMaps + +class RouteLayerGenerator { + let routeData: RouteResponse + let routeLayers: [LineLayer] + + static let routeLayerId = "route-layer" + static func getRouteLayerId(_ routeId: String) -> String { "\(routeLayerId)-\(routeId)" } + + init(routeData: RouteResponse) { + self.routeData = routeData + routeLayers = Self.createRouteLayers(routes: routeData.routes) + } + + static func createRouteLayers(routes: [Route]) -> [LineLayer] { + // Sort by reverse sort order so that lowest ordered routes are drawn first/lowest + routes + .sorted { $0.sortOrder >= $1.sortOrder } + .map { createRouteLayer(route: $0) } + } + + static func createRouteLayer(route: Route) -> LineLayer { + var routeLayer = LineLayer( + id: Self.getRouteLayerId(route.id), + source: RouteSourceGenerator.getRouteSourceId(route.id) + ) + routeLayer.lineWidth = .constant(4.0) + routeLayer.lineColor = .constant(StyleColor(UIColor(hex: route.color))) + routeLayer.lineBorderWidth = .constant(1.0) + routeLayer.lineBorderColor = .constant(StyleColor(.white)) + routeLayer.lineJoin = .constant(.round) + routeLayer.lineCap = .constant(.round) + return routeLayer + } +} diff --git a/iosApp/iosApp/Pages/Map/RouteSourceGenerator.swift b/iosApp/iosApp/Pages/Map/RouteSourceGenerator.swift new file mode 100644 index 000000000..aec6fa606 --- /dev/null +++ b/iosApp/iosApp/Pages/Map/RouteSourceGenerator.swift @@ -0,0 +1,85 @@ +// +// RouteSourceGenerator.swift +// iosApp +// +// Created by Simon, Emma on 3/25/24. +// Copyright © 2024 MBTA. All rights reserved. +// + +import Polyline +import shared +@_spi(Experimental) import MapboxMaps + +class RouteLineData { + let routePatternId: String + let line: LineString + let stopIds: [String] + + init(routePatternId: String, line: LineString, stopIds: [String]) { + self.routePatternId = routePatternId + self.line = line + self.stopIds = stopIds + } +} + +class RouteSourceData { + let route: Route + let lines: [RouteLineData] + let source: GeoJSONSource + + init(route: Route, lines: [RouteLineData], source: GeoJSONSource) { + self.route = route + self.lines = lines + self.source = source + } +} + +class RouteSourceGenerator { + let routeData: RouteResponse + + let routeSourceDetails: [RouteSourceData] + let routeSources: [GeoJSONSource] + + static let routeSourceId = "route-source" + static func getRouteSourceId(_ routeId: String) -> String { "\(routeSourceId)-\(routeId)" } + + init(routeData: RouteResponse) { + self.routeData = routeData + routeSourceDetails = Self.generateRouteSources(routeData: routeData) + routeSources = routeSourceDetails.map(\.source) + } + + static func generateRouteSources(routeData: RouteResponse) -> [RouteSourceData] { + routeData.routes.map { Self.generateRouteSource(route: $0, routeData: routeData) } + } + + static func generateRouteSource(route: Route, routeData: RouteResponse) -> RouteSourceData { + let routeLines = Self.generateRouteLines(route: route, routeData: routeData) + let routeFeatures: [Feature] = routeLines.map { Feature(geometry: $0.line) } + var routeSource = GeoJSONSource(id: Self.getRouteSourceId(route.id)) + routeSource.data = .featureCollection(FeatureCollection(features: routeFeatures)) + + return .init(route: route, lines: routeLines, source: routeSource) + } + + static func generateRouteLines(route: Route, routeData: RouteResponse) -> [RouteLineData] { + (route.routePatternIds ?? []) + .map { routeData.routePatterns[$0] } + .filter { $0?.typicality == .typical } + .compactMap { pattern in + guard let pattern, + let representativeTrip = routeData.trips[pattern.representativeTripId], + let shapeId = representativeTrip.shapeId, + let shape = routeData.shapes[shapeId], + let polyline = shape.polyline, + let coordinates = Polyline(encodedPolyline: polyline).coordinates + else { return nil } + + return .init( + routePatternId: pattern.id, + line: LineString(coordinates), + stopIds: representativeTrip.stopIds ?? [] + ) + } + } +} diff --git a/iosApp/iosApp/Pages/Map/StopLayerGenerator.swift b/iosApp/iosApp/Pages/Map/StopLayerGenerator.swift new file mode 100644 index 000000000..613e33d55 --- /dev/null +++ b/iosApp/iosApp/Pages/Map/StopLayerGenerator.swift @@ -0,0 +1,59 @@ +// +// StopLayerGenerator.swift +// iosApp +// +// Created by Simon, Emma on 3/25/24. +// Copyright © 2024 MBTA. All rights reserved. +// + +import shared +import SwiftUI +@_spi(Experimental) import MapboxMaps + +class StopLayerGenerator { + let stopLayerTypes: [LocationType] + let stopLayers: [SymbolLayer] + + static let stopLayerId = "stop-layer" + static func getStopLayerId(_ locationType: LocationType) -> String { + "\(stopLayerId)-\(locationType.name)" + } + + init(stopLayerTypes: [LocationType]) { + self.stopLayerTypes = stopLayerTypes + stopLayers = Self.createStopLayers(stopLayerTypes: stopLayerTypes) + } + + static func createStopLayers(stopLayerTypes: [LocationType]) -> [SymbolLayer] { + stopLayerTypes.map { Self.createStopLayer(locationType: $0) } + } + + static func createStopLayer(locationType: LocationType) -> SymbolLayer { + let layerId = Self.getStopLayerId(locationType) + let sourceId = StopSourceGenerator.getStopSourceId(locationType) + var stopLayer = SymbolLayer(id: layerId, source: sourceId) + stopLayer.iconImage = Self.getStopLayerIcon(locationType) + stopLayer.iconAllowOverlap = .constant(true) + stopLayer.minZoom = MapLayerManager.stopZoomThreshold - 1 + stopLayer.iconOpacity = .constant(0) + stopLayer.iconOpacityTransition = StyleTransition(duration: 1, delay: 0) + + return stopLayer + } + + static func getStopLayerIcon(_ locationType: LocationType) -> Value { + switch locationType { + case .station: + .constant(.name(MapLayerManager.stationIconId)) + case .stop: + .expression(Exp(.step) { + Exp(.zoom) + MapLayerManager.stopIconSmallId + MapLayerManager.tombstoneZoomThreshold + MapLayerManager.stopIconId + }) + default: + .constant(.name("")) + } + } +} diff --git a/iosApp/iosApp/Pages/Map/StopSourceGenerator.swift b/iosApp/iosApp/Pages/Map/StopSourceGenerator.swift new file mode 100644 index 000000000..5f70eacf1 --- /dev/null +++ b/iosApp/iosApp/Pages/Map/StopSourceGenerator.swift @@ -0,0 +1,87 @@ +// +// StopSourceGenerator.swift +// iosApp +// +// Created by Simon, Emma on 3/25/24. +// Copyright © 2024 MBTA. All rights reserved. +// + +import Polyline +import shared +@_spi(Experimental) import MapboxMaps + +struct StopFeatureData { + let stop: Stop + let feature: Feature +} + +class StopSourceGenerator { + let stops: [Stop] + let routeSourceDetails: [RouteSourceData]? + + var stopSources: [GeoJSONSource] = [] + + private var stopsById: [String: Stop] + private var stopFeatures: [StopFeatureData] = [] + private var touchedStopIds: Set = [] + + static let stopSourceId = "stop-source" + static func getStopSourceId(_ locationType: LocationType) -> String { + "\(stopSourceId)-\(locationType.name)" + } + + init(stops: [Stop], routeSourceDetails: [RouteSourceData]? = nil) { + self.stops = stops + self.routeSourceDetails = routeSourceDetails + + stopsById = stops.reduce(into: [String: Stop]()) { map, stop in map[stop.id] = stop } + stopFeatures = generateStopFeatures() + stopSources = generateStopSources() + } + + func generateStopFeatures() -> [StopFeatureData] { + generateRouteAssociatedStops() + generateRemainingStops() + } + + func generateRouteAssociatedStops() -> [StopFeatureData] { + guard let routeSourceDetails else { return [] } + return routeSourceDetails.flatMap { routeSource in + routeSource.lines.flatMap { lineData in + lineData.stopIds.compactMap { childStopId in + guard let stopOnRoute = stopsById[childStopId] else { return nil } + guard let stop = stopOnRoute.resolveParent(stopsById) else { return nil } + + if touchedStopIds.contains(stop.id) { return nil } + + let snappedCoord = lineData.line.closestCoordinate(to: stop.coordinate) + var stopFeature = Feature(geometry: Point(snappedCoord?.coordinate ?? stop.coordinate)) + stopFeature.identifier = FeatureIdentifier(stop.id) + + touchedStopIds.insert(stop.id) + return .init(stop: stop, feature: stopFeature) + } + } + } + } + + func generateRemainingStops() -> [StopFeatureData] { + stops.compactMap { stop in + if touchedStopIds.contains(stop.id) { return nil } + if stop.parentStationId != nil { return nil } + + var stopFeature = Feature(geometry: Point(stop.coordinate)) + stopFeature.identifier = FeatureIdentifier(stop.id) + + touchedStopIds.insert(stop.id) + return .init(stop: stop, feature: stopFeature) + } + } + + func generateStopSources() -> [GeoJSONSource] { + Dictionary(grouping: stopFeatures, by: { $0.stop.locationType }).map { type, featureData in + var stopSource = GeoJSONSource(id: Self.getStopSourceId(type)) + stopSource.data = .featureCollection(FeatureCollection(features: featureData.map(\.feature))) + return stopSource + } + } +} diff --git a/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitView.swift b/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitView.swift index 0c279342a..db901548e 100644 --- a/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitView.swift +++ b/iosApp/iosApp/Pages/NearbyTransit/NearbyTransitView.swift @@ -126,6 +126,7 @@ struct NearbyTransitView_Previews: PreviewProvider { latitude: 42.265969, longitude: -70.969853, name: "Sea St opp Peterson Rd", + locationType: LocationType.stop, parentStationId: nil ) let busTrip = Trip( @@ -189,6 +190,7 @@ struct NearbyTransitView_Previews: PreviewProvider { latitude: 42.265969, longitude: -70.969853, name: "South Station", + locationType: LocationType.stop, parentStationId: nil ) let crTrip = Trip( diff --git a/iosApp/iosApp/Utils/StopExtension.swift b/iosApp/iosApp/Utils/StopExtension.swift new file mode 100644 index 000000000..2e67c4164 --- /dev/null +++ b/iosApp/iosApp/Utils/StopExtension.swift @@ -0,0 +1,23 @@ +// +// StopExtension.swift +// iosApp +// +// Created by Simon, Emma on 3/22/24. +// Copyright © 2024 MBTA. All rights reserved. +// + +import CoreLocation +import shared + +extension Stop { + var coordinate: CLLocationCoordinate2D { + .init(latitude: latitude, longitude: longitude) + } + + // TODO: This should be moved into a stop caching layer once it exists + func resolveParent(_ stopMap: [String: Stop]) -> Stop? { + guard let parentStationId else { return self } + guard let parent = stopMap[parentStationId] else { return nil } + return parent.resolveParent(stopMap) + } +} diff --git a/iosApp/iosApp/ViewportProvider.swift b/iosApp/iosApp/ViewportProvider.swift index 1e372ca90..ff8549a59 100644 --- a/iosApp/iosApp/ViewportProvider.swift +++ b/iosApp/iosApp/ViewportProvider.swift @@ -10,7 +10,7 @@ class ViewportProvider: ObservableObject { static let defaultCenter: CLLocationCoordinate2D = .init(latitude: 42.356395, longitude: -71.062424) - static let defaultZoom: CGFloat = 14 + static let defaultZoom: CGFloat = MapLayerManager.stopZoomThreshold + 0.25 @Published var viewport: Viewport @Published var cameraState: CameraState diff --git a/iosApp/iosAppTests/Pages/Map/MapTestDataHelper.swift b/iosApp/iosAppTests/Pages/Map/MapTestDataHelper.swift new file mode 100644 index 000000000..bc6cf71b1 --- /dev/null +++ b/iosApp/iosAppTests/Pages/Map/MapTestDataHelper.swift @@ -0,0 +1,109 @@ +// +// MapTestDataHelper.swift +// iosAppTests +// +// Created by Simon, Emma on 3/26/24. +// Copyright © 2024 MBTA. All rights reserved. +// + +@testable import iosApp +import shared + +enum MapTestDataHelper { + static let objects = ObjectCollectionBuilder() + static let routeRed = objects.route { route in + route.id = "Red" + route.color = "DA291C" + route.routePatternIds = ["Red-1-0", "Red-3-0"] + } + + static let routeOrange = objects.route { route in + route.id = "Orange" + route.color = "ED8B00" + route.routePatternIds = ["Orange-3-0", "Orange-7-0"] + } + + static let patternRed10 = objects.routePattern(route: routeRed) { pattern in + pattern.id = "Red-1-0" + pattern.typicality = .typical + pattern.representativeTripId = "canonical-Red-C2-0" + } + + static let patternRed30 = objects.routePattern(route: routeRed) { pattern in + pattern.id = "Red-3-0" + pattern.typicality = .typical + pattern.representativeTripId = "canonical-Red-C1-0" + } + + static let patternOrange30 = objects.routePattern(route: routeOrange) { pattern in + pattern.id = "Orange-3-0" + pattern.typicality = .typical + pattern.representativeTripId = "canonical-Orange-C1-0" + } + + static let patternOrange70 = objects.routePattern(route: routeOrange) { pattern in + pattern.id = "Orange-7-0" + pattern.typicality = .atypical + pattern.representativeTripId = "61746557" + } + + static let tripRedC1 = objects.trip(routePattern: patternRed30) { trip in + trip.id = "canonical-Red-C1-0" + trip.shapeId = "canonical-933_0009" + } + + static let tripRedC2 = objects.trip(routePattern: patternRed10) { trip in + trip.id = "canonical-Red-C2-0" + trip.shapeId = "canonical-931_0009" + trip.stopIds = ["70061"] + } + + static let tripOrangeC1 = objects.trip(routePattern: patternOrange30) { trip in + trip.id = "canonical-Orange-C1-0" + trip.shapeId = "canonical-903_0018" + } + + static let tripOrangeAtypical = objects.trip(routePattern: patternOrange30) { trip in + trip.id = "61746557" + trip.shapeId = "40460002" + } + + static let shapeRedC1 = objects.shape { shape in + shape.id = "canonical-933_0009" + shape.polyline = "}nwaG~|eqLGyNIqAAc@S_CAEWu@g@}@u@k@u@Wu@OMGIMISQkAOcAGw@SoDFkCf@sUXcJJuERwHPkENqCJmB^mDn@}D??D[TeANy@\\iAt@qB`AwBl@cAl@m@b@Yn@QrBEtCKxQ_ApMT??R?`m@hD`Np@jAF|@C`B_@hBi@n@s@d@gA`@}@Z_@RMZIl@@fBFlB\\tAP??~@L^?HCLKJWJ_@vC{NDGLQvG}HdCiD`@e@Xc@b@oAjEcPrBeGfAsCvMqVl@sA??jByD`DoGd@cAj@cBJkAHqBNiGXeHVmJr@kR~@q^HsB@U??NgDr@gJTcH`@aMFyCF}AL}DN}GL}CXkILaD@QFmA@[??DaAFiBDu@BkA@UB]Fc@Jo@BGJ_@Lc@\\}@vJ_OrCyDj@iAb@_AvBuF`@gA`@aAv@qBVo@Xu@??bDgI??Tm@~IsQj@cAr@wBp@kBj@kB??HWtDcN`@g@POl@UhASh@Eb@?t@FXHl@Px@b@he@h[pCC??bnAm@h@T??xF|BpBp@^PLBXAz@Yl@]l@e@|B}CT[p@iA|A}BZi@jBeDnAiBz@iAf@k@l@g@dAs@fAe@|@WpCe@l@GTCRE\\G??~@O`@ELA|AGf@A\\CjCGrEKz@AdEAxHY|BD~@JjB^fF~AdDbA|InCxCv@zD|@rWfEXDpB`@tANvAHx@AjBIx@M~@S~@a@fAi@HEnA{@fA{@|HuI|DwEbDqDpLkNhCyClEiFhLaN`@c@f@o@RURUbDsDbAiA`AgAv@_AHKHI~E}FdBoBfAgAfD{DxDoE~DcF|BkClAwALODEJOJK|@gATWvAoA`Au@fAs@hAk@n@QpAa@vDeAhA[x@Yh@Wv@a@b@YfAaAjCgCz@aAtByBz@{@??|FaGtCaDbL{LhI{IzHgJdAuAjC{CVYvAwA??JIl@a@NMNM\\[|AuArF_GlPyQrD_ErAwAd@e@nE{ErDuD\\a@nE_FZYPSRUvL{Mv@}@Z[JILKv@m@z@i@fCkAlBmAl@[t@[??h@WxBeAp@]dAi@p@YXIPEXKDALENEbAQl@Gz@ChADtAL~ARnCZbGx@xB`@TDL@PBzAVjIvA^FVDVB|@NjHlAlPnCnCd@vBXhBNv@JtAPL@|BXrAN??`@FRBj@Bp@FbADz@?dAIp@I|@Mx@Q`AWhAYlBs@pDaBzAs@nBgAZQJGJGhAs@RKVMNKTMf@YdHcEzBmApAw@`GmDLI@AHGlEwClAi@hA_@v@Up@ObB]z@Kr@Ir@EZCpA?dCRf@DpAHvANrE`@bDTr@DfMdA`CJvBRn@DnCLnBPfAFV@" + } + + static let shapeRedC2 = objects.shape { shape in + shape.id = "canonical-931_0009" + shape.polyline = "}nwaG~|eqLGyNIqAAc@S_CAEWu@g@}@u@k@u@Wu@OMGIMISQkAOcAGw@SoDFkCf@sUXcJJuERwHPkENqCJmB^mDn@}D??D[TeANy@\\iAt@qB`AwBl@cAl@m@b@Yn@QrBEtCKxQ_ApMT??R?`m@hD`Np@jAF|@C`B_@hBi@n@s@d@gA`@}@Z_@RMZIl@@fBFlB\\tAP??~@L^?HCLKJWJ_@vC{NDGLQvG}HdCiD`@e@Xc@b@oAjEcPrBeGfAsCvMqVl@sA??jByD`DoGd@cAj@cBJkAHqBNiGXeHVmJr@kR~@q^HsB@U??NgDr@gJTcH`@aMFyCF}AL}DN}GL}CXkILaD@QFmA@[??DaAFiBDu@BkA@UB]Fc@Jo@BGJ_@Lc@\\}@vJ_OrCyDj@iAb@_AvBuF`@gA`@aAv@qBVo@Xu@??bDgI??Tm@~IsQj@cAr@wBp@kBj@kB??HWtDcN`@g@POl@UhASh@Eb@?t@FXHl@Px@b@he@h[pCC??bnAm@h@T??xF|BpBp@^PLBXAz@Yl@]l@e@|B}CT[p@iA|A}BZi@zDuF\\c@n@s@VObAw@^Sl@Yj@U\\O|@WdAUxAQRCt@E??xAGrBQZAhAGlAEv@Et@E~@AdAAbCGpCA|BEjCMr@?nBDvANlARdBb@nDbA~@XnBp@\\JRH??|Al@`AZbA^jA^lA\\h@P|@TxAZ|@J~@LN?fBXxHhApDt@b@JXFtAVhALx@FbADtAC`B?z@BHBH@|@f@RN^^T\\h@hANb@HZH`@H^LpADlA@dD@jD@x@@b@Bp@HdAFd@Ll@F^??n@rDBRl@vD^pATp@Rb@b@z@\\l@`@j@p@t@j@h@n@h@n@`@hAh@n@\\t@PzANpAApBGtE}@xBa@??xB_@nOmB`OgBb@IrC[p@MbEmARCV@d@LH?tDyAXM" + } + + static let shapeOrangeC1 = objects.shape { shape in + shape.id = "canonical-903_0018" + shape.polyline = "an_bG|_xpLpBPrCZXBTBP@P@~Dd@dANlATjBd@tDhALDNDhA\\pIbClGjBz@ZhA\\xA`@XJpBl@??ZHpBn@HBfHvBfF~AhDbApA\\bAVND`Er@nALvCVfBLfFDb@?dBAjCEfB?pFDrHF~HFfB?vAC~FFpQJpB?|C@P?`B?V?X?h@B??l@DfEFL@f@BpCDxDFfABfA?lCBdB@fABnC@|C@tO@`ECfCI??|@ElESdCGdCAzA@pEEdBCnBItAAdBA^AT@nAAd@@`A?VAbC?fABzAD??nBDR?Z@v@@`DKtFCn@AZC`@Gr@Q\\MHEb@MzA{@zJeIlBaBhBoBzA}BlAoBrB_EVw@r@oBvAiE??DId@cBxCkGpGmK@Cda@o_@PO??tHkGbCuCh@e@b@Y????`@Sr@IlMg@zGB??T?vC?N@VN`ElDvAvBbBrBtA~A????v@t@hAtAhDbDb@f@t@p@VJj@Nh@@n@Bz@F??RBdD`@|B^XJRLz@`AxBvB|@N??xATNP`HrRJd@BZAf@OzCCbAApFCvG@hC?~F@t@@bB@jAFlAJ~AJf@???@Jb@DLRb@T^v@dA|AxBbB~BnHlJ^f@xAlBJHn@x@rBbClDnEt@fA??Zd@b@n@b@l@nDxEjN`RfAtApCrDb@h@RV??tCxDRTjLzNNRfEnFNN~AhBn@n@NJ`@\\????|@t@dE~CnA|@bAn@n@X`@Vd@R|@^\\Rf@Tp@Xn@R|@T~@ZpA`@l@NZHtEpAHBf@LbHrCvAr@??LFRLf@\\PJvA|@lA~@|AhAvCbCfDpCnA|@hCbBv@b@vAt@lAj@VL??JDd@TtBz@hA^RFvD`AdAZhBl@pA\\??~CfAzAh@vDfBxC`BXNb@T??NJdAj@~CdB|@h@h@Zv@h@fDhBHDnFvCdAn@vC`B~BrAvA~@|CjBrA~@hBhALJl@d@NHNJnDvBhAr@" + } + + static let shapeOrangeAtypical = objects.shape { shape in shape.id = "40460002" } + + static let routeResponse = RouteResponse( + routes: [routeRed, routeOrange], + routePatterns: [ + "Red-1-0": patternRed10, + "Red-3-0": patternRed30, + "Orange-3-0": patternOrange30, + "Orange-7-0": patternOrange70, + ], + shapes: [ + "canonical-933_0009": shapeRedC1, + "canonical-931_0009": shapeRedC2, + "canonical-903_0018": shapeOrangeC1, + "40460002": shapeOrangeAtypical, + ], + trips: [ + "canonical-Red-C1-0": tripRedC1, + "canonical-Red-C2-0": tripRedC2, + "canonical-Orange-C1-0": tripOrangeC1, + "61746557": tripOrangeAtypical, + ] + ) +} diff --git a/iosApp/iosAppTests/Pages/Map/RouteLayerGeneratorTests.swift b/iosApp/iosAppTests/Pages/Map/RouteLayerGeneratorTests.swift new file mode 100644 index 000000000..422fa3ecf --- /dev/null +++ b/iosApp/iosAppTests/Pages/Map/RouteLayerGeneratorTests.swift @@ -0,0 +1,29 @@ +// +// RouteLayerGeneratorTests.swift +// iosAppTests +// +// Created by Simon, Emma on 3/26/24. +// Copyright © 2024 MBTA. All rights reserved. +// + +@testable import iosApp +import shared +import XCTest +@_spi(Experimental) import MapboxMaps + +final class RouteLayerGeneratorTests: XCTestCase { + override func setUp() { + executionTimeAllowance = 60 + } + + func testRouteLayersAreCreated() { + let routeLayerGenerator = RouteLayerGenerator(routeData: MapTestDataHelper.routeResponse) + let routeLayers = routeLayerGenerator.routeLayers + + XCTAssertEqual(routeLayers.count, 2) + let redRouteLayer = routeLayers.first { $0.id == RouteLayerGenerator.getRouteLayerId(MapTestDataHelper.routeRed.id) } + XCTAssertNotNil(redRouteLayer) + guard let redRouteLayer else { return } + XCTAssertEqual(redRouteLayer.lineColor, .constant(StyleColor(.init(hex: MapTestDataHelper.routeRed.color)))) + } +} diff --git a/iosApp/iosAppTests/Pages/Map/RouteSourceGeneratorTests.swift b/iosApp/iosAppTests/Pages/Map/RouteSourceGeneratorTests.swift new file mode 100644 index 000000000..f23e49b79 --- /dev/null +++ b/iosApp/iosAppTests/Pages/Map/RouteSourceGeneratorTests.swift @@ -0,0 +1,49 @@ +// +// RouteSourceGeneratorTests.swift +// iosAppTests +// +// Created by Simon, Emma on 3/26/24. +// Copyright © 2024 MBTA. All rights reserved. +// + +@testable import iosApp +import Polyline +import shared +import XCTest +@_spi(Experimental) import MapboxMaps + +final class RouteSourceGeneratorTests: XCTestCase { + override func setUp() { + executionTimeAllowance = 60 + } + + func testRouteSourcesAreCreated() { + let routeSourceGenerator = RouteSourceGenerator(routeData: MapTestDataHelper.routeResponse) + + XCTAssertEqual(routeSourceGenerator.routeSources.count, 2) + + let redSource = routeSourceGenerator.routeSources.first { $0.id == RouteSourceGenerator.getRouteSourceId(MapTestDataHelper.routeRed.id) } + XCTAssertNotNil(redSource) + if case let .featureCollection(collection) = redSource!.data.unsafelyUnwrapped { + XCTAssertEqual(collection.features.count, 2) + XCTAssertEqual( + collection.features[0].geometry, + .lineString(LineString(Polyline(encodedPolyline: MapTestDataHelper.shapeRedC2.polyline!).coordinates!)) + ) + } else { + XCTFail("Red route source had no features") + } + + let orangeSource = routeSourceGenerator.routeSources.first { $0.id == RouteSourceGenerator.getRouteSourceId(MapTestDataHelper.routeOrange.id) } + XCTAssertNotNil(orangeSource) + if case let .featureCollection(collection) = orangeSource!.data.unsafelyUnwrapped { + XCTAssertEqual(collection.features.count, 1) + XCTAssertEqual( + collection.features[0].geometry, + .lineString(LineString(Polyline(encodedPolyline: MapTestDataHelper.shapeOrangeC1.polyline!).coordinates!)) + ) + } else { + XCTFail("Orange route source had no features") + } + } +} diff --git a/iosApp/iosAppTests/Pages/Map/StopLayerGeneratorTests.swift b/iosApp/iosAppTests/Pages/Map/StopLayerGeneratorTests.swift new file mode 100644 index 000000000..f8e650638 --- /dev/null +++ b/iosApp/iosAppTests/Pages/Map/StopLayerGeneratorTests.swift @@ -0,0 +1,29 @@ +// +// StopLayerGeneratorTests.swift +// iosAppTests +// +// Created by Simon, Emma on 3/26/24. +// Copyright © 2024 MBTA. All rights reserved. +// + +@testable import iosApp +import shared +import XCTest +@_spi(Experimental) import MapboxMaps + +final class StopLayerGeneratorTests: XCTestCase { + override func setUp() { + executionTimeAllowance = 60 + } + + func testStopLayersAreCreated() { + let stopLayerGenerator = StopLayerGenerator(stopLayerTypes: [.stop, .station]) + let stopLayers = stopLayerGenerator.stopLayers + + XCTAssertEqual(stopLayers.count, 2) + let stationLayer = stopLayers.first { $0.id == StopLayerGenerator.getStopLayerId(.station) } + XCTAssertNotNil(stationLayer) + guard let stationLayer else { return } + XCTAssertEqual(stationLayer.iconImage, StopLayerGenerator.getStopLayerIcon(.station)) + } +} diff --git a/iosApp/iosAppTests/Pages/Map/StopSourceGeneratorTests.swift b/iosApp/iosAppTests/Pages/Map/StopSourceGeneratorTests.swift new file mode 100644 index 000000000..01962262a --- /dev/null +++ b/iosApp/iosAppTests/Pages/Map/StopSourceGeneratorTests.swift @@ -0,0 +1,195 @@ +// +// StopSourceGeneratorTests.swift +// iosAppTests +// +// Created by Simon, Emma on 3/26/24. +// Copyright © 2024 MBTA. All rights reserved. +// + +@testable import iosApp +import shared +import XCTest +@_spi(Experimental) import MapboxMaps + +final class StopSourceGeneratorTests: XCTestCase { + override func setUp() { + executionTimeAllowance = 60 + } + + func testStopSourcesAreCreated() { + let objects = ObjectCollectionBuilder() + + let stop1 = objects.stop { stop in + stop.id = "place-aqucl" + stop.name = "Aquarium" + stop.latitude = 42.359784 + stop.longitude = -71.051652 + stop.locationType = .station + } + let stop2 = objects.stop { stop in + stop.id = "place-armnl" + stop.name = "Arlington" + stop.latitude = 42.351902 + stop.longitude = -71.070893 + stop.locationType = .station + } + let stop3 = objects.stop { stop in + stop.id = "place-asmnl" + stop.name = "Ashmont" + stop.latitude = 42.28452 + stop.longitude = -71.063777 + stop.locationType = .station + } + let stop4 = objects.stop { stop in + stop.id = "1432" + stop.name = "Arsenal St @ Irving St" + stop.latitude = 42.364737 + stop.longitude = -71.178564 + stop.locationType = .stop + } + let stop5 = objects.stop { stop in + stop.id = "14320" + stop.name = "Adams St @ Whitwell St" + stop.latitude = 42.253069 + stop.longitude = -71.017292 + stop.locationType = .stop + } + let stop6 = objects.stop { stop in + stop.id = "13" + stop.name = "Andrew" + stop.latitude = 42.329962 + stop.longitude = -71.057625 + stop.locationType = .stop + stop.parentStationId = "place-andrw" + } + + let stopSourceGenerator = StopSourceGenerator(stops: [stop1, stop2, stop3, stop4, stop5, stop6]) + let sources = stopSourceGenerator.stopSources + XCTAssertEqual(sources.count, 2) + + let sourceIds = sources.map(\.id) + XCTAssert(sourceIds.contains(StopSourceGenerator.getStopSourceId(.station))) + XCTAssert(sourceIds.contains(StopSourceGenerator.getStopSourceId(.stop))) + + let stationSource = sources.first { $0.id == StopSourceGenerator.getStopSourceId(.station) } + XCTAssertNotNil(stationSource) + if case let .featureCollection(collection) = stationSource!.data.unsafelyUnwrapped { + XCTAssertEqual(collection.features.count, 3) + XCTAssertEqual(collection.features[0].geometry, .point(Point(stop1.coordinate))) + } else { + XCTFail("Station source had no features") + } + + let stopSource = sources.first { $0.id == StopSourceGenerator.getStopSourceId(.stop) } + XCTAssertNotNil(stopSource) + if case let .featureCollection(collection) = stopSource!.data.unsafelyUnwrapped { + XCTAssertEqual(collection.features.count, 2) + XCTAssertEqual(collection.features[0].geometry, .point(Point(stop4.coordinate))) + } else { + XCTFail("Stop source had no features") + } + } + + func testStopsAreSnappedToRoutes() { + let objects = MapTestDataHelper.objects + + let stops = [ + objects.stop { stop in + stop.id = "70061" + stop.name = "Alewife" + stop.latitude = 42.396158 + stop.longitude = -71.139971 + stop.locationType = .stop + stop.parentStationId = "place-alfcl" + }, + objects.stop { stop in + stop.id = "place-alfcl" + stop.name = "Alewife" + stop.latitude = 42.39583 + stop.longitude = -71.141287 + stop.locationType = .station + }, + objects.stop { stop in + stop.id = "place-astao" + stop.name = "Assembly" + stop.latitude = 42.392811 + stop.longitude = -71.077257 + stop.locationType = .station + }, + ] + + let routeSourceGenerator = RouteSourceGenerator(routeData: MapTestDataHelper.routeResponse) + let stopSourceGenerator = StopSourceGenerator(stops: stops, routeSourceDetails: routeSourceGenerator.routeSourceDetails) + let sources = stopSourceGenerator.stopSources + let snappedStopCoordinates = CLLocationCoordinate2D(latitude: 42.39616238508952, longitude: -71.14129664308807) + + let stationSource = sources.first { $0.id == StopSourceGenerator.getStopSourceId(.station) } + XCTAssertNotNil(stationSource) + if case let .featureCollection(collection) = stationSource!.data.unsafelyUnwrapped { + XCTAssertEqual(collection.features.count, 2) + if case let .point(point) = collection.features[0].geometry { + XCTAssertEqual(point.coordinates, snappedStopCoordinates) + } else { + XCTFail("Source feature was not a point") + } + } else { + XCTFail("Station source had no features") + } + } +} + +func getSnapTestRouteResponse(_ objects: ObjectCollectionBuilder) -> RouteResponse { + let routeRed = objects.route { route in + route.id = "Red" + route.routePatternIds = ["Red-1-0"] + } + + let patternRed10 = objects.routePattern(route: routeRed) { pattern in + pattern.id = "Red-1-0" + pattern.typicality = .typical + pattern.representativeTripId = "canonical-Red-C2-0" + } + + let tripRedC2 = objects.trip(routePattern: patternRed10) { trip in + trip.id = "canonical-Red-C2-0" + trip.shapeId = "canonical-931_0009" + trip.stopIds = ["70061"] + } + + let shapeRedC2 = objects.shape { shape in + shape.id = "canonical-931_0009" + shape.polyline = "}nwaG~|eqLGyNIqAAc@S_CAEWu@g@}@u@k@u@Wu@OMGIMISQkAOcAGw@SoDFkCf@sUXcJJuERwHPkENqCJmB^mDn@}D??D[TeANy@\\iAt@qB`AwBl@cAl@m@b@Yn@QrBEtCKxQ_ApMT??R?`m@hD`Np@jAF|@C`B_@hBi@n@s@d@gA`@}@Z_@RMZIl@@fBFlB\\tAP??~@L^?HCLKJWJ_@vC{NDGLQvG}HdCiD`@e@Xc@b@oAjEcPrBeGfAsCvMqVl@sA??jByD`DoGd@cAj@cBJkAHqBNiGXeHVmJr@kR~@q^HsB@U??NgDr@gJTcH`@aMFyCF}AL}DN}GL}CXkILaD@QFmA@[??DaAFiBDu@BkA@UB]Fc@Jo@BGJ_@Lc@\\}@vJ_OrCyDj@iAb@_AvBuF`@gA`@aAv@qBVo@Xu@??bDgI??Tm@~IsQj@cAr@wBp@kBj@kB??HWtDcN`@g@POl@UhASh@Eb@?t@FXHl@Px@b@he@h[pCC??bnAm@h@T??xF|BpBp@^PLBXAz@Yl@]l@e@|B}CT[p@iA|A}BZi@zDuF\\c@n@s@VObAw@^Sl@Yj@U\\O|@WdAUxAQRCt@E??xAGrBQZAhAGlAEv@Et@E~@AdAAbCGpCA|BEjCMr@?nBDvANlARdBb@nDbA~@XnBp@\\JRH??|Al@`AZbA^jA^lA\\h@P|@TxAZ|@J~@LN?fBXxHhApDt@b@JXFtAVhALx@FbADtAC`B?z@BHBH@|@f@RN^^T\\h@hANb@HZH`@H^LpADlA@dD@jD@x@@b@Bp@HdAFd@Ll@F^??n@rDBRl@vD^pATp@Rb@b@z@\\l@`@j@p@t@j@h@n@h@n@`@hAh@n@\\t@PzANpAApBGtE}@xBa@??xB_@nOmB`OgBb@IrC[p@MbEmARCV@d@LH?tDyAXM" + } + + let stops = [ + objects.stop { stop in + stop.id = "70061" + stop.name = "Alewife" + stop.latitude = 42.396158 + stop.longitude = -71.139971 + stop.locationType = .stop + stop.parentStationId = "place-alfcl" + }, + objects.stop { stop in + stop.id = "place-alfcl" + stop.name = "Alewife" + stop.latitude = 42.39583 + stop.longitude = -71.141287 + stop.locationType = .station + }, + objects.stop { stop in + stop.id = "place-astao" + stop.name = "Assembly" + stop.latitude = 42.392811 + stop.longitude = -71.077257 + stop.locationType = .station + }, + ] + + return RouteResponse( + routes: [routeRed], + routePatterns: ["Red-1-0": patternRed10], + shapes: ["canonical-931_0009": shapeRedC2], + trips: ["canonical-Red-C2-0": tripRedC2] + ) +} diff --git a/iosApp/iosAppTests/Views/NearbyTransitViewTests.swift b/iosApp/iosAppTests/Views/NearbyTransitViewTests.swift index 28cb46bfa..f15b66dd1 100644 --- a/iosApp/iosAppTests/Views/NearbyTransitViewTests.swift +++ b/iosApp/iosAppTests/Views/NearbyTransitViewTests.swift @@ -384,7 +384,7 @@ final class NearbyTransitViewTests: XCTestCase { nearbyFetcher.nearbyByRouteAndStop = NearbyStaticData.companion.build { builder in builder.route(route: nearbyFetcher.nearbyByRouteAndStop!.data[0].route) { builder in - let lechmere = Stop(id: "place-lech", latitude: 90.12, longitude: 34.56, name: "Lechmere", parentStationId: nil) + let lechmere = Stop(id: "place-lech", latitude: 90.12, longitude: 34.56, name: "Lechmere", locationType: .station, parentStationId: nil) builder.stop(stop: lechmere) { _ in } } diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/LocationType.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/LocationType.kt new file mode 100644 index 000000000..ddb574790 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/LocationType.kt @@ -0,0 +1,13 @@ +package com.mbta.tid.mbta_app.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class LocationType { + @SerialName("stop") STOP, + @SerialName("station") STATION, + @SerialName("entrance_exit") ENTRANCE_EXIT, + @SerialName("generic_node") GENERIC_NODE, + @SerialName("boarding_area") BOARDING_AREA +} diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/ObjectCollectionBuilder.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/ObjectCollectionBuilder.kt index 3d1d054b8..ee766c151 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/ObjectCollectionBuilder.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/ObjectCollectionBuilder.kt @@ -25,6 +25,7 @@ class ObjectCollectionBuilder { val schedules = mutableMapOf() val stops = mutableMapOf() val trips = mutableMapOf() + val shapes = mutableMapOf() val vehicles = mutableMapOf() interface ObjectBuilder { @@ -247,14 +248,24 @@ class ObjectCollectionBuilder { block ) + class ShapeBuilder : ObjectBuilder { + var id = uuid() + var polyline = "" + + override fun built() = Shape(id, polyline) + } + + fun shape(block: ShapeBuilder.() -> Unit = {}) = build(shapes, ShapeBuilder(), block) + class StopBuilder : ObjectBuilder { var id = uuid() var latitude = 1.2 var longitude = 3.4 var name = "" + var locationType = LocationType.STOP var parentStationId: String? = null - override fun built() = Stop(id, latitude, longitude, name, parentStationId) + override fun built() = Stop(id, latitude, longitude, name, locationType, parentStationId) } fun stop(block: StopBuilder.() -> Unit = {}) = build(stops, StopBuilder(), block) @@ -294,6 +305,8 @@ class ObjectCollectionBuilder { fun trip(block: TripBuilder.() -> Unit = {}) = ObjectCollectionBuilder().trip(block) + fun shape(block: ShapeBuilder.() -> Unit = {}) = ObjectCollectionBuilder().shape(block) + fun schedule(block: ScheduleBuilder.() -> Unit = {}) = ObjectCollectionBuilder().schedule(block) diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Shape.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Shape.kt index 158d87b23..47754fe4a 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Shape.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Shape.kt @@ -2,4 +2,5 @@ package com.mbta.tid.mbta_app.model import kotlinx.serialization.Serializable -@Serializable data class Shape(val id: String, val polyline: String? = null) +@Serializable +data class Shape(override val id: String, val polyline: String? = null) : BackendObject diff --git a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Stop.kt b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Stop.kt index 7321ae0a6..145b7885d 100644 --- a/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Stop.kt +++ b/shared/src/commonMain/kotlin/com/mbta/tid/mbta_app/model/Stop.kt @@ -10,6 +10,7 @@ data class Stop( val latitude: Double, val longitude: Double, val name: String, + @SerialName("location_type") val locationType: LocationType, @SerialName("parent_station_id") val parentStationId: String? = null ) : BackendObject { val position = Position(latitude = latitude, longitude = longitude) diff --git a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/BackendTest.kt b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/BackendTest.kt index 3b5b48f3a..097e71031 100644 --- a/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/BackendTest.kt +++ b/shared/src/commonTest/kotlin/com/mbta/tid/mbta_app/BackendTest.kt @@ -1,5 +1,6 @@ package com.mbta.tid.mbta_app +import com.mbta.tid.mbta_app.model.LocationType import com.mbta.tid.mbta_app.model.Route import com.mbta.tid.mbta_app.model.RoutePattern import com.mbta.tid.mbta_app.model.RouteResult @@ -40,6 +41,7 @@ class BackendTest { "name": "Sawmill Brook Pkwy @ Walsh Rd", "latitude": 42.289904, "longitude": -71.191003, + "location_type": "stop", "parent_station": null }, { @@ -47,6 +49,7 @@ class BackendTest { "name": "Sawmill Brook Pkwy @ Walsh Rd", "latitude": 42.289995, "longitude": -71.191092, + "location_type": "stop", "parent_station": null } ], @@ -175,12 +178,14 @@ class BackendTest { id = "8552", latitude = 42.289904, longitude = -71.191003, + locationType = LocationType.STOP, name = "Sawmill Brook Pkwy @ Walsh Rd" ), Stop( id = "84791", latitude = 42.289995, longitude = -71.191092, + locationType = LocationType.STOP, name = "Sawmill Brook Pkwy @ Walsh Rd" ) ),