import { ImageUploader } from "./ImageUploader" import { AuthorizedPanoramax, ImageData, Panoramax, PanoramaxXYZ } from "panoramax-js/dist" import ExifReader from "exifreader" import ImageProvider, { PanoramaView, ProvidedImage } from "./ImageProvider" import BaseUIElement from "../../UI/BaseUIElement" import { LicenseInfo } from "./LicenseInfo" import { GeoOperations } from "../GeoOperations" import Constants from "../../Models/Constants" import { Store, Stores, UIEventSource } from "../UIEventSource" import SvelteUIElement from "../../UI/Base/SvelteUIElement" import Panoramax_bw from "../../assets/svg/Panoramax_bw.svelte" import Link from "../../UI/Base/Link" import { Feature, Point } from "geojson" import { AddImageOptions } from "panoramax-js/dist/Panoramax" export default class PanoramaxImageProvider extends ImageProvider { public static readonly singleton: PanoramaxImageProvider = new PanoramaxImageProvider() private static readonly xyz = new PanoramaxXYZ() private static defaultPanoramax = new AuthorizedPanoramax( Constants.panoramax.url, Constants.panoramax.token, 3000 ) /** * * const url = "https://panoramax.mapcomplete.org/api/pictures/e931ce57-4591-4dd5-aa4c-595e89c37e84/hd.jpg" * const match = url.match(PanoramaxImageProvider.isDirectLink) * match[1] // => "e931ce57-4591-4dd5-aa4c-595e89c37e84" */ public static readonly isDirectLink = /https:\/\/panoramax.mapcomplete.org\/api\/pictures\/([0-9a-f-]+)\/(hd)|(sd)|(thumb).jpg/ public defaultKeyPrefixes: string[] = ["panoramax", "image"] public readonly name: string = "panoramax" private static knownMeta: Record< string, { data: Promise<{ data: ImageData; url: string }>; time: Date } > = {} public SourceIcon( img?: { id: string; url: string }, location?: { lon: number lat: number } ): BaseUIElement { const host = "https://" + new URL(img.url).host const p = new Panoramax(host) return new Link( new SvelteUIElement(Panoramax_bw), p.createViewLink({ imageId: img?.id, location, }), true ) } visitUrl( img: { id: string; url: string }, location?: { lon: number; lat: number } ): string | undefined { const host = "https://" + new URL(img.url).host const p = new Panoramax(host) return p.createViewLink({ imageId: img?.id, location, }) } public addKnownMeta(meta: ImageData, url?: string) { PanoramaxImageProvider.knownMeta[meta.id] = { data: Promise.resolve({ data: meta, url }), time: new Date(), } } /** * Tries to get the entry from the mapcomplete-panoramax instance. Might return undefined * @param id * @private */ private async getInfoFromMapComplete(id: string): Promise<{ data: ImageData; url: string }> { const url = `https://panoramax.mapcomplete.org/` const data = await PanoramaxImageProvider.defaultPanoramax.imageInfo(id) return { url, data } } private async getInfoFromXYZ(imageId: string): Promise<{ data: ImageData; url: string }> { const data = await PanoramaxImageProvider.xyz.imageInfo(imageId) return { data, url: "https://api.panoramax.xyz/" } } /** * Reads a geovisio-somewhat-looking-like-geojson object and converts it to a provided image * @private */ private featureToImage(info: { data: ImageData; url: string }) { const meta = info?.data if (!meta) { return undefined } const url = info.url function makeAbsolute(s: string) { if (!s.startsWith("https://") && !s.startsWith("http://")) { const parsed = new URL(url) return parsed.protocol + "//" + parsed.host + s } return s } const [lon, lat] = GeoOperations.centerpointCoordinates(meta) return { id: meta.id, url: makeAbsolute(meta.assets.sd.href), url_hd: makeAbsolute(meta.assets.hd.href), host: meta["links"].find((l) => l.rel === "root")?.href, lon, lat, key: "panoramax", provider: this, status: meta.properties["geovisio:status"], rotation: Number(meta.properties["view:azimuth"]), isSpherical: meta.properties.exif["Xmp.GPano.ProjectionType"] === "equirectangular", date: new Date(meta.properties.datetime), } } private async getInfoForUncached(id: string) { try { return await this.getInfoFromMapComplete(id) } catch (e) { console.debug(e) } try { return await this.getInfoFromXYZ(id) } catch (e) { console.debug(e) } return undefined } private async getInfoFor(id: string): Promise<{ data: ImageData; url: string }> { if (!id.match(/^[a-zA-Z0-9-]+$/)) { return undefined } const cached = PanoramaxImageProvider.knownMeta[id] if (cached) { if (new Date().getTime() - cached.time.getTime() < 5000) { return await cached.data } } const promise: Promise<{ data: ImageData; url: string }> = this.getInfoForUncached(id) PanoramaxImageProvider.knownMeta[id] = { time: new Date(), data: promise, } return await promise } public async ExtractUrls(key: string, value: string): Promise { const match = value.match(PanoramaxImageProvider.isDirectLink) let alt_id = undefined if (match) { alt_id = value value = match[1] // The ID } if (!Panoramax.isId(value)) { return undefined } const providedImage = await this.getInfo(value) providedImage.alt_id = alt_id providedImage.originalAttribute = { key, value } return [providedImage] } public async getInfo(hash: string): Promise { const r: { data: ImageData; url: string } = await this.getInfoFor(hash) return this.featureToImage(r) } getRelevantUrls(tags: Record, prefixes: string[]): Store { const source = UIEventSource.FromPromise(super.getRelevantUrlsFor(tags, prefixes)) function hasLoading(data: ProvidedImage[]) { if (data === undefined) { return true } return data?.some( (img) => img?.status !== undefined && img?.status !== "ready" && img?.status !== "broken" && img?.status !== "hidden" ) } Stores.Chronic(5000, () => hasLoading(source.data)).addCallback(() => { super.getRelevantUrlsFor(tags, prefixes).then((data) => { source.set(data) return !hasLoading(data) }) }) return Stores.ListStabilized(source) } public async DownloadAttribution(providedImage: { id: string }): Promise { const meta = await this.getInfoFor(providedImage.id) return { artist: meta.data.providers.at(-1).name, // We take the last provider, as that one probably contain the username of the uploader date: new Date(meta.data.properties["datetime"]), licenseShortName: meta.data.properties["geovisio:license"], } } public apiUrls(): string[] { return ["https://panoramax.mapcomplete.org", "https://panoramax.xyz"] } public static getPanoramaxInstance(host: string) { host = new URL(host).host if (new URL(this.defaultPanoramax.host).host === host) { return this.defaultPanoramax } if (new URL(this.xyz.host).host === host) { return this.xyz } return new Panoramax(host) } public async getPanoramaInfo(image: { id: string }): Promise> | undefined { const imageInfo = await PanoramaxImageProvider.xyz.imageInfo(image.id) const url = (imageInfo.assets.sd ?? imageInfo.assets.thumb ?? imageInfo.assets.hd).href const northOffset = imageInfo.properties["view:azimuth"] const pitchOffset = Number(imageInfo.properties.exif["Xmp.GPano.PosePitchDegrees"]) return >{ type: "Feature", geometry: imageInfo.geometry, properties: { url, northOffset, pitchOffset, provider: this, imageMeta: imageInfo, }, } } } export class PanoramaxUploader implements ImageUploader { public readonly panoramax: AuthorizedPanoramax maxFileSizeInMegabytes = 100 * 1000 * 1000 // 100MB private readonly _targetSequence?: Store constructor(url: string, token: string, targetSequence?: Store) { this._targetSequence = targetSequence this.panoramax = new AuthorizedPanoramax(url, token) } async uploadImage( blob: File, currentGps: [number, number], author: string, noblur: boolean = false, progress?: UIEventSource, sequenceId?: string, datetime?: string ): Promise<{ key: string value: string absoluteUrl: string }> { // https://panoramax.openstreetmap.fr/api/docs/swagger#/ let [lon, lat] = currentGps ?? [undefined, undefined] datetime ??= new Date().toISOString() try { const tags = await ExifReader.load(blob) if (tags.ProjectionType.value === "cylindrical") { throw "Unsupported image format: cylindrical images (panorama images) are currently not supported" } if (tags?.GPSLatitude?.value && tags?.GPSLongitude?.value) { const [[latD], [latM], [latS, latSDenom]] = < [[number, number], [number, number], [number, number]] >tags?.GPSLatitude?.value const [[lonD], [lonM], [lonS, lonSDenom]] = < [[number, number], [number, number], [number, number]] >tags?.GPSLongitude?.value const exifLat = latD + latM / 60 + latS / (3600 * latSDenom) const exifLon = lonD + lonM / 60 + lonS / (3600 * lonSDenom) if ( typeof exifLat === "number" && !isNaN(exifLat) && typeof exifLon === "number" && !isNaN(exifLon) && !(exifLat === 0 && exifLon === 0) ) { lat = exifLat lon = exifLon if (tags?.GPSLatitudeRef?.value?.[0] === "S") { lat *= -1 } if (tags?.GPSLongitudeRef?.value?.[0] === "W") { lon *= -1 } } } const dateTime = ( tags.DateTime.value[0] ?? tags.DateTimeOriginal.value[0] ?? tags.GPSDateStamp ?? tags.CreateDate ?? tags["Date Created"] )?.split(" ") if (dateTime) { const [date, time] = dateTime const exifDatetime = new Date(date.replaceAll(":", "-") + "T" + time) if (exifDatetime.getFullYear() === 1970) { // The data probably got reset to the epoch // we don't use the value console.log( "Datetime from picture is probably invalid:", exifDatetime, "using 'now' instead" ) } else { datetime = exifDatetime.toISOString() } } } catch (e) { console.warn("Could not read EXIF-tags due to", e) } const p = this.panoramax sequenceId ??= this._targetSequence?.data ?? Constants.panoramax.sequence const sequence: { id: string; "stats:items": { count: number } } = ( await p.mySequences() ).find((s) => s.id === sequenceId) const options: AddImageOptions = { lon, lat, datetime, isBlurred: noblur, onProgress: undefined, indexInSequence: sequence["stats:items"].count + 1, // stats:items is '1'-indexed, so .count is also the last index exifOverride: { Artist: author, }, } if (progress) { options.onProgress = (e: ProgressEvent) => { if (e.lengthComputable) { const percentage = (e.loaded / e.total) * 100 progress.set(Math.round(percentage)) } } progress.set(0) } const img = await p.addImage(blob, sequenceId, options) PanoramaxImageProvider.singleton.addKnownMeta(img) progress.set(undefined) return { key: "panoramax", value: img.id, absoluteUrl: img.assets.hd.href, } } }