import { IndexedFeatureSource } from "../FeatureSource/FeatureSource" import { GeoOperations } from "../GeoOperations" 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" import { Imgur } from "../ImageProviders/Imgur" import { Panoramax, PanoramaxXYZ } from "panoramax-js/dist" 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< number, Promise >() 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 { pictureUrl: string date?: number coordinates: { lat: number; lng: number } provider: "Mapillary" | string author? license? detailsUrl?: string direction?: number osmTags?: object /*To copy straight into OSM!*/ thumbUrl: string details: { isSpherical: boolean } } 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 }) } } 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 features which are currently loaded on the local machine, probably features of the same layer */ class ImagesInLoadedDataFetcher implements ImageFetcher { private indexedFeatures: IndexedFeatureSource private readonly _searchRadius: number public readonly name = "inLoadedData" constructor(indexedFeatures: IndexedFeatureSource, searchRadius: number = 500) { this.indexedFeatures = indexedFeatures this._searchRadius = searchRadius } async fetchImages(lat: number, lon: number): Promise { const foundImages: P4CPicture[] = [] this.indexedFeatures.features.data.forEach((feature) => { const props = feature.properties const images = [] if (props.image) { images.push(props.image) } for (let i = 0; i < 10; i++) { if (props["image:" + i]) { images.push(props["image:" + i]) } } if (images.length == 0) { return } const centerpoint = GeoOperations.centerpointCoordinates(feature) const d = GeoOperations.distanceBetween(centerpoint, [lon, lat]) if (this._searchRadius !== undefined && d > this._searchRadius) { return } for (const image of images) { foundImages.push({ pictureUrl: image, thumbUrl: image, coordinates: { lng: centerpoint[0], lat: centerpoint[1] }, provider: "OpenStreetMap", details: { isSpherical: false, }, osmTags: { image }, }) } }) return foundImages } } class ImagesFromPanoramaxFetcher implements ImageFetcher { private readonly _radius: number private readonly _panoramax: Panoramax name: string = "panoramax" public static readonly apiUrls: ReadonlyArray = [ "https://panoramax.openstreetmap.fr", "https://api.panoramax.xyz", "https://panoramax.mapcomplete.org", ] constructor(url?: string, radius: number = 100) { this._radius = radius if (url) { this._panoramax = new Panoramax(url) } else { this._panoramax = new PanoramaxXYZ() } } public async fetchImages(lat: number, lon: number): Promise { const bboxObj = new BBox([ GeoOperations.destination([lon, lat], this._radius * Math.sqrt(2), -45), GeoOperations.destination([lon, lat], this._radius * Math.sqrt(2), 135), ]) const bbox: [number, number, number, number] = bboxObj.toLngLatFlat() const images = await this._panoramax.search({ bbox, limit: 1000 }) return images.map((i) => { const [lng, lat] = i.geometry.coordinates return { pictureUrl: i.assets.sd.href, coordinates: { lng, lat }, provider: "panoramax", direction: i.properties["view:azimuth"], osmTags: { panoramax: i.id, }, thumbUrl: i.assets.thumb.href, date: new Date(i.properties.datetime).getTime(), license: i.properties["geovisio:license"], author: i.providers.at(-1).name, detailsUrl: i.id, details: { isSpherical: i.properties["exif"]["Xmp.GPano.ProjectionType"] === "equirectangular", }, } }) } } 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 static apiUrls: string[] = ["*.fbcdn.net", "https://graph.mapillary.com"] 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=geometry,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 geometry: Point 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 ?? img.geometry.coordinates if (img.thumb_original_url === undefined) { continue } 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: this._panoramas === "only", }, }) } return pics } } type P4CService = (typeof P4CImageFetcher.services)[number] export class CombinedFetcher { private readonly sources: ReadonlyArray public static apiUrls = [ ...P4CImageFetcher.apiUrls, Imgur.apiUrl, ...Imgur.supportingUrls, ...MapillaryFetcher.apiUrls, ...ImagesFromPanoramaxFetcher.apiUrls, ] constructor(radius: number, maxage: Date, indexedFeatures: IndexedFeatureSource) { this.sources = [ new ImagesInLoadedDataFetcher(indexedFeatures, radius), new ImagesFromPanoramaxFetcher(), new ImagesFromPanoramaxFetcher(Constants.panoramax.url), // For mapillary, we need to query both with and without panoramas. See https://www.mapillary.com/developer/api-documentation/ new MapillaryFetcher({ max_images: 25, start_captured_at: maxage, panoramas: "only", }), new MapillaryFetcher({ max_images: 25, start_captured_at: maxage, panoramas: "no", }), new P4CImageFetcher("mapillary"), new P4CImageFetcher("wikicommons"), ].map((f) => new CachedFetcher(f)) } private async fetchImage( source: CachedFetcher, lat: number, lon: number, state: UIEventSource>, sink: UIEventSource ): Promise { try { const pics = await source.fetchImages(lat, lon) console.log(source.name, "==>>", pics) state.data[source.name] = "done" state.ping() if (sink.data === undefined) { sink.setData(pics) } else { const newList = [] const seenIds = new Set() for (const p4CPicture of [...sink.data, ...pics]) { const id = p4CPicture.pictureUrl if (seenIds.has(id)) { continue } newList.push(p4CPicture) seenIds.add(id) } NearbyImageUtils.sortByDistance(newList, lon, lat) sink.setData(newList) } } catch (e) { console.error("Could not load images from", source.name, "due to", e) state.data[source.name] = "error" state.ping() } } public getImagesAround( lon: number, lat: number ): { images: Store state: Store> } { const sink = new UIEventSource([]) const state = new UIEventSource>({}) for (const source of this.sources) { state.data[source.name] = "loading" state.ping() this.fetchImage(source, lat, lon, state, sink) } return { images: sink, state } } }