diff --git a/IsraelHiking.Web/package-lock.json b/IsraelHiking.Web/package-lock.json index 90a3f72c2..dd097fd49 100644 --- a/IsraelHiking.Web/package-lock.json +++ b/IsraelHiking.Web/package-lock.json @@ -37,7 +37,7 @@ "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mapbox/polyline": "^1.2.1", "@mapbox/vector-tile": "^1.3.1", - "@maplibre/ngx-maplibre-gl": "^18.0.0", + "@maplibre/ngx-maplibre-gl": "^18.1.2", "@ng-idle/core": "^15.0.0", "@ngxs/store": "^18.1.1", "@nicky-lenaers/ngx-scroll-to": "^14.0.0", @@ -81,7 +81,7 @@ "linear-interpolator": "^1.0.2", "lodash-es": "^4.17.21", "lottie-web": "^5.12.2", - "maplibre-gl": "^4.6.0", + "maplibre-gl": "^4.7.0", "minisearch": "^7.1.0", "ngx-infinite-scroll": "^18.0.0", "ngx-lottie": "^12.0.0", @@ -4241,16 +4241,16 @@ "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==" }, "node_modules/@maplibre/ngx-maplibre-gl": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/@maplibre/ngx-maplibre-gl/-/ngx-maplibre-gl-18.0.0.tgz", - "integrity": "sha512-WxTaRjKxL9ImmYaIdiPJy0URjz32J27P/LpWlxZqNHWOtCrmtrg3vCN5QqRHX2Z5uYOTyjV8ITXZniopKqqzlg==", + "version": "18.1.2", + "resolved": "https://registry.npmjs.org/@maplibre/ngx-maplibre-gl/-/ngx-maplibre-gl-18.1.2.tgz", + "integrity": "sha512-LUwFKbXLutM0n7PgsVtdWA5fuzm5VjfSZH+QDw99glbczl0eaPl5tb/xzwi57AobO4Qj0PB5aV/YasYZnBhKYA==", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": ">= 18.0.0", "@angular/core": ">= 18.0.0", - "maplibre-gl": ">= 4.1.0", + "maplibre-gl": ">= 4.5.2", "rxjs": ">= 7.8.1" } }, @@ -13781,9 +13781,9 @@ } }, "node_modules/maplibre-gl": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.6.0.tgz", - "integrity": "sha512-zobZK+fE+XM+7K81fk5pSBYWZlTGjGT0P96y2fR4DV2ry35ZBfAd0uWNatll69EgYeE+uOhN1MvEk+z1PCuyOQ==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.0.tgz", + "integrity": "sha512-hkt7je7NxiMQE8EpCxLWP8t6tkK6SkrMe0hIBjYd4Ar/Q7BOCILxthGmGnU993Mwmkvs2mGiXnVUSOK12DeCzg==", "dependencies": { "@mapbox/geojson-rewind": "^0.5.2", "@mapbox/jsonlint-lines-primitives": "^2.0.2", diff --git a/IsraelHiking.Web/package.json b/IsraelHiking.Web/package.json index 403a9664a..e2ae5de60 100644 --- a/IsraelHiking.Web/package.json +++ b/IsraelHiking.Web/package.json @@ -28,7 +28,7 @@ "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mapbox/polyline": "^1.2.1", "@mapbox/vector-tile": "^1.3.1", - "@maplibre/ngx-maplibre-gl": "^18.0.0", + "@maplibre/ngx-maplibre-gl": "^18.1.2", "@ng-idle/core": "^15.0.0", "@ngxs/store": "^18.1.1", "@nicky-lenaers/ngx-scroll-to": "^14.0.0", @@ -72,7 +72,7 @@ "linear-interpolator": "^1.0.2", "lodash-es": "^4.17.21", "lottie-web": "^5.12.2", - "maplibre-gl": "^4.6.0", + "maplibre-gl": "^4.7.0", "minisearch": "^7.1.0", "ngx-infinite-scroll": "^18.0.0", "ngx-lottie": "^12.0.0", diff --git a/IsraelHiking.Web/src/application/application.module.ts b/IsraelHiking.Web/src/application/application.module.ts index 970fba373..a9c152786 100644 --- a/IsraelHiking.Web/src/application/application.module.ts +++ b/IsraelHiking.Web/src/application/application.module.ts @@ -97,6 +97,8 @@ import { OverpassTurboService } from "./services/overpass-turbo.service"; import { ImageAttributionService } from "./services/image-attribution.service"; import { PmTilesService } from "./services/pmtiles.service"; import { ApplicationUpdateService } from "./services/application-update.service"; +import { INatureService } from "./services/inature.service"; +import { WikidataService } from "./services/wikidata.service"; // interactions import { RouteEditPoiInteraction } from "./components/intercations/route-edit-poi.interaction"; import { RouteEditRouteInteraction } from "./components/intercations/route-edit-route.interaction"; @@ -309,6 +311,8 @@ const initializeApplication = (injector: Injector) => async () => { ImageAttributionService, PmTilesService, ApplicationUpdateService, + INatureService, + WikidataService, AudioPlayerFactory, FileSystemWrapper, // eslint-disable-next-line diff --git a/IsraelHiking.Web/src/application/components/map/layers-view.component.ts b/IsraelHiking.Web/src/application/components/map/layers-view.component.ts index f86db5217..ff69c66c4 100644 --- a/IsraelHiking.Web/src/application/components/map/layers-view.component.ts +++ b/IsraelHiking.Web/src/application/components/map/layers-view.component.ts @@ -16,6 +16,7 @@ import { SpatialService } from "../../services/spatial.service"; import { NavigateHereService } from "../../services/navigate-here.service"; import { SetSelectedPoiAction } from "../../reducers/poi.reducer"; import { AddPrivatePoiAction } from "../../reducers/routes.reducer"; +import { GeoJSONUtils } from "../../services/geojson-utils"; import type { ApplicationState, LatLngAlt, LinkData, Overlay } from "../../models/models"; @Component({ @@ -26,7 +27,7 @@ import type { ApplicationState, LatLngAlt, LinkData, Overlay } from "../../model export class LayersViewComponent extends BaseMapComponent implements OnInit { private static readonly MAX_MENU_POINTS_IN_CLUSTER = 7; - public poiGeoJsonData: GeoJSON.FeatureCollection; + public poiGeoJsonData: GeoJSON.FeatureCollection; public selectedPoiFeature: GeoJSON.Feature; public selectedPoiGeoJson: Immutable; public selectedCluster: GeoJSON.Feature; @@ -132,11 +133,11 @@ export class LayersViewComponent extends BaseMapComponent implements OnInit { } public getTitle(feature: GeoJSON.Feature): string { - return this.poiService.getTitle(feature, this.resources.getCurrentLanguageCodeSimplified()); + return GeoJSONUtils.getTitle(feature, this.resources.getCurrentLanguageCodeSimplified()); } public hasExtraData(feature: GeoJSON.Feature): boolean { - return this.poiService.hasExtraData(feature, this.resources.getCurrentLanguageCodeSimplified()); + return GeoJSONUtils.hasExtraData(feature, this.resources.getCurrentLanguageCodeSimplified()); } public isCoordinatesFeature(feature: Immutable) { diff --git a/IsraelHiking.Web/src/application/components/overlays/cluster-overlay.component.ts b/IsraelHiking.Web/src/application/components/overlays/cluster-overlay.component.ts index c168dd9b1..79b5f4f84 100644 --- a/IsraelHiking.Web/src/application/components/overlays/cluster-overlay.component.ts +++ b/IsraelHiking.Web/src/application/components/overlays/cluster-overlay.component.ts @@ -3,8 +3,8 @@ import { Router } from "@angular/router"; import { BaseMapComponent } from "../base-map.component"; import { ResourcesService } from "../../services/resources.service"; -import { PoiService } from "../../services/poi.service"; import { RouteStrings } from "../../services/hash.service"; +import { GeoJSONUtils } from "../../services/geojson-utils"; @Component({ selector: "cluster-overlay", @@ -20,18 +20,17 @@ export class ClusterOverlayComponent extends BaseMapComponent { public closed: EventEmitter; constructor(resources: ResourcesService, - private readonly router: Router, - private readonly poiService: PoiService) { + private readonly router: Router) { super(resources); this.closed = new EventEmitter(); } public getTitle(feature: GeoJSON.Feature) { - return this.poiService.getTitle(feature, this.resources.getCurrentLanguageCodeSimplified()); + return GeoJSONUtils.getTitle(feature, this.resources.getCurrentLanguageCodeSimplified()); } public hasExtraData(feature: GeoJSON.Feature): boolean { - return this.poiService.hasExtraData(feature, this.resources.getCurrentLanguageCodeSimplified()); + return GeoJSONUtils.hasExtraData(feature, this.resources.getCurrentLanguageCodeSimplified()); } public clickOnItem(feature: GeoJSON.Feature) { diff --git a/IsraelHiking.Web/src/application/components/sidebar/categories-group.component.html b/IsraelHiking.Web/src/application/components/sidebar/categories-group.component.html index 37aa6fc0a..c40214aaf 100644 --- a/IsraelHiking.Web/src/application/components/sidebar/categories-group.component.html +++ b/IsraelHiking.Web/src/application/components/sidebar/categories-group.component.html @@ -5,7 +5,7 @@

{{resources.translate(categoriesGroup.type)}}

