Feat: check if the image was blurred, attempt to reload if it is done; refactoring of ImageProvider code

This commit is contained in:
Pieter Vander Vennet 2024-09-28 02:04:14 +02:00
parent 590591dd31
commit 4650170db4
14 changed files with 224 additions and 190 deletions

View file

@ -577,6 +577,7 @@
"title": "Nearby streetview imagery"
},
"pleaseLogin": "Please log in to add a picture",
"processing": "The server is processing your image",
"respectPrivacy": "Do not upload from Google Maps, Google Streetview or other copyrighted sources.",
"toBig": "Your image is too large as it is {actual_size}. Please use images of at most {max_size}",
"upload": {
@ -873,4 +874,4 @@
"startsWithQ": "A wikidata identifier starts with Q and is followed by a number"
}
}
}
}

14
package-lock.json generated
View file

@ -63,7 +63,7 @@
"opening_hours": "^3.6.0",
"osm-auth": "^2.5.0",
"osmtogeojson": "^3.0.0-beta.5",
"panoramax-js": "^0.1.1",
"panoramax-js": "^0.1.4",
"panzoom": "^9.4.3",
"papaparse": "^5.3.1",
"pbf": "^3.2.1",
@ -15994,9 +15994,9 @@
"license": "MIT"
},
"node_modules/panoramax-js": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/panoramax-js/-/panoramax-js-0.1.1.tgz",
"integrity": "sha512-6R/Bo89Nwln92zG0TwqxGhtjn6dyDrxMEO/lTTtgTZc1lkEF2znHfDXKJa4YfTPUz14FtNVOV1IWmPsp/YULYw==",
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/panoramax-js/-/panoramax-js-0.1.4.tgz",
"integrity": "sha512-X7plFMH1ndxiiyVFEluDloNiEBH0nEkurCPJ7zAInxbgv21pp/EGFwu3ynmF5ETyyXB9zu0n309juyjTdJ5pnQ==",
"dependencies": {
"@ogcapi-js/features": "^1.1.1",
"@ogcapi-js/shared": "^1.1.1",
@ -32056,9 +32056,9 @@
"version": "1.0.0"
},
"panoramax-js": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/panoramax-js/-/panoramax-js-0.1.1.tgz",
"integrity": "sha512-6R/Bo89Nwln92zG0TwqxGhtjn6dyDrxMEO/lTTtgTZc1lkEF2znHfDXKJa4YfTPUz14FtNVOV1IWmPsp/YULYw==",
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/panoramax-js/-/panoramax-js-0.1.4.tgz",
"integrity": "sha512-X7plFMH1ndxiiyVFEluDloNiEBH0nEkurCPJ7zAInxbgv21pp/EGFwu3ynmF5ETyyXB9zu0n309juyjTdJ5pnQ==",
"requires": {
"@ogcapi-js/features": "^1.1.1",
"@ogcapi-js/shared": "^1.1.1",

View file

@ -205,7 +205,7 @@
"opening_hours": "^3.6.0",
"osm-auth": "^2.5.0",
"osmtogeojson": "^3.0.0-beta.5",
"panoramax-js": "^0.1.1",
"panoramax-js": "^0.1.4",
"panzoom": "^9.4.3",
"papaparse": "^5.3.1",
"pbf": "^3.2.1",

View file

@ -6,6 +6,7 @@ import { Store, UIEventSource } from "../UIEventSource"
import ImageProvider, { ProvidedImage } from "./ImageProvider"
import { WikidataImageProvider } from "./WikidataImageProvider"
import Panoramax from "./Panoramax"
import { Utils } from "../../Utils"
/**
* A generic 'from the interwebz' image picker, without attribution
@ -45,10 +46,6 @@ export default class AllImageProviders {
wikimedia: WikimediaImageProvider.singleton,
panoramax: Panoramax.singleton
}
private static _cache: Map<string, UIEventSource<ProvidedImage[]>> = new Map<
string,
UIEventSource<ProvidedImage[]>
>()
public static byName(name: string) {
return AllImageProviders.providersByName[name.toLowerCase()]
@ -76,42 +73,25 @@ export default class AllImageProviders {
tags: Store<Record<string, string>>,
tagKey?: string[]
): Store<ProvidedImage[]> {
if (tags.data.id === undefined) {
if (tags?.data?.id === undefined) {
return undefined
}
const cacheKey = tags.data.id + tagKey
const cached = this._cache.get(cacheKey)
if (cached !== undefined) {
return cached
}
const source = new UIEventSource([])
this._cache.set(cacheKey, source)
const allSources: Store<ProvidedImage[]>[] = []
for (const imageProvider of AllImageProviders.ImageAttributionSource) {
const singleSource = imageProvider.GetRelevantUrls(tags, {
/*
By default, 'GetRelevantUrls' uses the defaultKeyPrefixes.
However, we override them if a custom image tag is set, e.g. 'image:menu'
*/
prefixes: tagKey ?? imageProvider.defaultKeyPrefixes,
})
/*
By default, 'GetRelevantUrls' uses the defaultKeyPrefixes.
However, we override them if a custom image tag is set, e.g. 'image:menu'
*/
const prefixes = tagKey ?? imageProvider.defaultKeyPrefixes
const singleSource = tags.bindD(tags => imageProvider.getRelevantUrls(tags, prefixes))
allSources.push(singleSource)
singleSource.addCallbackAndRunD((_) => {
const all: ProvidedImage[] = [].concat(...allSources.map((source) => source.data))
const uniq = []
const seen = new Set<string>()
for (const img of all) {
if (seen.has(img.url)) {
continue
}
seen.add(img.url)
uniq.push(img)
}
source.setData(uniq)
const dedup = Utils.DedupOnId(all, i => i?.id ?? i?.url)
source.set(dedup)
})
}
return source

