From c20013c5f54c8c74df6a518097210ffee828ec9d Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Fri, 19 Jul 2024 11:37:20 +0200 Subject: [PATCH] Merge master --- langs/en.json | 1 + src/Logic/Web/NearbyImagesSearch.ts | 386 ++++++++++++---------- src/Models/ThemeViewState.ts | 6 + src/UI/BigComponents/MapillaryLink.svelte | 2 +- src/UI/Image/NearbyImages.svelte | 70 ++-- src/UI/Map/MaplibreMap.svelte | 10 + src/UI/SpecialVisualization.ts | 2 + src/UI/SpecialVisualizations.ts | 4 +- 8 files changed, 274 insertions(+), 207 deletions(-) diff --git a/langs/en.json b/langs/en.json index 18eaa8f49d..34fd34e01f 100644 --- a/langs/en.json +++ b/langs/en.json @@ -562,6 +562,7 @@ "isDeleted": "Deleted", "nearby": { "close": "Collapse panel with nearby images", + "failed": "Fetching images from {service} failed", "link": "This picture shows the object", "noNearbyImages": "No nearby images were found", "seeNearby": "Browse and link nearby pictures", diff --git a/src/Logic/Web/NearbyImagesSearch.ts b/src/Logic/Web/NearbyImagesSearch.ts index d712c3c605..e7dc215f17 100644 --- a/src/Logic/Web/NearbyImagesSearch.ts +++ b/src/Logic/Web/NearbyImagesSearch.ts @@ -1,21 +1,48 @@ import { IndexedFeatureSource } from "../FeatureSource/FeatureSource" import { GeoOperations } from "../GeoOperations" -import { ImmutableStore, Store, Stores, UIEventSource } from "../UIEventSource" +import { Store, UIEventSource } from "../UIEventSource" import P4C from "pic4carto" +import { Tiles } from "../../Models/TileRange" +import { BBox } from "../BBox" +import Constants from "../../Models/Constants" import { Utils } from "../../Utils" +import { Point } from "geojson" -export interface NearbyImageOptions { - lon: number - lat: number - // Radius of the upstream search - searchRadius?: 500 | number - maxDaysOld?: 1095 | number - blacklist: Store<{ url: string }[]> - shownImagesCount?: UIEventSource - towardscenter?: UIEventSource - allowSpherical?: UIEventSource - // Radius of what is shown. Useless to select a value > searchRadius; defaults to searchRadius - shownRadius?: UIEventSource +interface ImageFetcher { + /** + * Returns images, null if an error happened + * @param lat + * @param lon + */ + fetchImages(lat: number, lon: number): Promise + + readonly name: string + +} + + +class CachedFetcher implements ImageFetcher { + private readonly _fetcher: ImageFetcher + private readonly _zoomlevel: number + private readonly cache: Map> = new Map>() + public readonly name: string + + constructor(fetcher: ImageFetcher, zoomlevel: number = 19) { + this._fetcher = fetcher + this._zoomlevel = zoomlevel + this.name = fetcher.name + } + + fetchImages(lat: number, lon: number): Promise { + const tile = Tiles.embedded_tile(lat, lon, this._zoomlevel) + const tileIndex = Tiles.tile_index(tile.z, tile.x, tile.y) + if (this.cache.has(tileIndex)) { + return this.cache.get(tileIndex) + } + const call = this._fetcher.fetchImages(lat, lon) + this.cache.set(tileIndex, call) + return call + } } export interface P4CPicture { @@ -34,181 +61,72 @@ export interface P4CPicture { } } -/** - * Uses Pic4Carto to fetch nearby images from various providers - */ -export default class NearbyImagesSearch { - public static readonly services = ["mapillary", "flickr", "kartaview", "wikicommons"] as const - public static readonly apiUrls = ["https://api.flickr.com"] - private readonly individualStores: Store< - { images: P4CPicture[]; beforeFilter: number } | undefined - >[] - private readonly _store: UIEventSource = new UIEventSource([]) - public readonly store: Store = this._store - public readonly allDone: Store - private readonly _options: NearbyImageOptions - - constructor(options: NearbyImageOptions, features: IndexedFeatureSource) { - this.individualStores = NearbyImagesSearch.services - .filter((s) => s !== "kartaview" /*DEAD*/) - .map((s) => NearbyImagesSearch.buildPictureFetcher(options, s)) - - const allDone = new UIEventSource(false) - this.allDone = allDone - const self = this - function updateAllDone() { - const stillRunning = self.individualStores.some((store) => store.data === undefined) - allDone.setData(!stillRunning) - } - self.individualStores.forEach((s) => s.addCallback((_) => updateAllDone())) - - this._options = options - if (features !== undefined) { - const osmImages = new ImagesInLoadedDataFetcher(features).fetchAround({ - lat: options.lat, - lon: options.lon, - searchRadius: options.searchRadius ?? 100, - }) - this.individualStores.push( - new ImmutableStore({ images: osmImages, beforeFilter: osmImages.length }) - ) - } - for (const source of this.individualStores) { - source.addCallback(() => this.update()) - } - this.update() - } - - private static async fetchImages( - options: NearbyImageOptions, - fetcher: P4CService - ): Promise { - const picManager = new P4C.PicturesManager({ usefetchers: [fetcher] }) - const maxAgeSeconds = (options.maxDaysOld ?? 3 * 365) * 24 * 60 * 60 * 1000 - const searchRadius = options.searchRadius ?? 100 - - try { - const pics: P4CPicture[] = await picManager.startPicsRetrievalAround( - new P4C.LatLng(options.lat, options.lon), - searchRadius, - { - mindate: new Date().getTime() - maxAgeSeconds, - towardscenter: false, - } - ) - return pics - } catch (e) { - console.warn("Could not fetch images from service", fetcher, e) - return [] - } - } - - private static buildPictureFetcher( - options: NearbyImageOptions, - fetcher: P4CService - ): Store<{ images: P4CPicture[]; beforeFilter: number } | null | undefined> { - const p4cStore = Stores.FromPromiseWithErr( - NearbyImagesSearch.fetchImages(options, fetcher) - ) - const searchRadius = options.searchRadius ?? 100 - return p4cStore.mapD( - (imagesState) => { - if (imagesState["error"]) { - return null - } - let images = imagesState["success"] - if (images === undefined) { - return undefined - } - const beforeFilterCount = images.length - if (!options?.allowSpherical?.data) { - images = images?.filter((i) => i.details.isSpherical !== true) - } - - const shownRadius = options?.shownRadius?.data ?? searchRadius - if (shownRadius !== searchRadius) { - images = images.filter((i) => { - const d = GeoOperations.distanceBetween( - [i.coordinates.lng, i.coordinates.lat], - [options.lon, options.lat] - ) - return d <= shownRadius - }) - } - if (options.towardscenter?.data) { - images = images.filter((i) => { - if (i.direction === undefined || isNaN(i.direction)) { - return false - } - const bearing = GeoOperations.bearing( - [i.coordinates.lng, i.coordinates.lat], - [options.lon, options.lat] - ) - const diff = Math.abs((i.direction - bearing) % 360) - return diff < 40 - }) - } - - images?.sort((a, b) => { - const distanceA = GeoOperations.distanceBetween( - [a.coordinates.lng, a.coordinates.lat], - [options.lon, options.lat] - ) - const distanceB = GeoOperations.distanceBetween( - [b.coordinates.lng, b.coordinates.lat], - [options.lon, options.lat] - ) - return distanceA - distanceB - }) - - return { images, beforeFilter: beforeFilterCount } - }, - [options.blacklist, options.allowSpherical, options.towardscenter, options.shownRadius] - ) - } - - private update() { - const seen: Set = new Set(this._options.blacklist.data.map((d) => d.url)) - let beforeFilter = 0 - let result: P4CPicture[] = [] - for (const source of this.individualStores) { - const imgs = source.data - if (imgs === undefined) { - continue - } - beforeFilter = beforeFilter + imgs.beforeFilter - for (const img of imgs.images) { - if (seen.has(img.pictureUrl)) { - continue - } - seen.add(img.pictureUrl) - result.push(img) - } - } - const c = [this._options.lon, this._options.lat] +class NearbyImageUtils { + /** + * In place sorting of the given array, by distance. Closest element will be first + */ + public static sortByDistance(result: P4CPicture[], lon: number, lat: number) { + const c = [lon, lat] result.sort((a, b) => { const da = GeoOperations.distanceBetween([a.coordinates.lng, a.coordinates.lat], c) const db = GeoOperations.distanceBetween([b.coordinates.lng, b.coordinates.lat], c) return da - db + }) - if (Utils.sameList(result, this._store.data)) { - // return - } - this._store.setData(result) } } + +class P4CImageFetcher implements ImageFetcher { + + public static readonly services = ["mapillary", "flickr", "kartaview", "wikicommons"] as const + public static readonly apiUrls = ["https://api.flickr.com"] + private _options: { maxDaysOld: number, searchRadius: number } + public readonly name: P4CService + + constructor(service: P4CService, options?: { maxDaysOld: number, searchRadius: number }) { + this.name = service + this._options = options + } + + async fetchImages(lat: number, lon: number): Promise { + const picManager = new P4C.PicturesManager({ usefetchers: [this.name] }) + const maxAgeSeconds = (this._options?.maxDaysOld ?? 3 * 365) * 24 * 60 * 60 * 1000 + const searchRadius = this._options?.searchRadius ?? 100 + + try { + + return await picManager.startPicsRetrievalAround( + new P4C.LatLng(lat, lon), + searchRadius, + { + mindate: new Date().getTime() - maxAgeSeconds, + towardscenter: false, + + }, + ) + } catch (e) { + console.log("P4C image fetcher failed with", e) + throw e + } + } + +} + /** * Extracts pictures from currently loaded features */ -class ImagesInLoadedDataFetcher { +class ImagesInLoadedDataFetcher implements ImageFetcher { private indexedFeatures: IndexedFeatureSource + private readonly _searchRadius: number + public readonly name = "inLoadedData" - constructor(indexedFeatures: IndexedFeatureSource) { + constructor(indexedFeatures: IndexedFeatureSource, searchRadius: number = 500) { this.indexedFeatures = indexedFeatures + this._searchRadius = searchRadius } - public fetchAround(loc: { lon: number; lat: number; searchRadius?: number }): P4CPicture[] { + async fetchImages(lat: number, lon: number): Promise { const foundImages: P4CPicture[] = [] this.indexedFeatures.features.data.forEach((feature) => { const props = feature.properties @@ -225,8 +143,8 @@ class ImagesInLoadedDataFetcher { return } const centerpoint = GeoOperations.centerpointCoordinates(feature) - const d = GeoOperations.distanceBetween(centerpoint, [loc.lon, loc.lat]) - if (loc.searchRadius !== undefined && d > loc.searchRadius) { + const d = GeoOperations.distanceBetween(centerpoint, [lon, lat]) + if (this._searchRadius !== undefined && d > this._searchRadius) { return } for (const image of images) { @@ -247,4 +165,122 @@ class ImagesInLoadedDataFetcher { } } -type P4CService = (typeof NearbyImagesSearch.services)[number] + +class MapillaryFetcher implements ImageFetcher { + + public readonly name = "mapillary_new" + private readonly _panoramas: "only" | "no" | undefined + private readonly _max_images: 100 | number + + private readonly start_captured_at?: Date + private readonly end_captured_at?: Date + + constructor(options?: { + panoramas: undefined | "only" | "no", + max_images?: 100 | number, + start_captured_at?: Date, + end_captured_at?: Date + }) { + this._panoramas = options?.panoramas + this._max_images = options?.max_images ?? 100 + this.start_captured_at = options?.start_captured_at + this.end_captured_at = options?.end_captured_at + } + + async fetchImages(lat: number, lon: number): Promise { + + const boundingBox = new BBox([[lon, lat]]).padAbsolute(0.003) + let url = "https://graph.mapillary.com/images?fields=computed_geometry,creator,id,thumb_256_url,thumb_original_url,compass_angle&bbox=" + + [boundingBox.getWest(), boundingBox.getSouth(), boundingBox.getEast(), boundingBox.getNorth()].join(",") + + "&access_token=" + encodeURIComponent(Constants.mapillary_client_token_v4) + + "&limit=" + this._max_images + { + if (this._panoramas === "no") { + url += "&is_pano=false" + } else if (this._panoramas === "only") { + url += "&is_pano=true" + } + if (this.start_captured_at) { + url += "&start_captured_at="+ this.start_captured_at?.toISOString() + } + if (this.end_captured_at) { + url += "&end_captured_at="+ this.end_captured_at?.toISOString() + } + } + + const response = await Utils.downloadJson<{ + data: { id: string, creator: string, computed_geometry: Point, is_pano: boolean,thumb_256_url: string, thumb_original_url: string, compass_angle: number }[] + }>(url) + const pics: P4CPicture[] = [] + for (const img of response.data) { + + const c = img.computed_geometry.coordinates + pics.push({ + pictureUrl: img.thumb_original_url, + provider: "Mapillary", + coordinates: { lng: c[0], lat: c[1] }, + thumbUrl: img.thumb_256_url, + osmTags: { + "mapillary":img.id + }, + details: { + isSpherical: img.is_pano, + }, + }) + } + return pics + } +} + +type P4CService = (typeof P4CImageFetcher.services)[number] + +export class CombinedFetcher { + private readonly sources: ReadonlyArray + public static apiUrls = P4CImageFetcher.apiUrls + + + constructor(radius: number, maxage: Date, indexedFeatures: IndexedFeatureSource) { + this.sources = [ + new ImagesInLoadedDataFetcher(indexedFeatures, radius), + new MapillaryFetcher({ + panoramas: "no", + max_images: 25, + start_captured_at : maxage + }), + new P4CImageFetcher("mapillary"), + new P4CImageFetcher("wikicommons"), + ].map(f => new CachedFetcher(f)) + } + + public getImagesAround(lon: number, lat: number): { + images: Store, + state: Store> + } { + const src = new UIEventSource([]) + const state = new UIEventSource>({}) + for (const source of this.sources) { + state.data[source.name] = "loading" + state.ping() + source.fetchImages(lat, lon) + .then(pics => { + console.log(source.name,"==>>",pics) + state.data[source.name] = "done" + state.ping() + if (src.data === undefined) { + src.setData(pics) + } else { + const newList = [...src.data, ...pics] + NearbyImageUtils.sortByDistance(newList, lon, lat) + src.setData(newList) + } + }, err => { + console.error("Could not load images from", source.name, "due to", err) + state.data[source.name] = "error" + state.ping() + }) + } + return { images: src, state } + } + +} + diff --git a/src/Models/ThemeViewState.ts b/src/Models/ThemeViewState.ts index 480bdbda72..2f4c61aa58 100644 --- a/src/Models/ThemeViewState.ts +++ b/src/Models/ThemeViewState.ts @@ -73,6 +73,7 @@ import { LayerConfigJson } from "./ThemeConfig/Json/LayerConfigJson" import Locale from "../UI/i18n/Locale" import Hash from "../Logic/Web/Hash" import { GeoOperations } from "../Logic/GeoOperations" +import { CombinedFetcher } from "../Logic/Web/NearbyImagesSearch" /** * @@ -152,6 +153,8 @@ export default class ThemeViewState implements SpecialVisualizationState { public readonly visualFeedback: UIEventSource = new UIEventSource(false) public readonly toCacheSavers: ReadonlyMap + public readonly nearbyImageSearcher + constructor(layout: LayoutConfig, mvtAvailableLayers: Set) { Utils.initDomPurify() this.layout = layout @@ -369,6 +372,9 @@ export default class ThemeViewState implements SpecialVisualizationState { this.changes, ) this.favourites = new FavouritesFeatureSource(this) + const longAgo = new Date() + longAgo.setTime(new Date().getTime() - 5 * 365 * 24 * 60 * 60 * 1000 ) + this.nearbyImageSearcher = new CombinedFetcher(50, longAgo, this.indexedFeatures) this.featureSummary = this.setupSummaryLayer( new LayerConfig(summaryLayer, "summaryLayer", true), diff --git a/src/UI/BigComponents/MapillaryLink.svelte b/src/UI/BigComponents/MapillaryLink.svelte index c54c894d10..430338e748 100644 --- a/src/UI/BigComponents/MapillaryLink.svelte +++ b/src/UI/BigComponents/MapillaryLink.svelte @@ -21,7 +21,7 @@ - + {#if large}
diff --git a/src/UI/Image/NearbyImages.svelte b/src/UI/Image/NearbyImages.svelte index c6ecede29e..fff20b7b08 100644 --- a/src/UI/Image/NearbyImages.svelte +++ b/src/UI/Image/NearbyImages.svelte @@ -3,10 +3,9 @@ * Show nearby images which can be clicked */ import type { OsmTags } from "../../Models/OsmFeature" - import { Store, UIEventSource } from "../../Logic/UIEventSource" + import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource" import type { SpecialVisualizationState } from "../SpecialVisualization" import type { P4CPicture } from "../../Logic/Web/NearbyImagesSearch" - import NearbyImagesSearch from "../../Logic/Web/NearbyImagesSearch" import LinkableImage from "./LinkableImage.svelte" import type { Feature } from "geojson" import LayerConfig from "../../Models/ThemeConfig/LayerConfig" @@ -14,7 +13,7 @@ import AllImageProviders from "../../Logic/ImageProviders/AllImageProviders" import Tr from "../Base/Tr.svelte" import Translations from "../i18n/Translations" - import LoginToggle from "../Base/LoginToggle.svelte" + import MapillaryLink from "../BigComponents/MapillaryLink.svelte" export let tags: UIEventSource export let state: SpecialVisualizationState @@ -25,36 +24,49 @@ export let linkable: boolean = true export let layer: LayerConfig - let imagesProvider = new NearbyImagesSearch( - { - lon, - lat, - allowSpherical: new UIEventSource(false), - blacklist: AllImageProviders.LoadImagesFor(tags), - }, - state.indexedFeatures - ) + let imagesProvider = state.nearbyImageSearcher + + let loadedImages = AllImageProviders.LoadImagesFor(tags).mapD(loaded => new Set(loaded.map(img => img.url))) + let imageState = imagesProvider.getImagesAround(lon, lat) + let result: Store = imageState.images.mapD((pics: P4CPicture[]) => pics.filter((p: P4CPicture) => + !loadedImages.data.has(p.pictureUrl) // We don't show any image which is already linked + && !p.details.isSpherical, + ).slice(0, 25), [loadedImages]) + + let someLoading = imageState.state.mapD(stateRecord => Object.values(stateRecord).some(v => v === "loading")) + let errors = imageState.state.mapD(stateRecord => Object.keys(stateRecord).filter(k => stateRecord[k] === "error")) - let images: Store = imagesProvider.store.map((images) => images.slice(0, 20)) - let allDone = imagesProvider.allDone -
-

- -

- -
-{#if !$allDone} - -{:else if $images.length === 0} - -{:else} -
- {#each $images as image (image.pictureUrl)} +
+ + {#if $result.length === 0} + {#if $someLoading} +
+ +
+ {:else } + + {/if} + {:else} +
+ {#each $result as image (image.pictureUrl)} - {/each} + {/each} +
+ {/if} +
+
+ {#if $someLoading && $result.length > 0} + + {/if} + {#if $errors.length > 0} + + {/if} +
+
-{/if} +
diff --git a/src/UI/Map/MaplibreMap.svelte b/src/UI/Map/MaplibreMap.svelte index 36f54c43aa..3a6ae11d9d 100644 --- a/src/UI/Map/MaplibreMap.svelte +++ b/src/UI/Map/MaplibreMap.svelte @@ -97,6 +97,7 @@ onDestroy(async () => { +<<<<<<< HEAD await Utils.waitFor(100) requestAnimationFrame( () => { @@ -109,6 +110,15 @@ } } ) +======= + await Utils.waitFor(250) + try { + _map?.remove() + map = null + } catch (e) { + console.error("Could not destroy map") + } +>>>>>>> 6093ac3ea (Refactoring: rework image fetching code, improve nearby images UI. Fix #2026, #2027) }) diff --git a/src/UI/SpecialVisualization.ts b/src/UI/SpecialVisualization.ts index 4b7d850ebb..70c8c4b63b 100644 --- a/src/UI/SpecialVisualization.ts +++ b/src/UI/SpecialVisualization.ts @@ -28,6 +28,7 @@ import { SummaryTileSourceRewriter } from "../Logic/FeatureSource/TiledFeatureSo import LayoutSource from "../Logic/FeatureSource/Sources/LayoutSource" import { Map as MlMap } from "maplibre-gl" import ShowDataLayer from "./Map/ShowDataLayer" +import { CombinedFetcher } from "../Logic/Web/NearbyImagesSearch" /** * The state needed to render a special Visualisation. @@ -92,6 +93,7 @@ export interface SpecialVisualizationState { readonly imageUploadManager: ImageUploadManager readonly previewedImage: UIEventSource + readonly nearbyImageSearcher: CombinedFetcher readonly geolocation: GeoLocationHandler showCurrentLocationOn(map: Store): ShowDataLayer diff --git a/src/UI/SpecialVisualizations.ts b/src/UI/SpecialVisualizations.ts index bdbb46da3f..964ac2c711 100644 --- a/src/UI/SpecialVisualizations.ts +++ b/src/UI/SpecialVisualizations.ts @@ -59,7 +59,6 @@ import { Imgur } from "../Logic/ImageProviders/Imgur" import Constants from "../Models/Constants" import { MangroveReviews } from "mangrove-reviews-typescript" import Wikipedia from "../Logic/Web/Wikipedia" -import NearbyImagesSearch from "../Logic/Web/NearbyImagesSearch" import AllReviews from "./Reviews/AllReviews.svelte" import StarsBarIcon from "./Reviews/StarsBarIcon.svelte" import ReviewForm from "./Reviews/ReviewForm.svelte" @@ -98,6 +97,7 @@ import MarkdownUtils from "../Utils/MarkdownUtils" import ArrowDownTray from "@babeard/svelte-heroicons/mini/ArrowDownTray" import Trash from "@babeard/svelte-heroicons/mini/Trash" import NothingKnown from "./Popup/NothingKnown.svelte" +import { CombinedFetcher } from "../Logic/Web/NearbyImagesSearch" class NearbyImageVis implements SpecialVisualization { // Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests @@ -116,7 +116,7 @@ class NearbyImageVis implements SpecialVisualization { docs = "A component showing nearby images loaded from various online services such as Mapillary. In edit mode and when used on a feature, the user can select an image to add to the feature" funcName = "nearby_images" - needsUrls = NearbyImagesSearch.apiUrls + needsUrls = CombinedFetcher.apiUrls svelteBased = true constr(