-
diff --git a/IsraelHiking.Web/src/application/components/sidebar/layers-sidebar.component.html b/IsraelHiking.Web/src/application/components/sidebar/layers-sidebar.component.html index abfc3a174..142eda343 100644 --- a/IsraelHiking.Web/src/application/components/sidebar/layers-sidebar.component.html +++ b/IsraelHiking.Web/src/application/components/sidebar/layers-sidebar.component.html @@ -46,7 +46,7 @@
- diff --git a/IsraelHiking.Web/src/application/components/sidebar/publicpoi/public-poi-sidebar.component.ts b/IsraelHiking.Web/src/application/components/sidebar/publicpoi/public-poi-sidebar.component.ts index 77cc9aa05..42a411901 100644 --- a/IsraelHiking.Web/src/application/components/sidebar/publicpoi/public-poi-sidebar.component.ts +++ b/IsraelHiking.Web/src/application/components/sidebar/publicpoi/public-poi-sidebar.component.ts @@ -25,6 +25,7 @@ import { GeoJsonParser } from "../../../services/geojson.parser"; import { sidebarAnimate } from "../sidebar.component"; import { AddRouteAction, AddPrivatePoiAction } from "../../../reducers/routes.reducer"; import { SetSelectedPoiAction, SetUploadMarkerDataAction, SetSidebarAction } from "../../../reducers/poi.reducer"; +import { GeoJSONUtils } from "../../../services/geojson-utils"; import type { LinkData, LatLngAlt, @@ -181,13 +182,13 @@ export class PublicPoiSidebarComponent extends BaseMapComponent implements OnDes private initFromFeature(feature: GeoJSON.Feature) { this.fullFeature = feature; - this.latlng = this.poiService.getLocation(feature); + this.latlng = GeoJSONUtils.getLocation(feature); this.sourceImageUrls = this.getSourceImageUrls(feature); this.shareLinks = this.poiService.getPoiSocialLinks(feature); this.contribution = this.poiService.getContribution(feature); this.info = this.poiService.getEditableDataFromFeature(feature); const language = this.resources.getCurrentLanguageCodeSimplified(); - this.titleService.set(this.poiService.getTitle(feature, language)); + this.titleService.set(GeoJSONUtils.getTitle(feature, language)); } private getSourceImageUrls(feature: GeoJSON.Feature): SourceImageUrlPair[] { @@ -228,8 +229,8 @@ export class PublicPoiSidebarComponent extends BaseMapComponent implements OnDes return ""; } const language = this.resources.getCurrentLanguageCodeSimplified(); - const description = this.poiService.getDescription(this.fullFeature, language) || - this.poiService.getExternalDescription(this.fullFeature, language); + const description = GeoJSONUtils.getDescription(this.fullFeature, language) || + GeoJSONUtils.getExternalDescription(this.fullFeature, language); if (description) { return description; } @@ -326,8 +327,8 @@ export class PublicPoiSidebarComponent extends BaseMapComponent implements OnDes } public navigateHere() { - const location = this.poiService.getLocation(this.fullFeature); - const title = this.poiService.getTitle(this.fullFeature, this.resources.getCurrentLanguageCodeSimplified()); + const location = GeoJSONUtils.getLocation(this.fullFeature); + const title = GeoJSONUtils.getTitle(this.fullFeature, this.resources.getCurrentLanguageCodeSimplified()); this.navigateHereService.addNavigationSegment(location, title); } diff --git a/IsraelHiking.Web/src/application/models/state/offline-state.d.ts b/IsraelHiking.Web/src/application/models/state/offline-state.d.ts index 6b109633e..a8f9fdee1 100644 --- a/IsraelHiking.Web/src/application/models/state/offline-state.d.ts +++ b/IsraelHiking.Web/src/application/models/state/offline-state.d.ts @@ -3,10 +3,6 @@ * Maps last modified date */ lastModifiedDate: Date; - /** - * Points of interest last modified date - */ - poisLastModifiedDate: Date; /** * Shares last modified date */ diff --git a/IsraelHiking.Web/src/application/reducers/initial-state.ts b/IsraelHiking.Web/src/application/reducers/initial-state.ts index 2e70afad9..35fe8f40f 100644 --- a/IsraelHiking.Web/src/application/reducers/initial-state.ts +++ b/IsraelHiking.Web/src/application/reducers/initial-state.ts @@ -164,7 +164,6 @@ export const initialState = offlineState: { isOfflineAvailable: false, lastModifiedDate: null, - poisLastModifiedDate: null, shareUrlsLastModifiedDate: null, uploadPoiQueue: [], isPmtilesDownloaded: false diff --git a/IsraelHiking.Web/src/application/reducers/offline.reducer.ts b/IsraelHiking.Web/src/application/reducers/offline.reducer.ts index 5063cdeb9..40bba5512 100644 --- a/IsraelHiking.Web/src/application/reducers/offline.reducer.ts +++ b/IsraelHiking.Web/src/application/reducers/offline.reducer.ts @@ -15,11 +15,6 @@ export class SetOfflineMapsLastModifiedDateAction { constructor(public lastModifiedDate: Date) {} } -export class SetOfflinePoisLastModifiedDateAction { - public static type = this.prototype.constructor.name; - constructor(public lastModifiedDate: Date) {} -} - export class SetShareUrlsLastModifiedDateAction { public static type = this.prototype.constructor.name; constructor(public lastModifiedDate: Date) {} @@ -59,14 +54,6 @@ export class OfflineReducer { })); } - @Action(SetOfflinePoisLastModifiedDateAction) - public setOfflinePoisLastModifiedDate(ctx: StateContext, action: SetOfflinePoisLastModifiedDateAction) { - ctx.setState(produce(ctx.getState(), lastState => { - lastState.poisLastModifiedDate = action.lastModifiedDate; - return lastState; - })); - } - @Action(SetShareUrlsLastModifiedDateAction) public setShareUrlsLastModifiedDate(ctx: StateContext, action: SetShareUrlsLastModifiedDateAction) { ctx.setState(produce(ctx.getState(), lastState => { diff --git a/IsraelHiking.Web/src/application/services/database.service.ts b/IsraelHiking.Web/src/application/services/database.service.ts index f87cf13e6..ee8699e92 100644 --- a/IsraelHiking.Web/src/application/services/database.service.ts +++ b/IsraelHiking.Web/src/application/services/database.service.ts @@ -23,11 +23,9 @@ export class DatabaseService { private static readonly STATE_DB_NAME = "State"; private static readonly STATE_TABLE_NAME = "state"; private static readonly STATE_DOC_ID = "state"; - private static readonly POIS_DB_NAME = "PointsOfInterest"; - private static readonly POIS_TABLE_NAME = "pois"; + private static readonly POIS_UPLOAD_QUEUE_DB_NAME = "UploadQueue"; private static readonly POIS_UPLOAD_QUEUE_TABLE_NAME = "uploadQueue"; private static readonly POIS_ID_COLUMN = "properties.poiId"; - private static readonly POIS_LOCATION_COLUMN = "[properties.poiGeolocation.lat+properties.poiGeolocation.lon]"; private static readonly IMAGES_DB_NAME = "Images"; private static readonly IMAGES_TABLE_NAME = "images"; private static readonly SHARE_URLS_DB_NAME = "ShareUrls"; @@ -36,7 +34,7 @@ export class DatabaseService { private static readonly TRACES_TABLE_NAME = "traces"; private stateDatabase: Dexie; - private poisDatabase: Dexie; + private uploadQueueDatabase: Dexie; private imagesDatabase: Dexie; private shareUrlsDatabase: Dexie; private tracesDatabase: Dexie; @@ -54,11 +52,8 @@ export class DatabaseService { this.stateDatabase.version(1).stores({ state: "id" }); - this.poisDatabase = new Dexie(DatabaseService.POIS_DB_NAME); - this.poisDatabase.version(1).stores({ - pois: DatabaseService.POIS_ID_COLUMN + "," + DatabaseService.POIS_LOCATION_COLUMN, - }); - this.poisDatabase.version(2).stores({ + this.uploadQueueDatabase = new Dexie(DatabaseService.POIS_UPLOAD_QUEUE_DB_NAME); + this.uploadQueueDatabase.version(1).stores({ uploadQueue: DatabaseService.POIS_ID_COLUMN }); this.imagesDatabase = new Dexie(DatabaseService.IMAGES_DB_NAME); @@ -129,55 +124,16 @@ export class DatabaseService { } } - public storePois(pois: GeoJSON.Feature[]): Promise { - return this.poisDatabase.table(DatabaseService.POIS_TABLE_NAME).bulkPut(pois); - } - - public deletePois(poiIds: string[]): Promise { - return this.poisDatabase.table(DatabaseService.POIS_TABLE_NAME).bulkDelete(poiIds); - } - - public async getPoisForClustering(): Promise[]> { - this.loggingService.debug("[Database] Startting getting pois for clustering in chunks"); - let features = [] as GeoJSON.Feature[]; - let index = 0; - const size = 2000; - let currentFeatures = []; - do { - currentFeatures = await this.poisDatabase.table(DatabaseService.POIS_TABLE_NAME).offset(index * size).limit(size).toArray(); - features = features.concat(currentFeatures); - index++; - } while (currentFeatures.length !== 0); - this.loggingService.debug("[Database] Finished getting pois for clustering in chunks: " + features.length); - const pointFeatures = features.map((feature: GeoJSON.Feature) => { - const geoLocation = feature.properties.poiGeolocation; - const pointFeature = { - type: "Feature", - geometry: { - type: "Point", - coordinates: [parseFloat(geoLocation.lon), parseFloat(geoLocation.lat)] - }, - properties: feature.properties - } as GeoJSON.Feature; - return pointFeature; - }); - return pointFeatures; - } - - public getPoiById(id: string): Promise { - return this.poisDatabase.table(DatabaseService.POIS_TABLE_NAME).get(id); - } - public addPoiToUploadQueue(feature: GeoJSON.Feature): Promise { - return this.poisDatabase.table(DatabaseService.POIS_UPLOAD_QUEUE_TABLE_NAME).put(feature); + return this.uploadQueueDatabase.table(DatabaseService.POIS_UPLOAD_QUEUE_TABLE_NAME).put(feature); } public getPoiFromUploadQueue(featureId: string): Promise { - return this.poisDatabase.table(DatabaseService.POIS_UPLOAD_QUEUE_TABLE_NAME).get(featureId); + return this.uploadQueueDatabase.table(DatabaseService.POIS_UPLOAD_QUEUE_TABLE_NAME).get(featureId); } public removePoiFromUploadQueue(featureId: string): Promise { - return this.poisDatabase.table(DatabaseService.POIS_UPLOAD_QUEUE_TABLE_NAME).delete(featureId); + return this.uploadQueueDatabase.table(DatabaseService.POIS_UPLOAD_QUEUE_TABLE_NAME).delete(featureId); } public storeImages(images: ImageUrlAndData[]): Promise { diff --git a/IsraelHiking.Web/src/application/services/file.service.ts b/IsraelHiking.Web/src/application/services/file.service.ts index 381cc2f2b..9fd75c515 100644 --- a/IsraelHiking.Web/src/application/services/file.service.ts +++ b/IsraelHiking.Web/src/application/services/file.service.ts @@ -262,7 +262,7 @@ export class FileService { public async writeStyle(styleFileName: string, styleText: string) { await this.fileSystemWrapper.writeFile(this.fileSystemWrapper.dataDirectory, styleFileName, styleText, { append: false, replace: true, truncate: 0 }); - this.loggingService.info(`[Files] Write style finished succefully: ${styleFileName}`); + this.loggingService.info(`[Files] Write style finished successfully: ${styleFileName}`); } public async compressTextToBase64Zip(contents: {name: string; text: string}[]): Promise { diff --git a/IsraelHiking.Web/src/application/services/fit-bounds.service.spec.ts b/IsraelHiking.Web/src/application/services/fit-bounds.service.spec.ts index 4163b8b64..530a387a2 100644 --- a/IsraelHiking.Web/src/application/services/fit-bounds.service.spec.ts +++ b/IsraelHiking.Web/src/application/services/fit-bounds.service.spec.ts @@ -32,6 +32,7 @@ describe("FitBoundsService", () => { const spy = jasmine.createSpy(); mapService.map.fitBounds = spy; await service.fitBounds({ northEast: { lat: 1, lng: 1}, southWest: { lat: 2, lng: 2}}); + console.log(spy.calls.all()[0].args[1]); expect(spy.calls.all()[0].args[1].padding.left).toBe(400); })); diff --git a/IsraelHiking.Web/src/application/services/geojson-utils.spec.ts b/IsraelHiking.Web/src/application/services/geojson-utils.spec.ts new file mode 100644 index 000000000..13231691f --- /dev/null +++ b/IsraelHiking.Web/src/application/services/geojson-utils.spec.ts @@ -0,0 +1,42 @@ +import { GeoJSONUtils } from "./geojson-utils"; + +describe("GeoJsonUtils", () => { + it("should get extenal description for hebrew", () => { + const results = GeoJSONUtils.getExternalDescription( + {properties: { "poiExternalDescription:he": "desc"}} as any as GeoJSON.Feature, "he"); + expect(results).toBe("desc"); + }); + + it("should get extenal description for language independant", () => { + const results = GeoJSONUtils.getExternalDescription( + {properties: { poiExternalDescription: "desc"}} as any as GeoJSON.Feature, "he"); + expect(results).toBe("desc"); + }); + + it("should get title when there's mtb name with language", () => { + const results = GeoJSONUtils.getTitle({properties: { "mtb:name:he": "name"}} as any as GeoJSON.Feature, "he"); + expect(results).toBe("name"); + }); + + it("should get title when there's mtb name without language", () => { + const results = GeoJSONUtils.getTitle({properties: { "mtb:name": "name"}} as any as GeoJSON.Feature, "he"); + expect(results).toBe("name"); + }); + + it("should get title even when there's no title for language description", () => { + const results = GeoJSONUtils.getTitle({properties: { name: "name"}} as any as GeoJSON.Feature, "he"); + expect(results).toBe("name"); + }); + + it("should get title even when there's no title for language description", () => { + const results = GeoJSONUtils.getTitle({properties: { name: "name"}} as any as GeoJSON.Feature, "he"); + expect(results).toBe("name"); + }); + it("should return has extra data for feature with description", () => { + expect(GeoJSONUtils.hasExtraData({properties: { "description:he": "desc"}} as any as GeoJSON.Feature, "he")).toBeTruthy(); + }); + + it("should return has extra data for feature with image", () => { + expect(GeoJSONUtils.hasExtraData({properties: { image: "image-url"}} as any as GeoJSON.Feature, "he")).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/IsraelHiking.Web/src/application/services/geojson-utils.ts b/IsraelHiking.Web/src/application/services/geojson-utils.ts new file mode 100644 index 000000000..b7faef3ac --- /dev/null +++ b/IsraelHiking.Web/src/application/services/geojson-utils.ts @@ -0,0 +1,71 @@ +import { Immutable } from "immer"; +import { LatLngAlt } from "../models/models"; + +export class GeoJSONUtils { + public static setProperty(feature: GeoJSON.Feature, key: string, value: string): string { + if (!feature.properties[key]) { + feature.properties[key] = value; + return ""; + } + let index = 1; + while (feature.properties[key + index]) { + index++; + } + feature.properties[key + index] = value; + return `${index}`; + } + + public static setLocation(feature: GeoJSON.Feature, value: LatLngAlt) { + feature.properties.poiGeolocation = { + lat: value.lat, + lon: value.lng + }; + } + + public static setDescription(feature: GeoJSON.Feature, value: string, language: string) { + feature.properties["description:" + language] = value; + } + + public static setTitle(feature: GeoJSON.Feature, value: string, language: string) { + feature.properties["name:" + language] = value; + } + + public static getTitle(feature: Immutable, language: string): string { + if (feature.properties["name:" + language]) { + return feature.properties["name:" + language]; + } + if (feature.properties.name) { + return feature.properties.name; + } + if (feature.properties["mtb:name:"+ language]) { + return feature.properties["mtb:name:"+ language]; + } + if (feature.properties["mtb:name"]) { + return feature.properties["mtb:name"]; + } + return ""; + } + + public static getDescription(feature: Immutable, language: string): string { + return feature.properties["description:" + language] || feature.properties.description; + } + + public static getExternalDescription(feature: GeoJSON.Feature, language: string): string { + return feature.properties["poiExternalDescription:" + language] || feature.properties.poiExternalDescription; + } + + public static getLocation(feature: GeoJSON.Feature): LatLngAlt { + return { + lat: feature.properties.poiGeolocation.lat, + lng: feature.properties.poiGeolocation.lon, + alt: feature.properties.poiAlt + }; + } + + public static hasExtraData(feature: GeoJSON.Feature, language: string): boolean { + return feature.properties["description:" + language] || + Object.keys(feature.properties).find(k => k.startsWith("image")) != null || + Object.keys(feature.properties).find(k => k.startsWith("wikipedia")) != null || + Object.keys(feature.properties).find(k => k.startsWith("wikidata")) != null; + } +} \ No newline at end of file diff --git a/IsraelHiking.Web/src/application/services/inature.service.ts b/IsraelHiking.Web/src/application/services/inature.service.ts new file mode 100644 index 000000000..9873501d9 --- /dev/null +++ b/IsraelHiking.Web/src/application/services/inature.service.ts @@ -0,0 +1,105 @@ +import { HttpClient } from "@angular/common/http"; +import { Injectable } from "@angular/core"; +import { firstValueFrom, timeout } from "rxjs"; + +import { GeoJSONUtils } from "./geojson-utils"; + +@Injectable() +export class INatureService { + private readonly API_URL = "https://inature.info/w/api.php"; + private readonly TIMEOUT = 3000; + + constructor(private readonly httpClient: HttpClient) {} + + public async createFeatureFromPageId(pageId: string): Promise { + const address = this.getContnetRetrivalAddress(pageId, true); + const {content, title} = await this.getPageContentAndTitleFromAddress(address); + const feature: GeoJSON.Feature = { + type: "Feature", + properties: {}, + geometry: { + type: "Point", + coordinates: [] + } + }; + const lngLat = this.setLocation(content, feature); + this.setImageAndWebsite(content, feature, title); + feature.geometry = await this.getGeometryFromContent(content) ?? { + type: "Point", + coordinates: [lngLat.lng, lngLat.lat] + }; + feature.properties.poiSource = "iNature"; + feature.properties.poiCategory = "iNature"; + feature.properties.poiLanguage = "he"; + feature.properties.poiId = "iNature_" + pageId; + feature.properties.identifier = pageId; + feature.properties.name = title; + if (feature.geometry.type !== "Point") { + feature.properties.poiIcon = "icon-hike"; + feature.properties.poiCategory = "Hiking"; + feature.properties.poiIconColor = "black"; + feature.properties.name = feature.properties.name + " - טבע ונופים"; + } else { + feature.properties.poiIcon = "icon-inature"; + feature.properties.poiCategory = "iNature"; + feature.properties.poiIconColor = "#116C00"; + } + return feature; + } + + public async enritchFeatureFromINature(feature: GeoJSON.Feature): Promise { + const iNatureRef = feature.properties["ref:IL:inature"]; + const address = this.getContnetRetrivalAddress(iNatureRef, false); + const contentAndTitle = await this.getPageContentAndTitleFromAddress(address); + this.setImageAndWebsite(contentAndTitle.content, feature, iNatureRef); + } + + private setLocation(content: string, feature: GeoJSON.Feature) { + const regexp = /נצ=(\d+\.\d+)\s*,\s*(\d+\.\d+)/; + const match = content.match(regexp); + const latLng = { lat: parseFloat(match[1]), lng: parseFloat(match[2]) }; + GeoJSONUtils.setLocation(feature, latLng); + return latLng; + } + + private setImageAndWebsite(content: string, feature: GeoJSON.Feature, title: string) { + feature.properties.poiExternalDescription = content.match(/סקירה=(.*)/)[1]; + const indexString = GeoJSONUtils.setProperty(feature, "website", `https://inature.info/wiki/${title}`); + feature.properties["poiSourceImageUrl" + indexString] = "https://user-images.githubusercontent.com/3269297/37312048-2d6e7488-2652-11e8-9dbe-c1465ff2e197.png"; + const image = content.match(/תמונה=(.*)/)[1]; + const imageSrc = `https://inature.info/w/index.php?title=Special:Redirect/file/${image}`; + GeoJSONUtils.setProperty(feature, "image", imageSrc); + } + + private getContnetRetrivalAddress(key: string, isPageId: boolean): string { + const baseAddress = `${this.API_URL}?action=query&prop=revisions&rvprop=content&format=json&origin=*` + if (isPageId) { + return baseAddress + `&pageids=${key}`; + } + return baseAddress + `&titles=${key}`; + } + + private async getPageContentAndTitleFromAddress(address: string): Promise<{content: string, title: string}> { + const iNatureJson = await firstValueFrom(this.httpClient.get(address).pipe(timeout(this.TIMEOUT))) as any; + const pageData = iNatureJson.query.pages[Object.keys(iNatureJson.query.pages)[0]]; + return { + content: pageData.revisions[0]["*"], + title: pageData.title + } + } + + private async getGeometryFromContent(content: string): Promise { + const shareRegexp = /israelhiking\.osm\.org\.il\/share\/(.*?)["']/i; + const match = content.match(shareRegexp); + if (match == null) { + return null; + } + const shareId = match[1]; + // HM TODO: replace this: + //let url = Urls.urls + shareId + "?format=geojson"; + const url = "https://israelhiking.osm.org.il/api/urls/" + shareId + "?format=geojson"; + const geojson = await firstValueFrom(this.httpClient.get(url)) as GeoJSON.FeatureCollection; + console.log(geojson); + return geojson.features.find(f => f.geometry.type !== "Point")?.geometry; + } +} diff --git a/IsraelHiking.Web/src/application/services/layers.service.ts b/IsraelHiking.Web/src/application/services/layers.service.ts index 489a54a7b..5c9f52e18 100644 --- a/IsraelHiking.Web/src/application/services/layers.service.ts +++ b/IsraelHiking.Web/src/application/services/layers.service.ts @@ -292,7 +292,7 @@ export class LayersService { public toggleOverlay(overlay: Overlay) { const newVisibility = !overlay.visible; - this.loggingService.info(`[Layers] Changing visiblity of ${overlay.key} to ${newVisibility ? "visible" : "hidden"}`); + this.loggingService.info(`[Layers] Changing visibility of ${overlay.key} to ${newVisibility ? "visible" : "hidden"}`); this.store.dispatch(new UpdateOverlayAction(overlay.key, { ...overlay, visible: newVisibility diff --git a/IsraelHiking.Web/src/application/services/overpass-turbo.service.ts b/IsraelHiking.Web/src/application/services/overpass-turbo.service.ts index cac5646c6..6dbeb7833 100644 --- a/IsraelHiking.Web/src/application/services/overpass-turbo.service.ts +++ b/IsraelHiking.Web/src/application/services/overpass-turbo.service.ts @@ -1,27 +1,64 @@ import { HttpClient } from "@angular/common/http"; import { Injectable } from "@angular/core"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, timeout } from "rxjs"; import { addProtocol } from "maplibre-gl"; import osmtogeojson from "osmtogeojson"; @Injectable() export class OverpassTurboService { + private static readonly OVERPASS_API_URL = "https://overpass-api.de/api/interpreter"; + constructor(private readonly httpClient: HttpClient) {} public initialize() { addProtocol("overpass", async (params, _abortController) => { - const geojson = await this.getGeoJson(params.url.replace("overpass://Q/", "").replace("overpass://", "")); + const query = decodeURIComponent(params.url.replace("overpass://Q/", "").replace("overpass://", "")); + const geojson = await this.getGeoJsonFromQuery(query, 20000); return {data: geojson}; }); } - private async getGeoJson(url: string): Promise { - const body = decodeURIComponent(url); - const text = await firstValueFrom(this.httpClient - .post("https://overpass-api.de/api/interpreter", body, {responseType: "text"}) - ) as string; - const parser = new DOMParser(); - const xmlDoc = parser.parseFromString(text, "text/xml"); - return osmtogeojson(xmlDoc); + private async getGeoJsonFromQuery(query: string, timeoutInMilliseconds = 2000): Promise { + try { + const text = await firstValueFrom(this.httpClient + .post(OverpassTurboService.OVERPASS_API_URL, query, {responseType: "text"}).pipe(timeout(timeoutInMilliseconds)) + ) as string; + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(text, "text/xml"); + return osmtogeojson(xmlDoc); + } catch { + return { + type: "FeatureCollection", + features: [] + }; + } + } + + public async getLongWay(id: string, title: string, isWaterway: boolean, isMtbRoute: boolean): Promise { + const query = ` + way(${id}); + complete + { + way(around:30) + [${isWaterway ? 'waterway' : 'highway'}] + ["${isMtbRoute ? 'mtb:name' : 'name'}"="${title}"]; + } + out geom;`; + return await this.getGeoJsonFromQuery(query); + } + + public async getPlaceGeometry(nodeId: string): Promise { + const query = ` + node(${nodeId}); + + node._ -> .p; + .p is_in; + area._[place] + (if: t["name"] == p.u(t["name"])) + (if: t["place"] == p.u(t["place"])) + ; + wr(pivot); + out geom;` + return await this.getGeoJsonFromQuery(query); } } diff --git a/IsraelHiking.Web/src/application/services/pmtiles.service.ts b/IsraelHiking.Web/src/application/services/pmtiles.service.ts index b8ee76fd5..2b36cb565 100644 --- a/IsraelHiking.Web/src/application/services/pmtiles.service.ts +++ b/IsraelHiking.Web/src/application/services/pmtiles.service.ts @@ -50,7 +50,7 @@ export class PmTilesService { /** * Get's a tile from the stored pmtiles file - * @param url - should be something like pmtiles://filename.pmtiles/{z}/{x}/{y}.png + * @param url - should be something like custom://filename-without-pmtiles-extention/{z}/{x}/{y}.png * @returns */ public async getTile(url: string): Promise { diff --git a/IsraelHiking.Web/src/application/services/poi.service.spec.ts b/IsraelHiking.Web/src/application/services/poi.service.spec.ts index 4598209fa..bfd9f92f8 100644 --- a/IsraelHiking.Web/src/application/services/poi.service.spec.ts +++ b/IsraelHiking.Web/src/application/services/poi.service.spec.ts @@ -1,8 +1,8 @@ -import { TestBed, inject, fakeAsync, tick, discardPeriodicTasks } from "@angular/core/testing"; +import { TestBed, inject } from "@angular/core/testing"; import { HttpRequest, provideHttpClient, withInterceptorsFromDi } from "@angular/common/http"; import { HttpTestingController, provideHttpClientTesting } from "@angular/common/http/testing"; import { NgxsModule, Store } from "@ngxs/store"; -import JSZip from "jszip"; +import { LngLatBounds } from "maplibre-gl"; import { ToastServiceMockCreator } from "./toast.service.spec"; import { ResourcesService } from "./resources.service"; @@ -16,13 +16,16 @@ import { FileService } from "./file.service"; import { ToastService } from "./toast.service"; import { MapService } from "./map.service"; import { ConnectionService } from "./connection.service"; +import { OverpassTurboService } from "./overpass-turbo.service"; +import { INatureService } from "./inature.service"; +import { WikidataService } from "./wikidata.service"; +import { GeoJSONUtils } from "./geojson-utils"; import { GeoJsonParser } from "./geojson.parser"; import { Urls } from "../urls"; import { LayersReducer } from "../reducers/layers.reducer"; import { AddToPoiQueueAction, OfflineReducer } from "../reducers/offline.reducer"; import { ConfigurationReducer, SetLanguageAction } from "../reducers/configuration.reducer"; import type { Category, MarkerData } from "../models/models"; -import { LngLatBounds } from "maplibre-gl"; describe("Poi Service", () => { @@ -44,13 +47,16 @@ describe("Poi Service", () => { storePois: jasmine.createSpy().and.returnValue(Promise.resolve()), storeImages: jasmine.createSpy().and.returnValue(Promise.resolve()) } as any; - const mapServiceMosk = { + const mapServiceMock = { map: { - on: () => { }, + on: () => {}, off: () => { }, getCenter: () => ({ lat: 0, lng: 0}), getZoom: () => 11, - getBounds: () => new LngLatBounds([1,1,2,2]) + getBounds: () => new LngLatBounds([1,1,2,2]), + addSource: () => {}, + addLayer: () => {}, + querySourceFeatures: () => [] as any[] }, initializationPromise: Promise.resolve() }; @@ -69,8 +75,11 @@ describe("Poi Service", () => { { provide: ToastService, useValue: toastMock.toastService }, { provide: FileService, useValue: fileServiceMock }, { provide: DatabaseService, useValue: databaseServiceMock }, - { provide: MapService, useValue: mapServiceMosk }, + { provide: MapService, useValue: mapServiceMock }, { provide: LoggingService, useValue: loggingService }, + { provide: OverpassTurboService, useValue: {} }, + { provide: INatureService, useValue: {} }, + { provide: WikidataService, useValue: {} }, ConnectionService, GeoJsonParser, RunningContextService, @@ -106,8 +115,8 @@ describe("Poi Service", () => { } ))); - it("Should initialize and show pois from server and then from memory after filtering", (inject([PoiService, HttpTestingController, Store, RunningContextService], - async (poiService: PoiService, mockBackend: HttpTestingController, store: Store, runningContextService: RunningContextService) => { + it("Should initialize and show pois tiles", (inject([PoiService, HttpTestingController, Store, RunningContextService, MapService], + async (poiService: PoiService, mockBackend: HttpTestingController, store: Store, runningContextService: RunningContextService, mapServiceMock: MapService) => { store.reset({ layersState: { @@ -115,45 +124,81 @@ describe("Poi Service", () => { type: "type", categories: [{ icon: "icon", - name: "category", + name: "Water", visible: true }] as any[], visible: true }] }, configuration: {}, offlineState: { - poisLastModifiedDate: new Date(), uploadPoiQueue: [] } }); (runningContextService as any).isIFrame = false; + mapServiceMock.map.on = ((type: string, f: () => void) => { if (type === "moveend") f(); }) as any; + mapServiceMock.map.querySourceFeatures = () => [ + { + id: "11", + geometry: { + type: "Point", + coordinates: [0, 0] + }, + properties: { + natural: "spring", + "name:he": "name", + "name:en": "name" + } + }, { + id: "21", + geometry: { + type: "Point", + coordinates: [0, 0] + }, + properties: { + natural: "spring", + "name:en": "name" + } + } + ] as any; const promise = poiService.initialize(); - mockBackend.match(r => r.url.startsWith(Urls.poiCategories)).forEach(t => t.flush([{ icon: "icon", name: "category" }])); + mockBackend.match(r => r.url.startsWith(Urls.poiCategories)).forEach(t => t.flush([{ icon: "icon", name: "Water" }])); await new Promise((resolve) => setTimeout(resolve, 100)); // this is in order to let the code continue to run to the next await - mockBackend.expectOne(r => r.url.startsWith(Urls.poi)).flush([{ + + expect(poiService.poiGeojsonFiltered.features.length).toBe(1); + + mapServiceMock.map.querySourceFeatures = () => [ + { + id: "11", + geometry: { + type: "Point", + coordinates: [0, 0] + }, properties: { - poiLanguage: "all", - poiCategory: "category", - name: "name" + natural: "spring", + "name:he": "name", + "name:en": "name" } - }, { + }, + { + id: "21", + geometry: { + type: "Point", + coordinates: [0, 0] + }, properties: { - poiLanguage: "en-US", - poiCategory: "category", - name: "name" + natural: "spring", + "name:he": "name" } } - ]); - await new Promise((resolve) => setTimeout(resolve, 100)); // this is in order to let the code continue to run to the next await - expect(poiService.poiGeojsonFiltered.features.length).toBe(2); + ] as any; store.dispatch(new SetLanguageAction({ code: "he", rtl: false })); await new Promise((resolve) => setTimeout(resolve, 100)); // this is in order to let the code continue to run to the next await - expect(poiService.poiGeojsonFiltered.features.length).toBe(1); + expect(poiService.poiGeojsonFiltered.features.length).toBe(2); return promise; } @@ -168,7 +213,6 @@ describe("Poi Service", () => { }, configuration: {}, offlineState: { - poisLastModifiedDate: new Date(), uploadPoiQueue: ["1"] } }); @@ -192,7 +236,6 @@ describe("Poi Service", () => { }, configuration: {}, offlineState: { - poisLastModifiedDate: new Date(), uploadPoiQueue: ["1"] } }); @@ -212,86 +255,6 @@ describe("Poi Service", () => { } ))); - it("Should initialize and download offline pois file", - inject([PoiService, HttpTestingController, RunningContextService, FileService, DatabaseService, Store], - async (poiService: PoiService, mockBackend: HttpTestingController, runningContextService: RunningContextService, - fileService: FileService, databaseService: DatabaseService, store: Store) => { - - store.reset({ - layersState: { - categoriesGroups: [{ type: "type", categories: [] as any[], visible: true }] - }, - offlineState: { - poisLastModifiedDate: null, - uploadPoiQueue: [] - } - }); - store.dispatch = jasmine.createSpy(); - (runningContextService as any).isCapacitor = true; - const zip = new JSZip(); - zip.folder("pois"); - zip.file("pois/001.geojson", JSON.stringify({ features: [{ - type: "Feature", - properties: { poiLastModified: new Date("2022-02-22") } - }] })); - zip.folder("images"); - zip.file("images/001.json", JSON.stringify([{imageUrls: [{imageUrl: "img", thumbnail: "some-data"}]}])); - const zipOutputPromise = zip.generateAsync({type: "blob"}); - const getFileFromcacheSpy = spyOn(fileService, "getFileFromCache").and.returnValues(Promise.resolve(null), zipOutputPromise); - - const promise = poiService.initialize().then(() => { - expect(poiService.poiGeojsonFiltered.features.length).toBe(0); - expect(getFileFromcacheSpy).toHaveBeenCalledTimes(2); - expect(databaseService.storePois).toHaveBeenCalled(); - expect(databaseService.storeImages).toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalled(); - }); - mockBackend.match(r => r.url.startsWith(Urls.poiCategories)).forEach(t => t.flush([{ icon: "icon", name: "category" }])); - - return promise; - } - ) - ); - - it("Should initialize and update pois by pagination", - fakeAsync(inject([PoiService, HttpTestingController, RunningContextService, DatabaseService, Store], - (poiService: PoiService, mockBackend: HttpTestingController, - runningContextService: RunningContextService, databaseService: DatabaseService, store: Store) => { - - store.reset({ - layersState: { - categoriesGroups: [{ type: "type", categories: [] as any[], visible: true }] - }, - offlineState: { - poisLastModifiedDate: Date.now(), - uploadPoiQueue: [] - }, - configuration: { - language: { code: "he", rtl: false } - } - }); - (runningContextService as any).isCapacitor = true; - poiService.initialize().then(() => { - expect(poiService.poiGeojsonFiltered.features.length).toBe(0); - expect(databaseService.storePois).toHaveBeenCalled(); - expect(databaseService.storeImages).toHaveBeenCalled(); - expect(databaseService.deletePois).toHaveBeenCalled(); - }); - mockBackend.match(r => r.url.startsWith(Urls.poiCategories)).forEach(t => t.flush([{ icon: "icon", name: "category" }])); - tick(); - mockBackend.expectOne(r => r.url.startsWith(Urls.poiUpdates)).flush({ - features: [{ - type: "Feature", - properties: { poiDeleted: true, poiId: "1"} - }], - images: [], - lastModified: Date.now() - }); - discardPeriodicTasks(); - } - )) - ); - it("Should get selectable categories", inject([PoiService, Store], (poiService: PoiService, store: Store) => { store.reset({ @@ -330,13 +293,13 @@ describe("Poi Service", () => { } ))); - it("Should get a point by id and source from the database in case the server is not available", - (inject([PoiService, HttpTestingController, DatabaseService], - async (poiService: PoiService, mockBackend: HttpTestingController, dbMock: DatabaseService) => { + it("Should get a point by id and source from the tiles in case the server is not available", + (inject([PoiService, HttpTestingController, MapService], + async (poiService: PoiService, mockBackend: HttpTestingController, mapServiceMock: MapService) => { - const id = "42"; + const id = "node_42"; const source = "source"; - const spy = spyOn(dbMock, "getPoiById").and.returnValue(Promise.resolve({} as any)); + const spy = spyOn(mapServiceMock.map, "querySourceFeatures").and.returnValue([{ id: "421", properties: {}, geometry: { type: "Point", coordinates: [0,0]}} as any]); const promise = poiService.getPoint(id, source).then((res) => { expect(res).not.toBeNull(); @@ -350,13 +313,13 @@ describe("Poi Service", () => { ) )); - it("Should throw when trying to get a point by id and source and it is not available in the server and in the database", - (inject([PoiService, HttpTestingController, DatabaseService], - async (poiService: PoiService, mockBackend: HttpTestingController, dbMock: DatabaseService) => { + it("Should throw when trying to get a point by id and source and it is not available in the server and in the tiles", + (inject([PoiService, HttpTestingController, MapService], + async (poiService: PoiService, mockBackend: HttpTestingController, mapServiceMock: MapService) => { const id = "42"; const source = "source"; - const spy = spyOn(dbMock, "getPoiById"); + const spy = spyOn(mapServiceMock.map, "querySourceFeatures").and.returnValue([]); const promise = new Promise((resolve, reject) => { poiService.getPoint(id, source).then(reject, (err) => { @@ -489,9 +452,9 @@ describe("Poi Service", () => { expect(feature.properties.poiId).not.toBeNull(); expect(feature.properties.poiSource).toBe("OSM"); expect(feature.properties["description:he"]).toBe("description"); - expect(poiService.getDescription(feature, "he")).toBe("description"); + expect(GeoJSONUtils.getDescription(feature, "he")).toBe("description"); expect(feature.properties["name:he"]).toBe("title"); - expect(poiService.getTitle(feature, "he")).toBe("title"); + expect(GeoJSONUtils.getTitle(feature, "he")).toBe("title"); expect(feature.properties.poiAddedUrls).toEqual(["some-new-url"]); expect(feature.properties.poiRemovedUrls).toEqual(["some-old-url"]); expect(feature.properties.poiAddedImages).toEqual(["some-new-image-url"]); @@ -499,8 +462,8 @@ describe("Poi Service", () => { expect(feature.properties.poiIcon).toBe("icon-spring"); expect(feature.properties.poiGeolocation.lat).toBe(1); expect(feature.properties.poiGeolocation.lon).toBe(2); - expect(poiService.getLocation(feature).lat).toBe(1); - expect(poiService.getLocation(feature).lng).toBe(2); + expect(GeoJSONUtils.getLocation(feature).lat).toBe(1); + expect(GeoJSONUtils.getLocation(feature).lng).toBe(2); // expected to not change geometry expect(feature.geometry.type).toBe("Point"); expect((feature.geometry as GeoJSON.Point).coordinates).toEqual([0, 0]); @@ -574,7 +537,7 @@ describe("Poi Service", () => { coordinates: [0, 0] } } as GeoJSON.Feature; - poiService.setLocation(featureInQueue, { lat: 1, lng: 2 }); + GeoJSONUtils.setLocation(featureInQueue, { lat: 1, lng: 2 }); dbMock.getPoiFromUploadQueue = () => Promise.resolve(featureInQueue); store.reset({ poiState: { @@ -615,16 +578,16 @@ describe("Poi Service", () => { expect(feature.properties.poiId).not.toBeNull(); expect(feature.properties.poiSource).toBe("OSM"); expect(feature.properties["description:he"]).toBe("description"); - expect(poiService.getDescription(feature, "he")).toBe("description"); + expect(GeoJSONUtils.getDescription(feature, "he")).toBe("description"); expect(feature.properties["name:he"]).toBe("title"); - expect(poiService.getTitle(feature, "he")).toBe("title"); + expect(GeoJSONUtils.getTitle(feature, "he")).toBe("title"); expect(feature.properties.poiAddedUrls).toEqual(["some-url"]); expect(feature.properties.poiAddedImages).toEqual(["some-image-url"]); expect(feature.properties.poiIcon).toBeUndefined(); expect(feature.properties.poiGeolocation.lat).toBe(1); expect(feature.properties.poiGeolocation.lon).toBe(2); - expect(poiService.getLocation(feature).lat).toBe(1); - expect(poiService.getLocation(feature).lng).toBe(2); + expect(GeoJSONUtils.getLocation(feature).lat).toBe(1); + expect(GeoJSONUtils.getLocation(feature).lng).toBe(2); // expected to not change geometry expect(feature.geometry.type).toBe("Point"); expect((feature.geometry as GeoJSON.Point).coordinates).toEqual([0, 0]); @@ -660,11 +623,11 @@ describe("Poi Service", () => { poiService.mergeWithPoi(feature, markerData); const info = poiService.getEditableDataFromFeature(feature); const featureAfterConverstion = poiService.getFeatureFromEditableData(info); - poiService.setLocation(featureAfterConverstion, { lat: 2, lng: 1}); - expect(poiService.getLocation(featureAfterConverstion).lat).toBe(2); - expect(poiService.getLocation(featureAfterConverstion).lng).toBe(1); - expect(poiService.getDescription(featureAfterConverstion, "he")).toBe("description"); - expect(poiService.getTitle(featureAfterConverstion, "he")).toBe("title"); + GeoJSONUtils.setLocation(featureAfterConverstion, { lat: 2, lng: 1}); + expect(GeoJSONUtils.getLocation(featureAfterConverstion).lat).toBe(2); + expect(GeoJSONUtils.getLocation(featureAfterConverstion).lng).toBe(1); + expect(GeoJSONUtils.getDescription(featureAfterConverstion, "he")).toBe("description"); + expect(GeoJSONUtils.getTitle(featureAfterConverstion, "he")).toBe("title"); expect(featureAfterConverstion.properties.image).toBe("image-url"); expect(featureAfterConverstion.properties.image0).toBeUndefined(); expect(featureAfterConverstion.properties.poiIcon).toBe("icon-some-type"); @@ -704,14 +667,6 @@ describe("Poi Service", () => { }) )); - it("should return has extra data for feature with description", inject([PoiService], (poiService: PoiService) => { - expect(poiService.hasExtraData({properties: { "description:he": "desc"}} as any as GeoJSON.Feature, "he")).toBeTruthy(); - })); - - it("should return has extra data for feature with image", inject([PoiService], (poiService: PoiService) => { - expect(poiService.hasExtraData({properties: { image: "image-url"}} as any as GeoJSON.Feature, "he")).toBeTruthy(); - })); - it("should return the itm coordinates for feature", inject([PoiService], (poiService: PoiService) => { const results = poiService.getItmCoordinates({properties: { poiItmEast: 1, poiItmNorth: 2}} as any as GeoJSON.Feature); expect(results.east).toBe(1); @@ -727,37 +682,7 @@ describe("Poi Service", () => { expect(results.userName).toBe("name"); })); - it("should get extenal description for hebrew", inject([PoiService], (poiService: PoiService) => { - const results = poiService.getExternalDescription( - {properties: { "poiExternalDescription:he": "desc"}} as any as GeoJSON.Feature, "he"); - expect(results).toBe("desc"); - })); - - it("should get extenal description for language independant", inject([PoiService], (poiService: PoiService) => { - const results = poiService.getExternalDescription( - {properties: { poiExternalDescription: "desc"}} as any as GeoJSON.Feature, "he"); - expect(results).toBe("desc"); - })); - - it("should get title when there's mtb name with language", inject([PoiService], (poiService: PoiService) => { - const results = poiService.getTitle({properties: { "mtb:name:he": "name"}} as any as GeoJSON.Feature, "he"); - expect(results).toBe("name"); - })); - - it("should get title when there's mtb name without language", inject([PoiService], (poiService: PoiService) => { - const results = poiService.getTitle({properties: { "mtb:name": "name"}} as any as GeoJSON.Feature, "he"); - expect(results).toBe("name"); - })); - - it("should get title even when there's no title for language description", inject([PoiService], (poiService: PoiService) => { - const results = poiService.getTitle({properties: { name: "name"}} as any as GeoJSON.Feature, "he"); - expect(results).toBe("name"); - })); - - it("should get title even when there's no title for language description", inject([PoiService], (poiService: PoiService) => { - const results = poiService.getTitle({properties: { name: "name"}} as any as GeoJSON.Feature, "he"); - expect(results).toBe("name"); - })); + it("should get social links", inject([PoiService], (poiService: PoiService) => { const results = poiService.getFeatureFromCoordinatesId("1_2", "he"); diff --git a/IsraelHiking.Web/src/application/services/poi.service.ts b/IsraelHiking.Web/src/application/services/poi.service.ts index 29df4b6e5..6e0681d1a 100644 --- a/IsraelHiking.Web/src/application/services/poi.service.ts +++ b/IsraelHiking.Web/src/application/services/poi.service.ts @@ -1,27 +1,29 @@ import { Injectable, EventEmitter, NgZone } from "@angular/core"; import { HttpClient, HttpParams } from "@angular/common/http"; -import { NgProgress } from "ngx-progressbar"; -import { uniq, cloneDeep, isEqualWith } from "lodash-es"; -import { fromEvent, Subscription, firstValueFrom } from "rxjs"; -import { timeout, throttleTime, skip, filter } from "rxjs/operators"; +import { cloneDeep, isEqualWith } from "lodash-es"; +import { firstValueFrom } from "rxjs"; +import { timeout, skip } from "rxjs/operators"; import { v4 as uuidv4 } from "uuid"; import { Store } from "@ngxs/store"; -import JSZip from "jszip"; -import MiniSearch from "minisearch"; +import osmtogeojson from "osmtogeojson"; import type { Immutable } from "immer"; +import type { MapGeoJSONFeature, SourceSpecification } from "maplibre-gl"; import { ResourcesService } from "./resources.service"; import { HashService, PoiRouterData, RouteStrings } from "./hash.service"; import { WhatsAppService } from "./whatsapp.service"; -import { DatabaseService, ImageUrlAndData } from "./database.service"; +import { DatabaseService } from "./database.service"; import { RunningContextService } from "./running-context.service"; import { SpatialService } from "./spatial.service"; import { LoggingService } from "./logging.service"; import { GeoJsonParser } from "./geojson.parser"; import { MapService } from "./map.service"; -import { FileService } from "./file.service"; import { ConnectionService } from "./connection.service"; -import { AddToPoiQueueAction, RemoveFromPoiQueueAction, SetOfflinePoisLastModifiedDateAction } from "../reducers/offline.reducer"; +import { OverpassTurboService } from "./overpass-turbo.service"; +import { GeoJSONUtils } from "./geojson-utils"; +import { INatureService } from "./inature.service"; +import { WikidataService } from "./wikidata.service"; +import { AddToPoiQueueAction, RemoveFromPoiQueueAction } from "../reducers/offline.reducer"; import { SetCategoriesGroupVisibilityAction, AddCategoryAction, @@ -35,25 +37,13 @@ import type { ApplicationState, Category, IconColorLabel, - SearchResultsPointOfInterest, Contribution, NorthEast, EditablePublicPointData, OfflineState } from "../models/models"; -export type SimplePointType = "Tap" | "CattleGrid" | "Parking" | "OpenGate" | "ClosedGate" | "Block" | "PicnicSite"; - -type ImageItem = { - thumbnail: string; - imageUrls: string[]; -}; - -type UpdatesResponse = { - features: GeoJSON.Feature[]; - images: ImageItem[]; - lastModified: Date; -}; +export type SimplePointType = "Tap" | "CattleGrid" | "Parking" | "OpenGate" | "ClosedGate" | "Block" | "PicnicSite" export type PoiSocialLinks = { poiLink: string; @@ -69,16 +59,52 @@ export interface ISelectableCategory extends Category { label: string; } +type Geolocation = { + lat: number; + lon: number; +} + +type PoiProperties = { + poiSource: string; + poiId: string; + identifier: string; + poiGeolocation: Geolocation; + poiLanguage: string; + poiIconColor: string; + poiIcon: string; + poiCategory: string; + "name:he"?: string; + "name:en"?: string; +} + +type SourceLayerAndJson = { + sourceLayer: string; + source: SourceSpecification; +} + @Injectable() export class PoiService { + + private static readonly POIS_MAP: Record = { + "points-of-interest": { sourceLayer: "public_pois", source: { + type: "vector", + url: "https://israelhiking.osm.org.il/vector/data/public_pois.json" + } }, + "trail-points-of-interest": { sourceLayer: "trail_pois", source: { + type: "vector", + url: "https://israelhiking.osm.org.il/vector/data/trail_pois.json" + } }, + "external-points-of-interest": { sourceLayer: "external", source: { + type: "vector", + url: "https://israelhiking.osm.org.il/vector/data/external.json" + } } + } + private poisCache: GeoJSON.Feature[]; - private poisGeojson: GeoJSON.FeatureCollection; - private miniSearch: MiniSearch; private queueIsProcessing: boolean; - private moveEndSubsription: Subscription; private offlineState: Immutable; - public poiGeojsonFiltered: GeoJSON.FeatureCollection; + public poiGeojsonFiltered: GeoJSON.FeatureCollection; public poisChanged: EventEmitter; constructor(private readonly resources: ResourcesService, @@ -91,42 +117,21 @@ export class PoiService { private readonly geoJsonParser: GeoJsonParser, private readonly loggingService: LoggingService, private readonly mapService: MapService, - private readonly fileService: FileService, private readonly connectionService: ConnectionService, - private readonly ngPregress: NgProgress, + private readonly overpassTurboService: OverpassTurboService, + private readonly iNatureService: INatureService, + private readonly wikidataService: WikidataService, private readonly store: Store ) { this.poisCache = []; this.poisChanged = new EventEmitter(); this.queueIsProcessing = false; - this.moveEndSubsription = null; this.poiGeojsonFiltered = { type: "FeatureCollection", features: [] }; - this.poisGeojson = { - type: "FeatureCollection", - features: [] - }; - this.miniSearch = new MiniSearch({ - idField: "poiId", - extractField: (p: GeoJSON.Feature, fieldName) => { - if (fieldName === "poiId") { - return this.getFeatureId(p); - } - if (p.properties.poiNames[fieldName]) { - return p.properties.poiNames[fieldName].join(" "); - } - return ""; - }, - fields: ["he", "en"], - searchOptions: { - fuzzy: 0.2 - } - }); - this.store.select((s: ApplicationState) => s.offlineState).subscribe(offlineState => this.offlineState = offlineState); } @@ -134,33 +139,14 @@ export class PoiService { this.store.select((state: ApplicationState) => state.configuration.language).pipe(skip(1)).subscribe(() => { this.poisCache = []; this.loggingService.info("[POIs] Language changed, updating pois"); - this.updatePois(this.offlineState.poisLastModifiedDate == null); + this.updatePois(); }); this.store.select((state: ApplicationState) => state.layersState.categoriesGroups).pipe(skip(1)).subscribe(() => { this.loggingService.info("[POIs] Categories changed, updating pois"); - this.updatePois(this.offlineState.poisLastModifiedDate == null); + this.updatePois(); }); await this.syncCategories(); - this.updatePois(true); // don't wait await this.mapService.initializationPromise; - let lastLocation = this.mapService.map.getCenter(); - this.moveEndSubsription = fromEvent(this.mapService.map as any, "moveend") - .pipe( - throttleTime(500, undefined, { trailing: true }), - filter(() => { - const lastLocationPoint = this.mapService.map.project(lastLocation); - return lastLocationPoint.dist(this.mapService.map.project(this.mapService.map.getCenter())) > 200; - }), - ).subscribe(() => { - lastLocation = this.mapService.map.getCenter(); - this.ngZone.run(() => { - this.updatePois(true); - }); - }); - - if (this.runningContextService.isCapacitor) { - await this.updateOfflinePois(); - } this.store.select((state: ApplicationState) => state.offlineState.uploadPoiQueue).subscribe((items: Immutable) => this.handleUploadQueueChanges(items)); this.connectionService.stateChanged.subscribe(online => { this.loggingService.info(`[POIs] Connection status changed to: ${online}`); @@ -168,7 +154,53 @@ export class PoiService { this.handleUploadQueueChanges(this.offlineState.uploadPoiQueue); } }); - + this.initializePois(); + } + + private initializePois() { + for (const source of Object.keys(PoiService.POIS_MAP)) { + const sourceLayer = PoiService.POIS_MAP[source]; + this.mapService.map.addSource(source, sourceLayer.source); + this.mapService.map.addLayer({ + id: `${source}-layer`, + type: "circle", + source: source, + "source-layer": sourceLayer.sourceLayer, + paint: { + "circle-color": "transparent", + } + }, this.resources.endOfBaseLayer); + + if (this.store.selectSnapshot((s: ApplicationState) => s.offlineState.lastModifiedDate) != null) { + this.mapService.map.addSource(`${source}-offline`, { + type: "vector", + tiles: [`custom://${sourceLayer.sourceLayer}/{z}/{x}/{y}.pbf`], + minzoom: 10, + maxzoom: 14 + }); + this.mapService.map.addLayer({ + id: `${source}-offline-layer`, + type: "circle", + source: `${source}-offline`, + "source-layer": sourceLayer.sourceLayer, + paint: { + "circle-color": "transparent", + } + }, this.resources.endOfBaseLayer); + } + } + this.mapService.map.on("sourcedata", (e) => { + if (Object.keys(PoiService.POIS_MAP).includes(e.sourceId)) { + this.ngZone.run(() => { + this.updatePois(); + }); + } + }); + this.mapService.map.on("moveend", () => { + this.ngZone.run(() => { + this.updatePois(); + }); + }); } private async handleUploadQueueChanges(items: Immutable) { @@ -200,14 +232,11 @@ export class PoiService { const poi = await firstValueFrom(poi$) as GeoJSON.Feature; if (feature.properties.poiIsSimple) { this.loggingService.info("[POIs] Uploaded successfully a simple feature with generated id: " + - `${firstItemId} at: ${JSON.stringify(this.getLocation(feature))}, removing from upload queue`); + `${firstItemId} at: ${JSON.stringify(GeoJSONUtils.getLocation(feature))}, removing from upload queue`); } else { this.loggingService.info("[POIs] Uploaded successfully a feature with id:" + `${this.getFeatureId(poi) ?? firstItemId}, removing from upload queue`); - if (this.runningContextService.isCapacitor) { - await this.databaseService.storePois([poi]); - this.rebuildPois(); - } + this.updatePois(); } this.databaseService.removePoiFromUploadQueue(firstItemId); this.queueIsProcessing = false; @@ -233,211 +262,331 @@ export class PoiService { } } - private async getPoisFromServer(): Promise[]> { - const visibleCategories = this.getVisibleCategories(); - if (this.mapService.map.getZoom() <= 10) { - return []; + private setIconColorCategory(feature: GeoJSON.Feature, poi: GeoJSON.Feature) { + if (poi.properties.poiIconColor && poi.properties.poiIcon && poi.properties.poiCategory) { + return; } - const bounds = SpatialService.getMapBounds(this.mapService.map); - // Adding half a screen padding: - bounds.northEast.lng += (bounds.northEast.lng - bounds.southWest.lng) / 2.0; - bounds.northEast.lat += (bounds.northEast.lat - bounds.southWest.lat) / 2.0; - bounds.southWest.lng -= (bounds.northEast.lng - bounds.southWest.lng) / 2.0; - bounds.southWest.lat -= (bounds.northEast.lat - bounds.southWest.lat) / 2.0; - - const language = this.resources.getCurrentLanguageCodeSimplified(); - const params = new HttpParams() - .set("northEast", bounds.northEast.lat + "," + bounds.northEast.lng) - .set("southWest", bounds.southWest.lat + "," + bounds.southWest.lng) - .set("categories", visibleCategories.join(",")) - .set("language", language); - try { - const features$ = this.httpClient.get(Urls.poi, { params }).pipe(timeout(10000)); - this.poisGeojson.features = await firstValueFrom(features$) as GeoJSON.Feature[]; - return this.poisGeojson.features; - } catch { - return this.poisGeojson.features; + if (feature.properties.boundary === "protected_area" || + feature.properties.boundary === "national_park" || + feature.properties.leisure === "nature_reserve") { + poi.properties.poiIconColor = "#008000"; + poi.properties.poiIcon = "icon-nature-reserve"; + poi.properties.poiCategory = "Other"; + return; } - } - - private getPoisFromMemory(): GeoJSON.Feature[] { - const visibleFeatures = []; - const visibleCategories = this.getVisibleCategories(); - const language = this.resources.getCurrentLanguageCodeSimplified(); - for (const feature of this.poisGeojson.features) { - if (feature.properties.poiLanguage !== "all" && feature.properties.poiLanguage !== language) { - continue; + if (feature.properties.network) { + switch (feature.properties.network) { + case "lcn": + case "rcn": + poi.properties.poiIconColor = "black"; + poi.properties.poiIcon = "icon-bike"; + poi.properties.poiCategory = "Bicycle"; + return; + case "lwn": + case "rwn": + poi.properties.poiIconColor = "black"; + poi.properties.poiIcon = "icon-hike"; + poi.properties.poiCategory = "Hiking"; + return; } - if (visibleCategories.indexOf(feature.properties.poiCategory) === -1) { - continue; + } + if (feature.properties.route) { + switch (feature.properties.route) { + case "hiking": + poi.properties.poiIconColor = "black"; + poi.properties.poiIcon = "icon-hike"; + poi.properties.poiCategory = "Hiking"; + return; + case "bicycle": + poi.properties.poiIconColor = "black"; + poi.properties.poiIcon = "icon-bike"; + poi.properties.poiCategory = "Bicycle"; + return; } - if (this.getTitle(feature, language) || this.hasExtraData(feature, language)) { - visibleFeatures.push(feature); + } + if (feature.properties.historic) { + poi.properties.poiIconColor = "#666666"; + poi.properties.poiCategory = "Historic"; + switch (feature.properties.historic) { + case "ruins": + poi.properties.poiIcon = "icon-ruins"; + return; + case "archaeological_site": + poi.properties.poiIcon = "icon-archaeological"; + return; + case "memorial": + case "monument": + poi.properties.poiIcon = "icon-memorial"; + return; + case "tomb": + poi.properties.poiIconColor = "black"; + poi.properties.poiIcon = "icon-cave"; + poi.properties.poiCategory = "Natural"; + return; } } - return visibleFeatures; - } + if (feature.properties.leisure === "picnic_table" || + feature.properties.tourism === "picnic_site" || + feature.properties.amenity === "picnic") { + poi.properties.poiIconColor = "#734a08"; + poi.properties.poiIcon = "icon-picnic"; + poi.properties.poiCategory = "Camping"; + return; + } - private async rebuildPois() { - this.poisGeojson.features = await this.databaseService.getPoisForClustering(); - if (this.poisGeojson.features.length > 0 && this.moveEndSubsription != null) { - this.loggingService.info("[POIs] Unsubscribing from move end, pois are from database now"); - this.moveEndSubsription.unsubscribe(); + if (feature.properties.natural) { + switch (feature.properties.natural) { + case "cave_entrance": + poi.properties.poiIconColor = "black"; + poi.properties.poiIcon = "icon-cave"; + poi.properties.poiCategory = "Natural"; + return; + case "spring": + poi.properties.poiIconColor = "blue"; + poi.properties.poiIcon = "icon-tint"; + poi.properties.poiCategory = "Water"; + return; + case "tree": + poi.properties.poiIconColor = "#008000"; + poi.properties.poiIcon = "icon-tree"; + poi.properties.poiCategory = "Natural"; + return; + case "flowers": + poi.properties.poiIconColor = "#008000"; + poi.properties.poiIcon = "icon-flowers"; + poi.properties.poiCategory = "Natural"; + return; + case "waterhole": + poi.properties.poiIconColor = "blue"; + poi.properties.poiIcon = "icon-waterhole"; + poi.properties.poiCategory = "Water"; + return; + } } - this.miniSearch.addAllAsync(this.poisGeojson.features); - this.loggingService.info(`[POIs] Finished getting pois from database and adding to memory: ${this.poisGeojson.features.length}`); - this.updatePois(false); - } - private async updateOfflinePois() { - try { - let lastModified = this.offlineState.poisLastModifiedDate; - if (lastModified != null) { - lastModified = new Date(lastModified); // deserialize from json + if (feature.properties.water === "reservoir" || + feature.properties.water === "pond") { + poi.properties.poiIconColor = "blue"; + poi.properties.poiIcon = "icon-tint"; + poi.properties.poiCategory = "Water"; + return; + } + + if (feature.properties.man_made) { + poi.properties.poiIconColor = "blue"; + poi.properties.poiCategory = "Water"; + switch (feature.properties.man_made) { + case "water_well": + poi.properties.poiIcon = "icon-water-well"; + return; + case "cistern": + poi.properties.poiIcon = "icon-cistern"; + return; } - this.loggingService.info(`[POIs] Getting POIs for: ${lastModified ? lastModified.toUTCString() : null} from server`); - if (lastModified == null || Date.now() - lastModified.getTime() > 1000 * 60 * 60 * 24 * 180) { - await this.downlodOfflineFileAndUpdateDatabase((value) => this.ngPregress.ref().set(value)); - lastModified = this.offlineState.poisLastModifiedDate; + } + + if (feature.properties.waterway === "waterfall") { + poi.properties.poiIconColor = "blue"; + poi.properties.poiIcon = "icon-waterfall"; + poi.properties.poiCategory = "Water"; + return; + } + + if (feature.properties.place) { + poi.properties.poiIconColor = "black"; + poi.properties.poiIcon = "icon-home"; + poi.properties.poiCategory = "Wikipedia"; + return; + } + + if (feature.properties.tourism) { + switch (feature.properties.tourism) { + case "viewpoint": + poi.properties.poiIconColor = "#008000"; + poi.properties.poiIcon = "icon-viewpoint"; + poi.properties.poiCategory = "Viewpoint"; + return; + case "picnic_site": + poi.properties.poiIconColor = "#734a08"; + poi.properties.poiIcon = "icon-picnic"; + poi.properties.poiCategory = "Camping"; + return; + case "camp_site": + poi.properties.poiIconColor = "#734a08"; + poi.properties.poiIcon = "icon-campsite"; + poi.properties.poiCategory = "Camping"; + return; + case "attraction": + poi.properties.poiIconColor = "#ffb800"; + poi.properties.poiIcon = "icon-star"; + poi.properties.poiCategory = "Other"; + return; } - if (lastModified == null) { - return; + return; + } + + if (feature.properties.wikidata || feature.properties.wikipedia) { + poi.properties.poiIconColor = "black"; + poi.properties.poiIcon = "icon-wikipedia-w"; + poi.properties.poiCategory = "Wikipedia"; + return; + } + + if (feature.properties.natural === "peak") { + poi.properties.poiIconColor = "black"; + poi.properties.poiIcon = "icon-peak"; + poi.properties.poiCategory = "Other"; + return; + } + + if (feature.properties["mtb:name"]) { + poi.properties.poiIconColor = "gray"; + poi.properties.poiIcon = "icon-bike"; + poi.properties.poiCategory = "Bicycle"; + return; + } + + poi.properties.poiIconColor = "black"; + poi.properties.poiIcon = "icon-search"; + poi.properties.poiCategory = "Other"; + + } + + private getGeolocation(feature: GeoJSON.Feature): Geolocation { + switch (feature.geometry.type) { + case "Point": + return { + lat: feature.geometry.coordinates[1], + lon: feature.geometry.coordinates[0], + }; + case "LineString": + return { + lat: feature.geometry.coordinates[0][1], + lon: feature.geometry.coordinates[0][0], + }; + case "Polygon": { + const bounds = SpatialService.getBoundsForFeature(feature); + return { + lat: (bounds.northEast.lat + bounds.southWest.lat) / 2, + lon: (bounds.northEast.lng + bounds.southWest.lng) / 2, + }; + } + case "MultiPolygon": { + const bounds = SpatialService.getBoundsForFeature(feature); + return { + lat: (bounds.northEast.lat + bounds.southWest.lat) / 2, + lon: (bounds.northEast.lng + bounds.southWest.lng) / 2, + }; } - await this.updateOfflinePoisByPaging(lastModified); - } catch (ex) { - this.loggingService.warning("[POIs] Unable to sync public pois and categories - using local data: " + (ex as Error).message); - } - this.loggingService.info("[POIs] Getting POIs for clustering from database"); - await this.rebuildPois(); - } - - private async downlodOfflineFileAndUpdateDatabase(progressCallback: (value: number, text?: string) => void): Promise { - progressCallback(1, this.resources.downloadingPoisForOfflineUsage); - const poiIdsToDelete = this.poisGeojson.features.map(f => this.getFeatureId(f)); - this.loggingService.info(`[POIs] Deleting exiting pois: ${poiIdsToDelete.length}`); - await this.databaseService.deletePois(poiIdsToDelete); - this.loggingService.info("[POIs] Getting cached offline pois file"); - let poisFile = await this.fileService.getFileFromCache(Urls.poisOfflineFile); - if (poisFile == null) { - this.loggingService.info("[POIs] No file in cache, downloading pois file"); - await this.fileService.downloadFileToCache(Urls.poisOfflineFile, (value) => progressCallback(value * 50)); - this.loggingService.info("[POIs] Finished downloading pois file, reading file"); - poisFile = await this.fileService.getFileFromCache(Urls.poisOfflineFile); - } - this.loggingService.info("[POIs] Opening pois file"); - const lastModified = await this.openPoisFile(poisFile, progressCallback); - this.loggingService.info(`[POIs] Updating last modified to: ${lastModified}`); - this.store.dispatch(new SetOfflinePoisLastModifiedDateAction(lastModified)); - this.loggingService.info(`[POIs] Finished downloading file and updating database, last modified: ${lastModified.toUTCString()}`); - await this.fileService.deleteFileFromCache(Urls.poisOfflineFile); - this.loggingService.info("[POIs] Finished deleting offline pois cached file"); - } - - private async updateOfflinePoisByPaging(lastModified: Date) { - let modifiedUntil = lastModified; - do { - lastModified = modifiedUntil; - modifiedUntil = new Date(lastModified.getTime() + 3 * 24 * 60 * 60 * 1000); // last modified + 3 days - this.loggingService.info(`[POIs] Getting POIs for: ${lastModified.toUTCString()} - ${modifiedUntil.toUTCString()}`); - const updates$ = this.httpClient.get(`${Urls.poiUpdates}${lastModified.toISOString()}/${modifiedUntil.toISOString()}`) - .pipe(timeout(60000)); - const updates = await firstValueFrom(updates$) as UpdatesResponse; - this.loggingService.info(`[POIs] Storing POIs for: ${lastModified.toUTCString()} - ${modifiedUntil.toUTCString()},` + - `got: ${ updates.features.length }`); - const deletedIds = updates.features.filter(f => f.properties.poiDeleted).map(f => this.getFeatureId(f)); - do { - await this.databaseService.storePois(updates.features.splice(0, 500)); - } while (updates.features.length > 0); - this.databaseService.deletePois(deletedIds); - const imageAndData = this.imageItemToUrl(updates.images); - this.loggingService.info(`[POIs] Storing images: ${imageAndData.length}`); - this.databaseService.storeImages(imageAndData); - const minDate = new Date(Math.min(new Date(updates.lastModified).getTime(), modifiedUntil.getTime())); - this.loggingService.info(`[POIs] Updating last modified to: ${minDate}`); - this.store.dispatch(new SetOfflinePoisLastModifiedDateAction(minDate)); - } while (modifiedUntil < new Date()); - } - - public async openPoisFile(blob: Blob, progressCallback: (percentage: number, text?: string) => void): Promise { - const zip = new JSZip(); - await zip.loadAsync(blob); - await this.writeImages(zip, progressCallback); - this.loggingService.info("[POIs] Finished saving images to database"); - return await this.writePois(zip, progressCallback); - } - - private async writePois(zip: JSZip, progressCallback: (percentage: number, content: string) => void): Promise { - let lastModified = new Date(0); - const poisFileNames = Object.keys(zip.files).filter(name => name.startsWith("pois/") && name.endsWith(".geojson")); - for (let poiFileIndex = 0; poiFileIndex < poisFileNames.length; poiFileIndex++) { - const poisFileName = poisFileNames[poiFileIndex]; - const poisJson = JSON.parse((await zip.file(poisFileName).async("text")).trim()) as GeoJSON.FeatureCollection; - const chunkLastModified = this.getLastModifiedFromFeatures(poisJson.features); - if (chunkLastModified > lastModified) { - lastModified = chunkLastModified; + case "MultiLineString": + return { + lat: feature.geometry.coordinates[0][0][1], + lon: feature.geometry.coordinates[0][0][0], + }; + default: + throw new Error("Unsupported geometry type: " + feature.geometry.type); + } + } + + private setLanguage(feature: GeoJSON.Feature, poi: GeoJSON.Feature) { + const hasHebrew = feature.properties["name:he"] || feature.properties["mtb:name"]; + const hasEnglish = feature.properties["name:en"] || feature.properties["mtb:name:en"]; + if (hasHebrew || hasEnglish) { + poi.properties.poiLanguage = hasHebrew && hasEnglish ? "all" : hasHebrew ? "he" : "en"; + } + } + + /** + * This will adjust the locaiton accorting to the location in the tile + * instead of clalculating a different location based on the geometry. + * This is somewhat of a hack to solve a difference of calculation between + * the tile generation and client side calculation. + * @param id - the id of the poi, for example OSM_way_1234 + * @param poi - the point of interest to adjust + */ + private adjustGeolocationBasedOnTileDate(id: string, poi: GeoJSON.Feature) { + if (poi.geometry.type === "Point") { + return; + } + for (const source of Object.keys(PoiService.POIS_MAP)) { + const features = this.mapService.map.querySourceFeatures(source, {sourceLayer: PoiService.POIS_MAP[source].sourceLayer}); + const feature = features.find(f => this.osmTileFeatureToPoiIdentifier(f) === id); + if (feature == null) { + continue; } - await this.databaseService.storePois(poisJson.features); - progressCallback(((poiFileIndex + 1) * 10.0 / poisFileNames.length) + 90, this.resources.downloadingPoisForOfflineUsage); - this.loggingService.debug(`[POIs] Stored pois ${poisFileName} ${poiFileIndex}/${poisFileNames.length}`); + poi.properties.poiGeolocation = this.getGeolocation(feature); } - return lastModified; } - private async writeImages(zip: JSZip, progressCallback: (percentage: number, content: string) => void) { - const imagesFileNames = Object.keys(zip.files).filter(name => name.startsWith("images/") && name.endsWith(".json")); - for (let imagesFileIndex = 0; imagesFileIndex < imagesFileNames.length; imagesFileIndex++) { - const imagesFile = imagesFileNames[imagesFileIndex]; - const imagesJson = JSON.parse(await zip.file(imagesFile).async("text") as string) as ImageItem[]; - const imagesUrl = this.imageItemToUrl(imagesJson); - await this.databaseService.storeImages(imagesUrl); - progressCallback((imagesFileIndex + 1) * 40.0 / imagesFileNames.length + 50, this.resources.downloadingPoisForOfflineUsage); - this.loggingService.debug(`[POIs] Stored images ${imagesFile} ${imagesFileIndex}/${imagesFileNames.length}`); + private getPoisFromTiles(): GeoJSON.Feature[] { + let features: MapGeoJSONFeature[] = []; + for (const source of Object.keys(PoiService.POIS_MAP)) { + features = features.concat(this.mapService.map.querySourceFeatures(source, {sourceLayer: PoiService.POIS_MAP[source].sourceLayer})); } + if (features.length === 0) { + for (const source of Object.keys(PoiService.POIS_MAP)) { + features = features.concat(this.mapService.map.querySourceFeatures(`${source}-offline`, {sourceLayer: PoiService.POIS_MAP[source].sourceLayer})); + } + } + const hashSet = new Set(); + let pois = features.map(feature => this.convertFeatureToPoi(feature, this.osmTileFeatureToPoiIdentifier(feature))) + pois = pois.filter(p => { + if (hashSet.has(p.properties.poiId)) { + return false; + } + hashSet.add(p.properties.poiId); + return true; + }); + return this.filterFeatures(pois); } - private getLastModifiedFromFeatures(features: GeoJSON.Feature[]): Date { - let lastModified = null; - for (const feature of features) { - const dateValue = new Date(feature.properties.poiLastModified); - if (lastModified == null || dateValue > lastModified) { - lastModified = dateValue; - } + public osmTileFeatureToPoiIdentifier(feature: GeoJSON.Feature): string { + if (feature.properties.identifier) { + return feature.properties.identifier; } - return lastModified; + const osmType = feature.id.toString().endsWith("1") ? "node_" : feature.id.toString().endsWith("2") ? "way_" : "relation_"; + return osmType + Math.floor((Number(feature.id)/ 10)); + } + + private poiIdentifierToTypeAndId(id: string): {type: string, osmId: string} { + const osmTypeAndId = id.split("_"); + return { + type: osmTypeAndId[0], + osmId: osmTypeAndId[1] + }; } - private imageItemToUrl(images: ImageItem[]): ImageUrlAndData[] { - const imageAndData = [] as ImageUrlAndData[]; - for (const image of images) { - for (const imageUrl of image.imageUrls) { - imageAndData.push({ imageUrl, data: image.thumbnail }); + private convertFeatureToPoi(feature: GeoJSON.Feature, id: string): GeoJSON.Feature { + const poi: GeoJSON.Feature = { + type: "Feature", + geometry: feature.geometry, + properties: JSON.parse(JSON.stringify(feature.properties)) || {} + }; + poi.properties.identifier = poi.properties.identifier || id; + poi.properties.poiSource = poi.properties.poiSource || "OSM"; + poi.properties.poiId = poi.properties.poiId || poi.properties.poiSource + "_" + poi.properties.identifier; + poi.properties.poiGeolocation = poi.properties.poiGeolocation || this.getGeolocation(feature); + this.setIconColorCategory(feature, poi); + this.setLanguage(feature, poi); + return poi; + } + + private filterFeatures(features: GeoJSON.Feature[]): GeoJSON.Feature[] { + const visibleFeatures = []; + const visibleCategories = this.getVisibleCategories(); + const language = this.resources.getCurrentLanguageCodeSimplified(); + for (const feature of features) { + if (feature.properties.poiLanguage !== "all" && feature.properties.poiLanguage !== language) { + continue; } - } - return imageAndData; - } - - public async getSerchResults(searchTerm: string): Promise { - const ids = this.miniSearch.search(searchTerm).map(r => r.id); - const results = [] as SearchResultsPointOfInterest[]; - for (const id of uniq(ids)) { - const feature = await this.databaseService.getPoiById(id); - const title = this.getTitle(feature, this.resources.getCurrentLanguageCodeSimplified()); - const point = { - description: feature.properties.description, - title, - displayName: title, - icon: feature.properties.poiIcon, - iconColor: feature.properties.poiIconColor, - location: this.getLocation(feature), - source: feature.properties.poiSource, - id: feature.properties.identifier - }; - results.push(point); - if (results.length === 10) { - return results; + if (visibleCategories.indexOf(feature.properties.poiCategory) === -1) { + continue; + } + if (GeoJSONUtils.getTitle(feature, language) || GeoJSONUtils.hasExtraData(feature, language)) { + visibleFeatures.push(feature); } } - return results; + return visibleFeatures; } private getVisibleCategories(): string[] { @@ -451,10 +600,9 @@ export class PoiService { return visibleCategories; } - private async updatePois(fromServer: boolean) { + private async updatePois() { await this.mapService.initializationPromise; - const visibleCategories = this.getVisibleCategories(); - if (visibleCategories.length === 0) { + if (this.getVisibleCategories().length === 0) { this.poiGeojsonFiltered = { type: "FeatureCollection", features: [] @@ -462,7 +610,7 @@ export class PoiService { this.poisChanged.next(); return; } - const visibleFeatures = fromServer ? await this.getPoisFromServer() : this.getPoisFromMemory(); + const visibleFeatures = await this.getPoisFromTiles(); this.poiGeojsonFiltered = { type: "FeatureCollection", features: visibleFeatures @@ -524,6 +672,8 @@ export class PoiService { return selectableCategories; } + + public async getPoint(id: string, source: string, language?: string): Promise { const itemInCache = this.poisCache.find(f => this.getFeatureId(f) === id && f.properties.source === source); if (itemInCache) { @@ -533,18 +683,71 @@ export class PoiService { return this.getFeatureFromCoordinatesId(id, language); } try { - const params = new HttpParams().set("language", language || this.resources.getCurrentLanguageCodeSimplified()); - const poi$ = this.httpClient.get(Urls.poi + source + "/" + id, { params }).pipe(timeout(6000)); - const poi = await firstValueFrom(poi$) as GeoJSON.Feature; - this.poisCache.splice(0, 0, poi); - return cloneDeep(poi); + if (source === "OSM") { + const { osmId, type } = this.poiIdentifierToTypeAndId(id); + const osmPoi$ = this.httpClient.get(`https://www.openstreetmap.org/api/0.6/${type}/${osmId}${type !== "node" ? "/full" : ""}`).pipe(timeout(6000)); + const osmPoi = await firstValueFrom(osmPoi$); + const geojson = osmtogeojson(osmPoi); + const feature = geojson.features[0]; + let wikidataPromise = Promise.resolve(); + let inaturePromise = Promise.resolve(); + let placePromise = Promise.resolve({features: []}); + let wayPromise = Promise.resolve({features: []}); + if (feature.properties.wikidata) { + wikidataPromise = this.wikidataService.enritchFeatureFromWikimedia(feature, language); + } + if (feature.properties["ref:IL:inature"] && language === "he") { + inaturePromise = this.iNatureService.enritchFeatureFromINature(feature); + } + if (type === "node" && feature.properties.place) { + placePromise = this.overpassTurboService.getPlaceGeometry(osmId); + } + if (type === "way" && (feature.properties.highway || feature.properties.waterway)) { + wayPromise = this.overpassTurboService.getLongWay(osmId, + feature.properties["mtb:name"] || feature.properties.name, + feature.properties.waterway != null, + feature.properties["mtb:name"] != null); + } + await Promise.all([wikidataPromise, inaturePromise, placePromise, wayPromise]); + const placeGeojson = await placePromise; + if (placeGeojson.features.length > 0) { + feature.geometry = placeGeojson.features[0].geometry; + } + const longGeojson = await wayPromise; + if (longGeojson.features.length > 1) { + feature.geometry = SpatialService.mergeLines(longGeojson.features) as GeoJSON.Geometry; + } + const poi = this.convertFeatureToPoi(feature, id); + this.adjustGeolocationBasedOnTileDate(id, poi); + this.poisCache.splice(0, 0, poi); + return cloneDeep(poi); + } else if (source === "iNature") { + const feature = await this.iNatureService.createFeatureFromPageId(id); + this.poisCache.splice(0, 0, feature); + return cloneDeep(feature); + } else if (source === "Wikidata") { + const feature = await this.wikidataService.createFeatureFromPageId(id, language); + this.poisCache.splice(0, 0, feature); + return cloneDeep(feature); + } else { + const params = new HttpParams().set("language", language || this.resources.getCurrentLanguageCodeSimplified()); + const poi$ = this.httpClient.get(Urls.poi + source + "/" + id, { params }).pipe(timeout(6000)); + const poi = await firstValueFrom(poi$) as GeoJSON.Feature; + this.poisCache.splice(0, 0, poi); + return cloneDeep(poi); + } } catch { - const feature = await this.databaseService.getPoiById(`${source}_${id}`); + let features: MapGeoJSONFeature[] = []; + for (const source of Object.keys(PoiService.POIS_MAP)) { + features = features.concat(this.mapService.map.querySourceFeatures(`${source}-offline`, {sourceLayer: PoiService.POIS_MAP[source].sourceLayer})); + } + const feature = features.find(f => this.osmTileFeatureToPoiIdentifier(f) === id); if (feature == null) { throw new Error("Failed to load POI from offline database."); } - this.poisCache.splice(0, 0, feature); - return feature; + const poi = this.convertFeatureToPoi(feature, id); + this.poisCache.splice(0, 0, poi); + return poi; } } @@ -570,8 +773,8 @@ export class PoiService { coordinates: SpatialService.toCoordinate(latlng) } } as GeoJSON.Feature; - this.setLocation(feature, latlng); - this.setTitle(feature, id, language); + GeoJSONUtils.setLocation(feature, latlng); + GeoJSONUtils.setTitle(feature, id, language); return feature; } @@ -590,20 +793,20 @@ export class PoiService { language } as PoiRouterData); const escaped = encodeURIComponent(poiLink); - const location = this.getLocation(feature); + const location = GeoJSONUtils.getLocation(feature); return { poiLink, facebook: `${Urls.facebook}${escaped}`, - whatsapp: this.whatsappService.getUrl(this.getTitle(feature, language), escaped) as string, + whatsapp: this.whatsappService.getUrl(GeoJSONUtils.getTitle(feature, language), escaped) as string, waze: `${Urls.waze}${location.lat},${location.lng}` }; } public mergeWithPoi(feature: GeoJSON.Feature, markerData: Immutable) { const language = this.resources.getCurrentLanguageCodeSimplified(); - this.setTitle(feature, feature.properties["name:" + language] || markerData.title, language); - this.setDescription(feature, feature.properties["description:" + language] || markerData.description, language); - this.setLocation(feature, markerData.latlng); + GeoJSONUtils.setTitle(feature, feature.properties["name:" + language] || markerData.title, language); + GeoJSONUtils.setDescription(feature, feature.properties["description:" + language] || markerData.description, language); + GeoJSONUtils.setLocation(feature, markerData.latlng); feature.properties.poiIcon = feature.properties.poiIcon || `icon-${markerData.type || "star"}`; let lastIndex = Math.max(-1, ...Object.keys(feature.properties) .filter(k => k.startsWith("image")) @@ -618,53 +821,6 @@ export class PoiService { return feature; } - public setDescription(feature: GeoJSON.Feature, value: string, language: string) { - feature.properties["description:" + language] = value; - } - - public setTitle(feature: GeoJSON.Feature, value: string, language: string) { - feature.properties["name:" + language] = value; - } - - public setLocation(feature: GeoJSON.Feature, value: LatLngAlt) { - feature.properties.poiGeolocation = { - lat: value.lat, - lon: value.lng - }; - } - - public getTitle(feature: Immutable, language: string): string { - if (feature.properties["name:" + language]) { - return feature.properties["name:" + language]; - } - if (feature.properties.name) { - return feature.properties.name; - } - if (feature.properties["mtb:name:"+ language]) { - return feature.properties["mtb:name:"+ language]; - } - if (feature.properties["mtb:name"]) { - return feature.properties["mtb:name"]; - } - return ""; - } - - public getDescription(feature: Immutable, language: string): string { - return feature.properties["description:" + language] || feature.properties.description; - } - - public getExternalDescription(feature: GeoJSON.Feature, language: string): string { - return feature.properties["poiExternalDescription:" + language] || feature.properties.poiExternalDescription; - } - - public getLocation(feature: GeoJSON.Feature): LatLngAlt { - return { - lat: feature.properties.poiGeolocation.lat, - lng: feature.properties.poiGeolocation.lon, - alt: feature.properties.poiAlt - }; - } - public getContribution(feature: GeoJSON.Feature): Contribution { return { lastModifiedDate: new Date(feature.properties.poiLastModified), @@ -680,10 +836,6 @@ export class PoiService { } as NorthEast; } - public hasExtraData(feature: GeoJSON.Feature, language: string): boolean { - return feature.properties["description:" + language] || Object.keys(feature.properties).find(k => k.startsWith("image")) != null; - } - public async getClosestPoint(location: LatLngAlt, source?: string, language?: string): Promise { let feature = null; try { @@ -718,13 +870,13 @@ export class PoiService { coordinates: SpatialService.toCoordinate(latlng) }, } as GeoJSON.Feature; - this.setLocation(feature, latlng); + GeoJSONUtils.setLocation(feature, latlng); return this.addPointToUploadQueue(feature); } public addComplexPoi(info: EditablePublicPointData, location: LatLngAlt): Promise { const feature = this.getFeatureFromEditableData(info); - this.setLocation(feature, location); + GeoJSONUtils.setLocation(feature, location); const id = uuidv4(); feature.id = id; feature.properties.poiId = id; @@ -762,16 +914,16 @@ export class PoiService { } if (newLocation) { - this.setLocation(featureContainingOnlyChanges, newLocation); + GeoJSONUtils.setLocation(featureContainingOnlyChanges, newLocation); hasChages = true; } const language = this.resources.getCurrentLanguageCodeSimplified(); if (info.title !== editableDataBeforeChanges.title) { - this.setTitle(featureContainingOnlyChanges, info.title, language); + GeoJSONUtils.setTitle(featureContainingOnlyChanges, info.title, language); hasChages = true; } if (info.description !== editableDataBeforeChanges.description) { - this.setDescription(featureContainingOnlyChanges, info.description, language); + GeoJSONUtils.setDescription(featureContainingOnlyChanges, info.description, language); hasChages = true; } if (info.icon !== editableDataBeforeChanges.icon || info.iconColor !== editableDataBeforeChanges.iconColor) { @@ -813,8 +965,8 @@ export class PoiService { return { id: this.getFeatureId(feature), category: feature.properties.poiCategory, - description: this.getDescription(feature, language), - title: this.getTitle(feature, language), + description: GeoJSONUtils.getDescription(feature, language), + title: GeoJSONUtils.getTitle(feature, language), icon: feature.properties.poiIcon, iconColor: feature.properties.poiIconColor, imagesUrls: Object.keys(feature.properties).filter(k => k.startsWith("image")).map(k => feature.properties[k]), @@ -851,8 +1003,8 @@ export class PoiService { index++; } const language = this.resources.getCurrentLanguageCodeSimplified(); - this.setDescription(feature, info.description, language); - this.setTitle(feature, info.title, language); + GeoJSONUtils.setDescription(feature, info.description, language); + GeoJSONUtils.setTitle(feature, info.title, language); return feature; } diff --git a/IsraelHiking.Web/src/application/services/search-results.provider.ts b/IsraelHiking.Web/src/application/services/search-results.provider.ts index 9725abcda..34c6d38de 100644 --- a/IsraelHiking.Web/src/application/services/search-results.provider.ts +++ b/IsraelHiking.Web/src/application/services/search-results.provider.ts @@ -33,15 +33,12 @@ export class SearchResultsProvider { description: "", }]; } - try { - const language = isHebrew ? "he" : "en"; - const params = new HttpParams().set("language", language); - const response = await firstValueFrom(this.httpClient.get(Urls.search + encodeURIComponent(searchWithoutBadCharacters), { - params - }).pipe(timeout(3000))); - return response as SearchResultsPointOfInterest[]; - } catch { - return await this.poiService.getSerchResults(searchWithoutBadCharacters); - } + const language = isHebrew ? "he" : "en"; + const params = new HttpParams().set("language", language); + const response = await firstValueFrom(this.httpClient.get(Urls.search + encodeURIComponent(searchWithoutBadCharacters), { + params + }).pipe(timeout(3000))); + return response as SearchResultsPointOfInterest[]; + // HM TODO: think if there's a way to have offline search results } } diff --git a/IsraelHiking.Web/src/application/services/share-urls.service.ts b/IsraelHiking.Web/src/application/services/share-urls.service.ts index 5885fc921..0d596e4ee 100644 --- a/IsraelHiking.Web/src/application/services/share-urls.service.ts +++ b/IsraelHiking.Web/src/application/services/share-urls.service.ts @@ -104,11 +104,11 @@ export class ShareUrlsService { const sharesLastSuccessfullSync = this.store.selectSnapshot((s: ApplicationState) => s.offlineState).shareUrlsLastModifiedDate; const operationStartTimeStamp = new Date(); let sharesToGetFromServer = [] as ShareUrl[]; - this.loggingService.info("[Shares] Starting shares sync, last modified:" + + this.loggingService.info("[Shares] Starting shares sync, last modified: " + (sharesLastSuccessfullSync || new Date(0)).toUTCString()); const shareUrls$ = this.httpClient.get(Urls.urls).pipe(timeout(20000)); const shareUrls = await firstValueFrom(shareUrls$) as ShareUrl[]; - this.loggingService.info("[Shares] Got the list of shares, statring to compare against exiting list"); + this.loggingService.info("[Shares] Got the list of shares, starting to compare against exiting list"); const exitingShareUrls = this.store.selectSnapshot((s: ApplicationState) => s.shareUrlsState).shareUrls; for (const shareUrl of shareUrls) { shareUrl.lastModifiedDate = new Date(shareUrl.lastModifiedDate); diff --git a/IsraelHiking.Web/src/application/services/spatial.service.spec.ts b/IsraelHiking.Web/src/application/services/spatial.service.spec.ts index 6a967dc54..edb9210f7 100644 --- a/IsraelHiking.Web/src/application/services/spatial.service.spec.ts +++ b/IsraelHiking.Web/src/application/services/spatial.service.spec.ts @@ -313,4 +313,73 @@ describe("Spatial service", () => { expect(pixel.pixelX).toBe(35); expect(pixel.pixelY).toBe(220); }); + + it("Should merge lines", () => { + const lines = [ + lineString([[0,0], [1,1]]), + lineString([[1,1], [2,2]]) + ]; + const merged = SpatialService.mergeLines(lines); + expect(merged.coordinates.length).toBe(3); + }); + + it("Should merge opposite lines", () => { + const lines = [ + lineString([[0,0], [1,1]]), + lineString([[2,2], [1,1]]) + ]; + const merged = SpatialService.mergeLines(lines); + expect(merged.coordinates.length).toBe(3); + }); + + it("Should merge reverse opposite lines", () => { + const lines = [ + lineString([[1,1], [0,0]]), + lineString([[1,1], [2,2]]) + ]; + const merged = SpatialService.mergeLines(lines); + expect(merged.coordinates.length).toBe(3); + expect(merged.coordinates[0]).toEqual([2,2]); + expect(merged.coordinates[2]).toEqual([0,0]); + }); + + it("Should merge unordered lines", () => { + const lines = [ + lineString([[1,1], [2,2]]), + lineString([[0,0], [1,1]]) + ]; + const merged = SpatialService.mergeLines(lines); + expect(merged.coordinates.length).toBe(3); + }); + + it("Should merge complicated unordered lines", () => { + const lines = [ + lineString([[1,1], [2,2]]), + lineString([[0,0], [1,1]]), + lineString([[3,3], [2,2]]) + ]; + const merged = SpatialService.mergeLines(lines); + expect(merged.coordinates.length).toBe(4); + }); + + it("Should merge complicated unordered lines with gap", () => { + const lines = [ + lineString([[0,0], [1,1]]), + lineString([[2,2], [3,3]]), + lineString([[1,1], [2,2]]) + ]; + const merged = SpatialService.mergeLines(lines); + expect(merged.coordinates.length).toBe(4); + }); + + it("Should try to merge and make the fisrt coordinate the same", () => { + const lines = [ + lineString([[0,0], [1,1]]), + lineString([[2,2], [1,1]]), + lineString([[2,2], [3,3]]) + ]; + const merged = SpatialService.mergeLines(lines); + expect(merged.coordinates.length).toBe(4); + expect(merged.coordinates[0]).toEqual([0,0]); + }); }); diff --git a/IsraelHiking.Web/src/application/services/spatial.service.ts b/IsraelHiking.Web/src/application/services/spatial.service.ts index c89967cdf..e4971d17a 100644 --- a/IsraelHiking.Web/src/application/services/spatial.service.ts +++ b/IsraelHiking.Web/src/application/services/spatial.service.ts @@ -373,4 +373,88 @@ export class SpatialService { SpatialService.insideBbox(position, [31.350, 30.0817, 31.355, 30.0860]) || SpatialService.insideBbox(position, [35.98, 31.70, 36.02, 31.73]); } + + private static canBeMreged(line1: GeoJSON.Position[], line2: GeoJSON.Position[]): "start-start" | "start-end" | "end-start" | "end-end" | null { + const start1 = line1[0]; + const end1 = line1[line1.length - 1]; + const start2 = line2[0]; + const end2 = line2[line2.length - 1]; + if (SpatialService.getDistanceForCoordinates(start1 as [number, number], start2 as [number, number]) < 1e-5) { + return "start-start"; + } + if (SpatialService.getDistanceForCoordinates(start1 as [number, number], end2 as [number, number]) < 1e-5) { + return "start-end"; + } + if (SpatialService.getDistanceForCoordinates(end1 as [number, number], start2 as [number, number]) < 1e-5) { + return "end-start"; + } + if (SpatialService.getDistanceForCoordinates(end1 as [number, number], end2 as [number, number]) < 1e-5) { + return "end-end"; + } + return null; + } + + public static mergeLines(lines: GeoJSON.Feature[]): GeoJSON.MultiLineString | GeoJSON.LineString { + const coordinatesGroups: GeoJSON.Position[][] = []; + const linesToMerge = lines.filter(l => l.geometry.coordinates.length > 0); + while (linesToMerge.length > 0) { + let lineIndex = 0; + let coordinatesGroupIndex = 0; + let foundType = null; + for (let i = 0; i < linesToMerge.length; i++) { + for (let j = 0; j < coordinatesGroups.length; j++) { + foundType = SpatialService.canBeMreged(coordinatesGroups[j], linesToMerge[i].geometry.coordinates); + if (foundType) { + lineIndex = i; + coordinatesGroupIndex = j; + break; + } + } + if (foundType) { + break; + } + } + if (!foundType) { + coordinatesGroups.push(linesToMerge[0].geometry.coordinates); + linesToMerge.shift(); + continue; + } + + const line = linesToMerge[lineIndex]; + linesToMerge.splice(lineIndex, 1); + const coordinateGroup = coordinatesGroups[coordinatesGroupIndex]; + switch (foundType) { + case "start-start": + line.geometry.coordinates.reverse(); + line.geometry.coordinates.pop(); + coordinatesGroups[coordinatesGroupIndex] = line.geometry.coordinates.concat(coordinateGroup); + break; + case "start-end": + line.geometry.coordinates.pop(); + coordinatesGroups[coordinatesGroupIndex] = line.geometry.coordinates.concat(coordinateGroup); + break; + case "end-start": + line.geometry.coordinates.shift(); + coordinatesGroups[coordinatesGroupIndex] = coordinateGroup.concat(line.geometry.coordinates); + break; + case "end-end": + line.geometry.coordinates.reverse(); + line.geometry.coordinates.shift(); + coordinatesGroups[coordinatesGroupIndex] = coordinateGroup.concat(line.geometry.coordinates); + break; + } + } + + if (coordinatesGroups.length === 1) { + return { + type: "LineString", + coordinates: coordinatesGroups[0] + } + } + + return { + type: "MultiLineString", + coordinates: coordinatesGroups + }; + } } diff --git a/IsraelHiking.Web/src/application/services/wikidata.service.ts b/IsraelHiking.Web/src/application/services/wikidata.service.ts new file mode 100644 index 000000000..e83312039 --- /dev/null +++ b/IsraelHiking.Web/src/application/services/wikidata.service.ts @@ -0,0 +1,88 @@ +import { HttpClient } from "@angular/common/http"; +import { Injectable } from "@angular/core"; + +import { ResourcesService } from "./resources.service"; +import { firstValueFrom, timeout } from "rxjs"; +import { GeoJSONUtils } from "./geojson-utils"; + +type WikiDataPage = { + sitelinks: { [key: string]: { site: string, title: string } }; + statements: { [key: string]: { value: { content: any } }[] }; +} + +@Injectable() +export class WikidataService { + + constructor(private readonly httpClient: HttpClient, + private readonly resources: ResourcesService) { + } + + public async enritchFeatureFromWikimedia(feature: GeoJSON.Feature, language: string): Promise { + const languageShort = language || this.resources.getCurrentLanguageCodeSimplified(); + const wikidata = await this.getWikidataFromId(feature.properties.wikidata); + await this.setDescriptionAndImages(wikidata, feature, languageShort); + } + + public async createFeatureFromPageId(wikidataId: string, language: string): Promise { + const wikidata = await this.getWikidataFromId(wikidataId); + + const feature: GeoJSON.Feature = { + type: "Feature", + properties: { + wikidata: wikidataId, + poiSource: "Wikidata", + poiId: "Wikidata_" + wikidataId, + poiIcon: "icon-wikipedia-w", + poiCategory: "Wikipedia", + poiIconColor: "black", + poiSourceImageUrl: "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6a/Wikidata-logo.svg/128px-Wikidata-logo.svg.png", + poiLanguage: language + }, + geometry: { + type: "Point", + coordinates: [] + } + }; + await this.enritchFeatureFromWikimedia(feature, language); + const lngLat = this.setLocation(wikidata, feature); + feature.geometry.coordinates = [lngLat.lng, lngLat.lat]; + feature.properties.name = this.getTitle(wikidata, language); + return feature; + } + + private async getWikidataFromId(wikidataId: string): Promise { + const url = `https://www.wikidata.org/w/rest.php/wikibase/v0/entities/items/${wikidataId}`; + return await firstValueFrom(this.httpClient.get(url).pipe(timeout(3000))) as any; + } + + private async setDescriptionAndImages(wikidata: WikiDataPage, feature: GeoJSON.Feature, language: string): Promise { + if (wikidata.statements.P18 && wikidata.statements.P18.length > 0) { + GeoJSONUtils.setProperty(feature, "image", `File:${wikidata.statements.P18[0].value.content}`); + } + const title = this.getTitle(wikidata, language); + if (title) { + const indexString = GeoJSONUtils.setProperty(feature, "website", `https://${language}.wikipedia.org/wiki/${title}`); + feature.properties["poiSourceImageUrl" + indexString] = "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/128px-Wikipedia-logo-v2.svg.png"; + } + const wikipediaPage = await firstValueFrom(this.httpClient.get(`http://${language}.wikipedia.org/w/api.php?format=json&action=query&prop=extracts&exintro=&explaintext=&titles=${title}&origin=*`)) as any; + const pagesIds = Object.keys(wikipediaPage.query.pages); + if (pagesIds.length > 0) { + feature.properties.poiExternalDescription = wikipediaPage.query.pages[pagesIds[0]].extract; + } + } + + private setLocation(wikidata: WikiDataPage, feature: GeoJSON.Feature) { + const latLng = { lat: 0, lng: 0 }; + if (wikidata.statements.P625 && wikidata.statements.P625.length > 0) { + const coordinates = wikidata.statements.P625[0].value.content; + latLng.lat = coordinates.latitude; + latLng.lng = coordinates.longitude; + } + GeoJSONUtils.setLocation(feature, latLng); + return latLng; + } + + private getTitle(wikidata: WikiDataPage, language: string): string { + return wikidata.sitelinks[`${language}wiki`]?.title; + } +} \ No newline at end of file