View file

@ -15,26 +15,24 @@ export default class GenericImageProvider extends ImageProvider {
this._valuePrefixBlacklist = valuePrefixBlacklist
}
async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
ExtractUrls(key: string, value: string): undefined | ProvidedImage[] {
if (this._valuePrefixBlacklist.some((prefix) => value.startsWith(prefix))) {
return []
return undefined
}
try {
new URL(value)
} catch (_) {
// Not a valid URL
return []
return undefined
}
return [
Promise.resolve({
key: key,
url: value,
provider: this,
id: value,
}),
]
return [{
key: key,
url: value,
provider: this,
id: value,
}]
}
SourceIcon() {

View file

@ -1,4 +1,4 @@
import { Store, UIEventSource } from "../UIEventSource"
import { Store, Stores, UIEventSource } from "../UIEventSource"
import BaseUIElement from "../../UI/BaseUIElement"
import { LicenseInfo } from "./LicenseInfo"
import { Utils } from "../../Utils"
@ -10,6 +10,7 @@ export interface ProvidedImage {
provider: ImageProvider
id: string
date?: Date,
status?: string | "ready"
/**
* Compass angle of the taken image
* 0 = north, 90° = East
@ -26,59 +27,45 @@ export default abstract class ImageProvider {
public abstract SourceIcon(id?: string, location?: { lon: number; lat: number }): BaseUIElement
/**
* Given a properties object, maps it onto _all_ the available pictures for this imageProvider.
* This iterates over _all_ tags and matches _anything_ that might be an image
* Gets all the relevant URLS for the given tags and for the given prefixes;
* extracts the necessary information
* @param tags
* @param prefixes
*/
public GetRelevantUrls(
allTags: Store<any>,
options?: {
prefixes?: string[]
}
): UIEventSource<ProvidedImage[]> {
const prefixes = Utils.Dedup(options?.prefixes ?? this.defaultKeyPrefixes)
if (prefixes === undefined) {
throw "No `defaultKeyPrefixes` defined by this image provider"
}
const relevantUrls = new UIEventSource<
{ id: string; url: string; key: string; provider: ImageProvider }[]
>([])
public async getRelevantUrlsFor(tags: Record<string, string>, prefixes: string[]): Promise<ProvidedImage[]> {
const relevantUrls: ProvidedImage[] = []
const seenValues = new Set<string>()
allTags.addCallbackAndRunD((tags) => {
for (const key in tags) {
if(key === "panoramax"){
console.log("Inspecting", key,"against", prefixes)
}
if (!prefixes.some((prefix) => key === prefix || key.match(new RegExp(prefix+":[0-9]+")))) {
for (const key in tags) {
if (!prefixes.some((prefix) => key === prefix || key.match(new RegExp(prefix+":[0-9]+")))) {
continue
}
const values = Utils.NoEmpty(tags[key]?.split(";")?.map((v) => v.trim()) ?? [])
for (const value of values) {
if (seenValues.has(value)) {
continue
}
const values = Utils.NoEmpty(tags[key]?.split(";")?.map((v) => v.trim()) ?? [])
for (const value of values) {
if (seenValues.has(value)) {
continue
}
seenValues.add(value)
this.ExtractUrls(key, value).then((promises) => {
for (const promise of promises ?? []) {
if (promise === undefined) {
continue
}
promise.then((providedImage) => {
if (providedImage === undefined) {
return
}
relevantUrls.data.push(providedImage)
relevantUrls.ping()
})
}
})
seenValues.add(value)
let images = this.ExtractUrls(key, value)
if(!Array.isArray(images)){
images = await images
}
if(images){
relevantUrls.push(...images)
}
}
})
}
return relevantUrls
}
public abstract ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]>
public getRelevantUrls(tags: Record<string, string>, prefixes: string[]): Store<ProvidedImage[]> {
return Stores.FromPromise(this.getRelevantUrlsFor(tags, prefixes))
}
public abstract ExtractUrls(key: string, value: string): undefined | ProvidedImage[] | Promise<ProvidedImage[]>
public abstract DownloadAttribution(providedImage: {
url: string

View file

@ -24,18 +24,18 @@ export class Imgur extends ImageProvider {
return undefined
}
public async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
public ExtractUrls(key: string, value: string): undefined | ProvidedImage[] {
if (Imgur.defaultValuePrefix.some((prefix) => value.startsWith(prefix))) {
return [
Promise.resolve({
{
url: value,
key: key,
provider: this,
id: value,
}),
}
]
}
return []
return undefined
}
/**

View file

@ -131,8 +131,9 @@ export class Mapillary extends ImageProvider {
return new SvelteUIElement(MapillaryIcon, { url })
}
async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
return [this.PrepareUrlAsync(key, value)]
async ExtractUrls(key: string, value: string): Promise<ProvidedImage[]> {
const img = await this.PrepareUrlAsync(key, value)
return [img]
}
public async DownloadAttribution(providedImage: { id: string }): Promise<LicenseInfo> {

View file

@ -1,35 +1,31 @@
import { ImageUploader } from "./ImageUploader"
import { AuthorizedPanoramax } from "panoramax-js/dist"
import { AuthorizedPanoramax, PanoramaxXYZ, ImageData } from "panoramax-js/dist"
import ExifReader from "exifreader"
import ImageProvider, { ProvidedImage } from "./ImageProvider"
import BaseUIElement from "../../UI/BaseUIElement"
import { LicenseInfo } from "./LicenseInfo"
import { Utils } from "../../Utils"
import { Feature, FeatureCollection, Point } from "geojson"
import { GeoOperations } from "../GeoOperations"
import Constants from "../../Models/Constants"
import { Store, Stores, UIEventSource } from "../UIEventSource"
type ImageData = Feature<Point, { "geovisio:producer": string, "geovisio:license": string, "datetime": string }> & {
id: string,
assets: { hd: { href: string }, sd: { href: string } },
providers: {name: string}[]
}
export default class PanoramaxImageProvider extends ImageProvider {
public static readonly singleton = new PanoramaxImageProvider()
private static readonly xyz = new PanoramaxXYZ()
private static defaultPanoramax = new AuthorizedPanoramax(Constants.panoramax.url, Constants.panoramax.token)
public defaultKeyPrefixes: string[] = ["panoramax"]
public readonly name: string = "panoramax"
private static knownMeta: Record<string, ImageData> = {}
private static knownMeta: Record<string, { data: ImageData, time: Date }> = {}
public SourceIcon(id?: string, location?: { lon: number; lat: number; }): BaseUIElement {
return undefined
}
public addKnownMeta(meta: ImageData){
console.log("Adding known meta for", meta.id)
PanoramaxImageProvider.knownMeta[meta.id] = meta
public addKnownMeta(meta: ImageData) {
PanoramaxImageProvider.knownMeta[meta.id] = { data: meta, time: new Date() }
}
/**
@ -39,16 +35,14 @@ export default class PanoramaxImageProvider extends ImageProvider {
*/
private async getInfoFromMapComplete(id: string): Promise<{ data: ImageData, url: string }> {
const sequence = "6e702976-580b-419c-8fb3-cf7bd364e6f8" // We always reuse this sequence
const url = `https://panoramax.mapcomplete.org/api/collections/${sequence}/items/${id}`
const data = <any> await Utils.downloadJsonCached(url, 60 * 60 * 1000)
return {url, data}
const url = `https://panoramax.mapcomplete.org/`
const data = await PanoramaxImageProvider.defaultPanoramax.imageInfo(sequence, id)
return { url, data }
}
private async getInfoFromXYZ(imageId: string): Promise<{ data: ImageData, url: string }> {
const url = "https://api.panoramax.xyz/api/search?limit=1&ids=" + imageId
const metaAll = await Utils.downloadJsonCached<FeatureCollection<Point>>(url, 1000 * 60 * 60)
const data= <any>metaAll.features[0]
return {data, url}
const data = await PanoramaxImageProvider.xyz.imageInfo(imageId)
return { data, url: "https://api.panoramax.xyz/" }
}
@ -57,17 +51,18 @@ export default class PanoramaxImageProvider extends ImageProvider {
* @param meta
* @private
*/
private featureToImage(info: {data: ImageData, url: string}) {
const meta = info.data
const url = info.url
private featureToImage(info: { data: ImageData, url: string }) {
const meta = info?.data
if (!meta) {
return undefined
}
function makeAbsolute(s: string){
if(!s.startsWith("https://") && !s.startsWith("http://")){
const parsed = new URL(url)
return parsed.protocol+"//"+parsed.host+s
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
}
@ -80,27 +75,64 @@ export default class PanoramaxImageProvider extends ImageProvider {
lon, lat,
key: "panoramax",
provider: this,
status: meta.properties["geovisio:status"],
rotation: Number(meta.properties["view:azimuth"]),
date: new Date(meta.properties.datetime),
}
}
private async getInfoFor(id: string): Promise<{ data: ImageData, url: string }> {
const cached= PanoramaxImageProvider.knownMeta[id]
console.log("Cached version", id, cached)
if(cached){
return {data: cached, url: undefined}
if (!id.match(/^[a-zA-Z0-9-]+$/)) {
return undefined
}
const cached = PanoramaxImageProvider.knownMeta[id]
if (cached) {
if(new Date().getTime() - cached.time.getTime() < 1000){
return { data: cached.data, url: undefined }
}
}
try {
return await this.getInfoFromMapComplete(id)
} catch (e) {
return await this.getInfoFromXYZ(id)
console.debug(e)
}
try {
return await this.getInfoFromXYZ(id)
} catch (e) {
console.debug(e)
}
return undefined
}
public async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
return [this.getInfoFor(value).then(r => this.featureToImage(<any>r))]
public async ExtractUrls(key: string, value: string): Promise<ProvidedImage[]> {
return [await this.getInfoFor(value).then(r => this.featureToImage(<any>r))]
}
getRelevantUrls(tags: Record<string, string>, prefixes: string[]): Store<ProvidedImage[]> {
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")
}
Stores.Chronic(1500, () =>
hasLoading(source.data),
).addCallback(_ => {
console.log("UPdating... ")
super.getRelevantUrlsFor(tags, prefixes).then(data => {
console.log("New panoramax data is", data, hasLoading(data))
source.set(data)
return !hasLoading(data)
})
})
return source
}
public async DownloadAttribution(providedImage: { url: string; id: string; }): Promise<LicenseInfo> {
@ -139,7 +171,7 @@ export class PanoramaxUploader implements ImageUploader {
const p = this._panoramax
const defaultSequence = (await p.mySequences())[0]
const img = <ImageData> await p.addImage(blob, defaultSequence, {
const img = <ImageData>await p.addImage(blob, defaultSequence, {
lat: !hasGPS ? lat : undefined,
lon: !hasGPS ? lon : undefined,
datetime: !hasDate ? new Date().toISOString() : undefined,
@ -149,11 +181,10 @@ export class PanoramaxUploader implements ImageUploader {
})
PanoramaxImageProvider.singleton.addKnownMeta(img)
await Utils.waitFor(1250)
return {
key: "panoramax",
value: img.id,
absoluteUrl: img.assets.hd.href
absoluteUrl: img.assets.hd.href,
}
}

View file

@ -5,6 +5,7 @@ import Wikidata from "../Web/Wikidata"
import SvelteUIElement from "../../UI/Base/SvelteUIElement"
import * as Wikidata_icon from "../../assets/svg/Wikidata.svelte"
import { Utils } from "../../Utils"
import { ImmutableStore, Store, Stores, UIEventSource } from "../UIEventSource"
export class WikidataImageProvider extends ImageProvider {
public static readonly singleton = new WikidataImageProvider()
@ -25,28 +26,28 @@ export class WikidataImageProvider extends ImageProvider {
return new SvelteUIElement(Wikidata_icon)
}
public async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
public async ExtractUrls(key: string, value: string): Promise<ProvidedImage[]> {
if (WikidataImageProvider.keyBlacklist.has(key)) {
return []
return undefined
}
const entity = await Wikidata.LoadWikidataEntryAsync(value)
if (entity === undefined) {
return []
return undefined
}
const allImages: Promise<ProvidedImage>[] = []
const allImages: Promise<ProvidedImage[]>[] = []
// P18 is the claim 'depicted in this image'
for (const img of Array.from(entity.claims.get("P18") ?? [])) {
const promises = await WikimediaImageProvider.singleton.ExtractUrls(undefined, img)
allImages.push(...promises)
const promises = WikimediaImageProvider.singleton.ExtractUrls(undefined, img)
allImages.push(promises)
}
// P373 is 'commons category'
for (let cat of Array.from(entity.claims.get("P373") ?? [])) {
if (!cat.startsWith("Category:")) {
cat = "Category:" + cat
}
const promises = await WikimediaImageProvider.singleton.ExtractUrls(undefined, cat)
allImages.push(...promises)
const promises = WikimediaImageProvider.singleton.ExtractUrls(undefined, cat)
allImages.push(promises)
}
const commons = entity.commons
@ -54,10 +55,11 @@ export class WikidataImageProvider extends ImageProvider {
commons !== undefined &&
(commons.startsWith("Category:") || commons.startsWith("File:"))
) {
const promises = await WikimediaImageProvider.singleton.ExtractUrls(undefined, commons)
allImages.push(...promises)
const promises = WikimediaImageProvider.singleton.ExtractUrls(undefined, commons)
allImages.push(promises)
}
return allImages
const resolved = await Promise.all(Utils.NoNull(allImages))
return [].concat(...resolved)
}
public DownloadAttribution(_): Promise<any> {

View file

@ -37,7 +37,7 @@ export class WikimediaImageProvider extends ImageProvider {
return value
}
const baseUrl = `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(
value
value,
)}`
if (useHd) {
return baseUrl
@ -97,28 +97,27 @@ export class WikimediaImageProvider extends ImageProvider {
return this.UrlForImage("File:" + value)
}
public async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
public async ExtractUrls(key: string, value: string): undefined | Promise<ProvidedImage[]> {
const hasCommonsPrefix = WikimediaImageProvider.startsWithCommonsPrefix(value)
if (key !== undefined && key !== this.commons_key && !hasCommonsPrefix) {
return []
return undefined
}
value = WikimediaImageProvider.removeCommonsPrefix(value)
if (value.startsWith("Category:")) {
const urls = await Wikimedia.GetCategoryContents(value)
return urls
.filter((url) => url.startsWith("File:"))
.map((image) => Promise.resolve(this.UrlForImage(image)))
return urls.filter((url) => url.startsWith("File:"))
.map((image) => this.UrlForImage(image))
}
if (value.startsWith("File:")) {
return [Promise.resolve(this.UrlForImage(value))]
return [this.UrlForImage(value)]
}
if (value.startsWith("http")) {
// PRobably an error
return []
// Probably an error
return undefined
}
// We do a last effort and assume this is a file
return [Promise.resolve(this.UrlForImage("File:" + value))]
return [(this.UrlForImage("File:" + value))]
}
public async DownloadAttribution(img: { url: string }): Promise<LicenseInfo> {
@ -148,7 +147,7 @@ export class WikimediaImageProvider extends ImageProvider {
console.warn(
"The file",
filename,
"has no usable metedata or license attached... Please fix the license info file yourself!"
"has no usable metedata or license attached... Please fix the license info file yourself!",
)
return undefined
}

View file

@ -13,6 +13,9 @@
import { onDestroy } from "svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import type { Feature, Point } from "geojson"
import Loading from "../Base/Loading.svelte"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
export let image: Partial<ProvidedImage>
let fallbackImage: string = undefined
@ -30,7 +33,7 @@
let showBigPreview = new UIEventSource(false)
onDestroy(showBigPreview.addCallbackAndRun(shown => {
if (!shown) {
previewedImage.set(false)
previewedImage.set(undefined)
}
}))
onDestroy(previewedImage.addCallbackAndRun(previewedImage => {
@ -49,12 +52,12 @@
type: "Feature",
properties: {
id: image.id,
rotation: image.rotation
rotation: image.rotation,
},
geometry: {
type: "Point",
coordinates: [image.lon, image.lat]
}
coordinates: [image.lon, image.lat],
},
}
console.log(f)
state?.geocodedImages.set([f])
@ -73,36 +76,45 @@
on:click={() => {console.log("Closing");previewedImage.set(undefined)}}></CloseButton>
</div>
</Popup>
<div class="relative shrink-0">
<div class="relative w-fit"
on:mouseenter={() => highlight()}
on:mouseleave={() => highlight(false)}
>
<img
bind:this={imgEl}
on:load={() => (loaded = true)}
class={imgClass ?? ""}
class:cursor-zoom-in={canZoom}
on:click={() => {
{#if image.status !== undefined && image.status !== "ready"}
<div class="h-full flex flex-col justify-center">
<Loading>
<Tr t={Translations.t.image.processing}/>
</Loading>
</div>
{:else}
<div class="relative shrink-0">
<div class="relative w-fit"
on:mouseenter={() => highlight()}
on:mouseleave={() => highlight(false)}
>
<img
bind:this={imgEl}
on:load={() => (loaded = true)}
class={imgClass ?? ""}
class:cursor-zoom-in={canZoom}
on:click={() => {
previewedImage?.set(image)
}}
on:error={() => {
on:error={() => {
if (fallbackImage) {
imgEl.src = fallbackImage
}
}}
src={image.url}
/>
src={image.url}
/>
{#if canZoom && loaded}
<div
class="bg-black-transparent absolute right-0 top-0 rounded-bl-full"
on:click={() => previewedImage.set(image)}>
<MagnifyingGlassPlusIcon class="h-8 w-8 cursor-zoom-in pl-3 pb-3" color="white" />
</div>
{/if}
{#if canZoom && loaded}
<div
class="bg-black-transparent absolute right-0 top-0 rounded-bl-full"
on:click={() => previewedImage.set(image)}>
<MagnifyingGlassPlusIcon class="h-8 w-8 cursor-zoom-in pl-3 pb-3" color="white" />
</div>
{/if}
</div>
<div class="absolute bottom-0 left-0">
<ImageAttribution {image} {attributionFormat} />
</div>
</div>
<div class="absolute bottom-0 left-0">
<ImageAttribution {image} {attributionFormat} />
</div>
</div>
{/if}

View file

@ -31,7 +31,7 @@ export class ImageCarousel extends Toggle {
image: url,
state,
previewedImage: state?.previewedImage,
})
}).SetClass("h-full")
if (url.key !== undefined) {
image = new Combine([
@ -42,8 +42,8 @@ export class ImageCarousel extends Toggle {
]).SetClass("relative")
}
image
.SetClass("w-full block cursor-zoom-in")
.SetStyle("min-width: 50px; background: grey;")
.SetClass("w-full h-full block cursor-zoom-in low-interaction")
.SetStyle("min-width: 50px;")
uiElements.push(image)
} catch (e) {
console.error("Could not generate image element for", url.url, "due to", e)

View file

@ -414,6 +414,29 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
return items
}
/**
* Deduplicates the given array based on some ID-properties.
* Removes all falsey values
* @param arr
* @param toKey
* @constructor
*/
public static DedupOnId<T>(arr: T[], toKey: ((t:T) => string) ): T[]{
const uniq: T[] = []
const seen = new Set<string>()
for (const img of arr) {
if(!img){
continue
}
const k = toKey(img)
if (!seen.has(k)) {
seen.add(k)
uniq.push(img)
}
}
return uniq
}
/**
* Finds all duplicates in a list of strings
*