From a0a6eadbecedd95f2ea107fa980c07592ec7904e Mon Sep 17 00:00:00 2001 From: harel Date: Wed, 29 Mar 2023 14:28:33 +0300 Subject: [PATCH] Resolves #1636 - Offline routing --- .../services/elevation.provider.spec.ts | 57 ++++++++++++++----- .../services/elevation.provider.ts | 8 --- .../application/services/resources.service.ts | 10 ++-- .../services/router.service.spec.ts | 8 +-- .../application/services/router.service.ts | 28 ++++++--- IsraelHiking.Web/src/translations/en-US.json | 4 +- IsraelHiking.Web/src/translations/he.json | 4 +- 7 files changed, 77 insertions(+), 42 deletions(-) diff --git a/IsraelHiking.Web/src/application/services/elevation.provider.spec.ts b/IsraelHiking.Web/src/application/services/elevation.provider.spec.ts index f15e4324..9ff4c312 100644 --- a/IsraelHiking.Web/src/application/services/elevation.provider.spec.ts +++ b/IsraelHiking.Web/src/application/services/elevation.provider.spec.ts @@ -3,17 +3,13 @@ import { HttpClientModule } from "@angular/common/http"; import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; import { MockNgRedux, MockNgReduxModule } from "@angular-redux2/store/testing"; -import { ResourcesService } from "./resources.service"; import { ElevationProvider } from "./elevation.provider"; -import { ToastService } from "./toast.service"; -import { ToastServiceMockCreator } from "./toast.service.spec"; import { LoggingService } from "./logging.service"; import { DatabaseService } from "./database.service"; describe("ElevationProvider", () => { beforeEach(() => { - let toastMockCreator = new ToastServiceMockCreator(); TestBed.configureTestingModule({ imports: [ HttpClientModule, @@ -21,8 +17,6 @@ describe("ElevationProvider", () => { MockNgReduxModule ], providers: [ - { provide: ResourcesService, useValue: toastMockCreator.resourcesService }, - { provide: ToastService, useValue: toastMockCreator.toastService }, { provide: LoggingService, useValue: { warning: () => { } } }, { provide: DatabaseService, useValue: {} }, ElevationProvider @@ -42,7 +36,8 @@ describe("ElevationProvider", () => { mockBackend.match(() => true)[0].flush([1]); return promise; - })); + } + )); it("Should not call provider bacause all coordinates has elevation", inject([ElevationProvider], async (elevationProvider: ElevationProvider) => { @@ -52,20 +47,56 @@ describe("ElevationProvider", () => { elevationProvider.updateHeights(latlngs).then(() => { expect(latlngs[0].alt).toBe(1); }); - })); + } + )); - it("Should raise toast when error occurs", inject([ElevationProvider, HttpTestingController, ToastService], - async (elevationProvider: ElevationProvider, mockBackend: HttpTestingController, toastService: ToastService) => { + it("Should not update elevation when getting an error from server and offline is not available", + inject([ElevationProvider, HttpTestingController], + async (elevationProvider: ElevationProvider, mockBackend: HttpTestingController) => { + + let latlngs = [{ lat: 0, lng: 0, alt: 0 }]; + + let promise = elevationProvider.updateHeights(latlngs); + promise.then(() => { + expect(latlngs[0].alt).toBe(0); + }, () => fail()); + mockBackend.match(() => true)[0].flush(null, { status: 500, statusText: "Server Error" }); + return promise; + } + )); + + it("Should update elevation when getting an error from server and offline is available", + inject([ElevationProvider, HttpTestingController, DatabaseService], + async (elevationProvider: ElevationProvider, mockBackend: HttpTestingController, db: DatabaseService) => { let latlngs = [{ lat: 0, lng: 0, alt: 0 }]; - spyOn(toastService, "warning"); + + MockNgRedux.store.getState = () => ({ + offlineState: { + isOfflineAvailable: true, + lastModifiedDate: new Date() + } + }); + + // create a blue image 256x256 + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + canvas.width = 256; + canvas.height = 256; + ctx.fillStyle = "blue"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.save(); + let url = canvas.toDataURL("image/png"); + + db.getTile = () => fetch(url).then(r => r.arrayBuffer()); let promise = elevationProvider.updateHeights(latlngs); promise.then(() => { - expect(toastService.warning).toHaveBeenCalled(); + expect(latlngs[0].alt).not.toBe(0); }, () => fail()); mockBackend.match(() => true)[0].flush(null, { status: 500, statusText: "Server Error" }); return promise; - })); + } + )); }); diff --git a/IsraelHiking.Web/src/application/services/elevation.provider.ts b/IsraelHiking.Web/src/application/services/elevation.provider.ts index 8941903b..c9b2db17 100644 --- a/IsraelHiking.Web/src/application/services/elevation.provider.ts +++ b/IsraelHiking.Web/src/application/services/elevation.provider.ts @@ -4,8 +4,6 @@ import { NgRedux } from "@angular-redux2/store"; import { timeout } from "rxjs/operators"; import { firstValueFrom } from "rxjs"; -import { ResourcesService } from "./resources.service"; -import { ToastService } from "./toast.service"; import { LoggingService } from "./logging.service"; import { SpatialService } from "./spatial.service"; import { DatabaseService } from "./database.service"; @@ -21,8 +19,6 @@ export class ElevationProvider { private elevationCache: Map; constructor(private readonly httpClient: HttpClient, - private readonly resources: ResourcesService, - private readonly toastService: ToastService, private readonly loggingService: LoggingService, private readonly databaseService: DatabaseService, private readonly ngRedux: NgRedux) { @@ -59,7 +55,6 @@ export class ElevationProvider { } catch (ex2) { this.loggingService.warning(`[Elevation] Unable to get elevation data for ${latlngs.length} points. ` + `${(ex as Error).message}, ${(ex2 as Error).message}`); - this.toastService.warning(this.resources.unableToGetElevationData); } } } @@ -75,9 +70,6 @@ export class ElevationProvider { const tileXmin = Math.min(...tiles.map(tile => Math.floor(tile.x))); const tileYmax = Math.max(...tiles.map(tile => Math.floor(tile.y))); const tileYmin = Math.min(...tiles.map(tile => Math.floor(tile.y))); - if (tileXmax - tileXmin > 1 || tileYmax - tileYmin > 1) { - throw new Error("[Elevation] Getting elevation is only supported for adjecent tiles maximum..."); - } for (let tileX = tileXmin; tileX <= tileXmax; tileX++) { for (let tileY = tileYmin; tileY <= tileYmax; tileY++) { let key = `${tileX}/${tileY}`; diff --git a/IsraelHiking.Web/src/application/services/resources.service.ts b/IsraelHiking.Web/src/application/services/resources.service.ts index 14d34e26..e98feebf 100644 --- a/IsraelHiking.Web/src/application/services/resources.service.ts +++ b/IsraelHiking.Web/src/application/services/resources.service.ts @@ -254,8 +254,8 @@ export class ResourcesService { public unableToLoadFromUrl: string; public routeNameAlreadyInUse: string; public unableToGenerateUrl: string; - public unableToGetElevationData: string; - public routingFailed: string; + public routingFailedTryShorterRoute: string; + public routingFailedBuySubscription: string; public unableToLogin: string; public unableToSendRoute: string; public noUnmappedRoutes: string; @@ -478,7 +478,7 @@ export class ResourcesService { } private async setLanguageInternal(language: Language): Promise { - await this.gettextCatalog.loadRemote(Urls.translations + language.code + ".json?sign=1679061617453"); + await this.gettextCatalog.loadRemote(Urls.translations + language.code + ".json?sign=1680089205399"); this.about = this.gettextCatalog.getString("About"); this.legend = this.gettextCatalog.getString("Legend"); this.clear = this.gettextCatalog.getString("Clear"); @@ -717,8 +717,8 @@ export class ResourcesService { this.unableToLoadFromUrl = this.gettextCatalog.getString("Unable to load from URL..."); this.routeNameAlreadyInUse = this.gettextCatalog.getString("The route's name was altered since it is in use..."); this.unableToGenerateUrl = this.gettextCatalog.getString("Unable to generate URL, please try again later..."); - this.unableToGetElevationData = this.gettextCatalog.getString("Unable to get elevation data:"); - this.routingFailed = this.gettextCatalog.getString("Routing failed:"); + this.routingFailedTryShorterRoute = this.gettextCatalog.getString("Routing failed, please try a shorter route..."); + this.routingFailedBuySubscription = this.gettextCatalog.getString("Routing failed, consider buying a subscription."); this.unableToLogin = this.gettextCatalog.getString("Unable to login..."); this.unableToSendRoute = this.gettextCatalog.getString("Unable to send route..."); this.noUnmappedRoutes = this.gettextCatalog.getString("No unmapped routes! :-)"); diff --git a/IsraelHiking.Web/src/application/services/router.service.spec.ts b/IsraelHiking.Web/src/application/services/router.service.spec.ts index ac0174ee..18def15f 100644 --- a/IsraelHiking.Web/src/application/services/router.service.spec.ts +++ b/IsraelHiking.Web/src/application/services/router.service.spec.ts @@ -14,12 +14,12 @@ import { RunningContextService } from "./running-context.service"; import geojsonVt from "geojson-vt"; import vtpbf from "vt-pbf"; -function createTileFromFeatureCollection(featureCollection: GeoJSON.FeatureCollection): ArrayBuffer { +const createTileFromFeatureCollection = (featureCollection: GeoJSON.FeatureCollection): ArrayBuffer => { let tileindex = geojsonVt(featureCollection); let tile = tileindex.getTile(14, 8192, 8191); return vtpbf.fromGeojsonVt({ geojsonLayer: tile }); -} +}; describe("Router Service", () => { beforeEach(() => { @@ -34,8 +34,8 @@ describe("Router Service", () => { { provide: ResourcesService, useValue: toastMockCreator.resourcesService }, { provide: ToastService, useValue: toastMockCreator.toastService }, { provide: DatabaseService, usevalue: {} }, - { provide: LoggingService, useValue: {} }, - { provide: RunningContextService, useValue: null }, + { provide: LoggingService, useValue: { error: () => {} } }, + { provide: RunningContextService, useValue: {} }, GeoJsonParser, RouterService ] diff --git a/IsraelHiking.Web/src/application/services/router.service.ts b/IsraelHiking.Web/src/application/services/router.service.ts index 94785d14..6fee46d7 100644 --- a/IsraelHiking.Web/src/application/services/router.service.ts +++ b/IsraelHiking.Web/src/application/services/router.service.ts @@ -11,6 +11,8 @@ import { ResourcesService } from "./resources.service"; import { ToastService } from "./toast.service"; import { SpatialService } from "./spatial.service"; import { DatabaseService } from "./database.service"; +import { LoggingService } from "./logging.service"; +import { RunningContextService } from "./running-context.service"; import { Urls } from "../urls"; import type { ApplicationState, LatLngAlt, RoutingType } from "../models/models"; @@ -22,6 +24,8 @@ export class RouterService { private readonly resources: ResourcesService, private readonly toastService: ToastService, private readonly databaseService: DatabaseService, + private readonly loggingService: LoggingService, + private readonly runningContextService: RunningContextService, private readonly ngRedux: NgRedux) { this.featuresCache = new Map>(); } @@ -37,10 +41,11 @@ export class RouterService { try { return await this.getOffineRoute(latlngStart, latlngEnd, routinType); } catch (ex2) { - // HM TODO: consider adding message about offline routing - this.toastService.error({ - message: (ex as Error).message + ", " + (ex2 as Error).message - }, this.resources.routingFailed); + this.loggingService.error(`[Routing] failed: ${(ex as Error).message}, ${(ex2 as Error).message}`); + this.toastService.warning(this.ngRedux.getState().offlineState.isOfflineAvailable || !this.runningContextService.isCapacitor + ? this.resources.routingFailedTryShorterRoute + : this.resources.routingFailedBuySubscription + ); return [latlngStart, latlngEnd]; } } @@ -52,17 +57,24 @@ export class RouterService { } let offlineState = this.ngRedux.getState().offlineState; if (!offlineState.isOfflineAvailable || offlineState.lastModifiedDate == null) { - throw new Error("[Routing] Offline routing is only supported after downloading offline data"); + throw new Error("Offline routing is only supported after downloading offline data"); } const zoom = 14; // this is the max zoom for these tiles let tiles = [latlngStart, latlngEnd].map(latlng => SpatialService.toTile(latlng, zoom)); - const tileXmax = Math.max(...tiles.map(tile => Math.floor(tile.x))); + let tileXmax = Math.max(...tiles.map(tile => Math.floor(tile.x))); const tileXmin = Math.min(...tiles.map(tile => Math.floor(tile.x))); - const tileYmax = Math.max(...tiles.map(tile => Math.floor(tile.y))); + let tileYmax = Math.max(...tiles.map(tile => Math.floor(tile.y))); const tileYmin = Math.min(...tiles.map(tile => Math.floor(tile.y))); if (tileXmax - tileXmin > 2 || tileYmax - tileYmin > 2) { - throw new Error("[Routing] Offline routing is only supported for adjecent tiles maximum..."); + throw new Error("Offline routing is only supported for adjecent tiles maximum..."); } + // increase the chance of getting a route by adding more tiles + if (tileXmax === tileXmin) { + tileXmax += 1; + }; + if (tileYmax === tileYmin) { + tileYmax += 1; + }; let features = await this.updateCacheAndGetFeatures(tileXmin, tileXmax, tileYmin, tileYmax, zoom); if (routinType === "4WD") { features = features.filter(f => diff --git a/IsraelHiking.Web/src/translations/en-US.json b/IsraelHiking.Web/src/translations/en-US.json index 1bfbd3e0..dbe9f1c3 100644 --- a/IsraelHiking.Web/src/translations/en-US.json +++ b/IsraelHiking.Web/src/translations/en-US.json @@ -300,7 +300,8 @@ "Route Statistics": "Route Statistics", "Routes": "Routes", "Routes and Points": "Routes and Points", - "Routing failed:": "Routing failed:", + "Routing failed, consider buying a subscription.": "Routing failed, consider buying a subscription for offline maps.", + "Routing failed, please try a shorter route...": "Routing failed, please try a shorter route...", "Ruins": "Ruins", "Running in the background": "Running in the background", "Runway and Taxiway": "Airfield with Runway and Taxiway", @@ -369,7 +370,6 @@ "Unable to find the required point of interest...": "Unable to find the required point of interest...", "Unable to find your location...": "Unable to find your location...", "Unable to generate URL, please try again later...": "Unable to generate URL, please try again later...", - "Unable to get elevation data:": "Unable to get elevation data:", "Unable to get search results...": "Unable to get search results...", "Unable to load from URL...": "Unable to load from URL...", "Unable to login...": "Unable to login...", diff --git a/IsraelHiking.Web/src/translations/he.json b/IsraelHiking.Web/src/translations/he.json index c5e1f4b1..c3196c98 100644 --- a/IsraelHiking.Web/src/translations/he.json +++ b/IsraelHiking.Web/src/translations/he.json @@ -300,7 +300,8 @@ "Route Statistics": "סטטיסטיקת מסלול", "Routes": "מסלולים", "Routes and Points": "מסלולים ונקודות", - "Routing failed:": "הניווט נכשל:", + "Routing failed, consider buying a subscription.": "הניווט נכשל, אנו ממליצים לרכוש מנוי למפות ללא רשת.", + "Routing failed, please try a shorter route...": "הניווט נכשל, אנא נסו לצייר מסלול קצר יותר...", "Ruins": "חורבות", "Running in the background": "רצה ברקע", "Runway and Taxiway": "שדה תעופה עם מסלולי המראה והסעה", @@ -369,7 +370,6 @@ "Unable to find the required point of interest...": "אין אפשרות למצוא את נקודת העניין המבוקשת...", "Unable to find your location...": "אין אפשרות למצוא את מיקומך...", "Unable to generate URL, please try again later...": "לא ניתן ליצור קישור, אנא נסו מאוחר יותר...", - "Unable to get elevation data:": "לא ניתן לקבל מידע על גבהים...", "Unable to get search results...": "לא ניתן לאחזר תוצאות חיפוש...", "Unable to load from URL...": "לא ניתן לפתוח את הקישור...", "Unable to login...": "הכניסה נכשלה...",