From 48a7c45416b1c49c703d71c37e722d1f9c959521 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Sat, 16 Sep 2023 02:30:01 +0200 Subject: [PATCH] Refactoring: port image link functionality to Svelte --- src/Logic/ImageProviders/Mapillary.ts | 2 +- src/Logic/Osm/Actions/ChangeTagAction.ts | 2 +- src/Logic/Osm/Actions/LinkPicture.ts | 32 +++ src/Logic/Web/NearbyImagesSearch.ts | 222 +++++++++++++++ src/UI/Popup/LinkableImage.svelte | 73 +++++ src/UI/Popup/NearbyImages.svelte | 41 +++ src/UI/Popup/NearbyImages.ts | 316 ---------------------- src/UI/Popup/NearbyImagesCollapsed.svelte | 32 +++ src/UI/SpecialVisualizations.ts | 128 +-------- 9 files changed, 407 insertions(+), 441 deletions(-) create mode 100644 src/Logic/Osm/Actions/LinkPicture.ts create mode 100644 src/Logic/Web/NearbyImagesSearch.ts create mode 100644 src/UI/Popup/LinkableImage.svelte create mode 100644 src/UI/Popup/NearbyImages.svelte delete mode 100644 src/UI/Popup/NearbyImages.ts create mode 100644 src/UI/Popup/NearbyImagesCollapsed.svelte diff --git a/src/Logic/ImageProviders/Mapillary.ts b/src/Logic/ImageProviders/Mapillary.ts index 1fbbbc145..102bb709b 100644 --- a/src/Logic/ImageProviders/Mapillary.ts +++ b/src/Logic/ImageProviders/Mapillary.ts @@ -86,7 +86,7 @@ export class Mapillary extends ImageProvider { public async DownloadAttribution(url: string): Promise { const license = new LicenseInfo() - license.artist = "Contributor name unavailable" + license.artist = undefined license.license = "CC BY-SA 4.0" // license.license = "Creative Commons Attribution-ShareAlike 4.0 International License"; license.attributionRequired = true diff --git a/src/Logic/Osm/Actions/ChangeTagAction.ts b/src/Logic/Osm/Actions/ChangeTagAction.ts index 1abfb6798..72fbffab4 100644 --- a/src/Logic/Osm/Actions/ChangeTagAction.ts +++ b/src/Logic/Osm/Actions/ChangeTagAction.ts @@ -28,7 +28,7 @@ export default class ChangeTagAction extends OsmChangeAction { currentTags: Record, meta: { theme: string - changeType: "answer" | "soft-delete" | "add-image" | string + changeType: "answer" | "soft-delete" | "add-image" | "link-image" | string } ) { super(elementId, true) diff --git a/src/Logic/Osm/Actions/LinkPicture.ts b/src/Logic/Osm/Actions/LinkPicture.ts new file mode 100644 index 000000000..014a836a0 --- /dev/null +++ b/src/Logic/Osm/Actions/LinkPicture.ts @@ -0,0 +1,32 @@ +import ChangeTagAction from "./ChangeTagAction" +import { Tag } from "../../Tags/Tag" + +export default class LinkPicture extends ChangeTagAction { + /** + * Adds a link to an image + * @param elementId + * @param proposedKey: a key which might be used, typically `image`. If the key is already used with a different URL, `key+":0"` will be used instead (or a higher number if needed) + * @param url + * @param currentTags + * @param meta + * + */ + constructor( + elementId: string, + proposedKey: "image" | "mapillary" | "wiki_commons" | string, + url: string, + currentTags: Record, + meta: { + theme: string + changeType: "add-image" | "link-image" + } + ) { + let key = proposedKey + let i = 0 + while (currentTags[key] !== undefined && currentTags[key] !== url) { + key = proposedKey + ":" + i + i++ + } + super(elementId, new Tag(key, url), currentTags, meta) + } +} diff --git a/src/Logic/Web/NearbyImagesSearch.ts b/src/Logic/Web/NearbyImagesSearch.ts new file mode 100644 index 000000000..665cc66e7 --- /dev/null +++ b/src/Logic/Web/NearbyImagesSearch.ts @@ -0,0 +1,222 @@ +import { IndexedFeatureSource } from "../FeatureSource/FeatureSource" +import { GeoOperations } from "../GeoOperations" +import { ImmutableStore, Store, Stores, UIEventSource } from "../UIEventSource" +import { Mapillary } from "../ImageProviders/Mapillary" +import P4C from "pic4carto" +import { Utils } from "../../Utils" +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 +} + +export interface P4CPicture { + pictureUrl: string + date?: number + coordinates: { lat: number; lng: number } + provider: "Mapillary" | string + author? + license? + detailsUrl?: string + direction? + osmTags?: object /*To copy straight into OSM!*/ + thumbUrl: string + details: { + isSpherical: boolean + } +} + +/** + * Uses Pic4wCarto to fetch nearby images from various providers + */ +export default class NearbyImagesSearch { + private static readonly services = [ + "mapillary", + "flickr", + "openstreetcam", + "wikicommons", + ] as const + + private individualStores + private readonly _store: UIEventSource = new UIEventSource([]) + public readonly store: Store = this._store + private readonly _options: NearbyImageOptions + + constructor(options: NearbyImageOptions, features: IndexedFeatureSource) { + this.individualStores = NearbyImagesSearch.services.map((s) => + NearbyImagesSearch.buildPictureFetcher(options, s) + ) + 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 buildPictureFetcher( + options: NearbyImageOptions, + fetcher: "mapillary" | "flickr" | "openstreetcam" | "wikicommons" + ): Store<{ images: P4CPicture[]; beforeFilter: number }> { + const picManager = new P4C.PicturesManager({ usefetchers: [fetcher] }) + const searchRadius = options.searchRadius ?? 100 + const maxAgeSeconds = (options.maxDaysOld ?? 3 * 365) * 24 * 60 * 60 * 1000 + + const p4cStore = Stores.FromPromise( + picManager.startPicsRetrievalAround( + new P4C.LatLng(options.lat, options.lon), + searchRadius, + { + mindate: new Date().getTime() - maxAgeSeconds, + towardscenter: false, + } + ) + ) + return p4cStore.map( + (images) => { + 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] + 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) + } +} + +/** + * Extracts pictures from currently loaded features + */ +class ImagesInLoadedDataFetcher { + private indexedFeatures: IndexedFeatureSource + + constructor(indexedFeatures: IndexedFeatureSource) { + this.indexedFeatures = indexedFeatures + } + + public fetchAround(loc: { lon: number; lat: number; searchRadius?: number }): P4CPicture[] { + 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, [loc.lon, loc.lat]) + if (loc.searchRadius !== undefined && d > loc.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 + } +} diff --git a/src/UI/Popup/LinkableImage.svelte b/src/UI/Popup/LinkableImage.svelte new file mode 100644 index 000000000..798bebb09 --- /dev/null +++ b/src/UI/Popup/LinkableImage.svelte @@ -0,0 +1,73 @@ + +
+ + {#if linkable} + + {/if} +
diff --git a/src/UI/Popup/NearbyImages.svelte b/src/UI/Popup/NearbyImages.svelte new file mode 100644 index 000000000..eb79e80ff --- /dev/null +++ b/src/UI/Popup/NearbyImages.svelte @@ -0,0 +1,41 @@ + + +{#if $images.length === 0} + +{:else} +
+ {#each $images as image (image.pictureUrl)} + + {/each} +
+{/if} diff --git a/src/UI/Popup/NearbyImages.ts b/src/UI/Popup/NearbyImages.ts deleted file mode 100644 index 6ddc1ab6f..000000000 --- a/src/UI/Popup/NearbyImages.ts +++ /dev/null @@ -1,316 +0,0 @@ -import Combine from "../Base/Combine" -import { Store, Stores, UIEventSource } from "../../Logic/UIEventSource" -import { SlideShow } from "../Image/SlideShow" -import { ClickableToggle } from "../Input/Toggle" -import Loading from "../Base/Loading" -import { AttributedImage } from "../Image/AttributedImage" -import AllImageProviders from "../../Logic/ImageProviders/AllImageProviders" -import Svg from "../../Svg" -import BaseUIElement from "../BaseUIElement" -import { InputElement } from "../Input/InputElement" -import { VariableUiElement } from "../Base/VariableUIElement" -import Translations from "../i18n/Translations" -import { Mapillary } from "../../Logic/ImageProviders/Mapillary" -import { SubtleButton } from "../Base/SubtleButton" -import { GeoOperations } from "../../Logic/GeoOperations" -import Lazy from "../Base/Lazy" -import P4C from "pic4carto" -import { IndexedFeatureSource } from "../../Logic/FeatureSource/FeatureSource" - -export interface P4CPicture { - pictureUrl: string - date?: number - coordinates: { lat: number; lng: number } - provider: "Mapillary" | string - author? - license? - detailsUrl?: string - direction? - osmTags?: object /*To copy straight into OSM!*/ - thumbUrl: string - details: { - isSpherical: boolean - } -} - -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 -} - -class ImagesInLoadedDataFetcher { - private indexedFeatures: IndexedFeatureSource - - constructor(indexedFeatures: IndexedFeatureSource) { - this.indexedFeatures = indexedFeatures - } - - public fetchAround(loc: { lon: number; lat: number; searchRadius?: number }): P4CPicture[] { - 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, [loc.lon, loc.lat]) - if (loc.searchRadius !== undefined && d > loc.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, - }, - }) - } - }) - const cleaned: P4CPicture[] = [] - const seen = new Set() - for (const foundImage of foundImages) { - if (seen.has(foundImage.pictureUrl)) { - continue - } - seen.add(foundImage.pictureUrl) - cleaned.push(foundImage) - } - return cleaned - } -} - -export default class NearbyImages extends Lazy { - constructor(options: NearbyImageOptions, state?: IndexedFeatureSource) { - super(() => { - const t = Translations.t.image.nearbyPictures - const shownImages = options.shownImagesCount ?? new UIEventSource(25) - - const loadedPictures = NearbyImages.buildPictureFetcher(options, state) - - const loadMoreButton = new Combine([ - new SubtleButton(Svg.add_svg(), t.loadMore).onClick(() => { - shownImages.setData(shownImages.data + 25) - }), - ]).SetClass("flex flex-col justify-center") - - const imageElements = loadedPictures.map( - (imgs) => { - if (imgs === undefined) { - return [] - } - const elements = (imgs.images ?? []) - .slice(0, shownImages.data) - .map((i) => this.prepareElement(i)) - if (imgs.images !== undefined && elements.length < imgs.images.length) { - // We effectively sliced some items, so we can increase the count - elements.push(loadMoreButton) - } - return elements - }, - [shownImages] - ) - - return new VariableUiElement( - loadedPictures.map((loaded) => { - if (loaded?.images === undefined) { - return NearbyImages.NoImagesView(new Loading(t.loading)).SetClass( - "animate-pulse" - ) - } - const images = loaded.images - const beforeFilter = loaded?.beforeFilter - if (beforeFilter === 0) { - return NearbyImages.NoImagesView(t.nothingFound.SetClass("alert block")) - } else if (images.length === 0) { - const removeFiltersButton = new SubtleButton( - Svg.filter_disable_svg(), - t.removeFilters - ).onClick(() => { - options.shownRadius.setData(options.searchRadius) - options.allowSpherical.setData(true) - options.towardscenter.setData(false) - }) - - return NearbyImages.NoImagesView( - t.allFiltered.SetClass("font-bold"), - removeFiltersButton - ) - } - - return new SlideShow(imageElements) - }) - ) - }) - } - - private static NoImagesView(...elems: BaseUIElement[]) { - return new Combine(elems) - .SetClass("flex flex-col justify-center items-center bg-gray-200 mb-2 rounded-lg") - .SetStyle( - "height: calc( var(--image-carousel-height) - 0.5rem ) ; max-height: calc( var(--image-carousel-height) - 0.5rem );" - ) - } - - private static buildPictureFetcher(options: NearbyImageOptions, state?: IndexedFeatureSource) { - const picManager = new P4C.PicturesManager({}) - const searchRadius = options.searchRadius ?? 500 - - const nearbyImages = - state !== undefined ? new ImagesInLoadedDataFetcher(state).fetchAround(options) : [] - - return Stores.FromPromise( - picManager.startPicsRetrievalAround( - new P4C.LatLng(options.lat, options.lon), - options.searchRadius ?? 500, - { - mindate: - new Date().getTime() - - (options.maxDaysOld ?? 3 * 365) * 24 * 60 * 60 * 1000, - towardscenter: false, - } - ) - ).map( - (images) => { - if (images === undefined) { - return undefined - } - images = (images ?? []).concat(nearbyImages) - const blacklisted = options.blacklist?.data - images = images?.filter( - (i) => - !blacklisted?.some((notAllowed) => - Mapillary.sameUrl(i.pictureUrl, notAllowed.url) - ) - ) - - 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] - ) - } - - protected prepareElement(info: P4CPicture): BaseUIElement { - const provider = AllImageProviders.byName(info.provider) - return new AttributedImage({ url: info.pictureUrl, provider }) - } - - private static asAttributedImage(info: P4CPicture): AttributedImage { - const provider = AllImageProviders.byName(info.provider) - return new AttributedImage({ url: info.thumbUrl, provider, date: new Date(info.date) }) - } - - protected asToggle(info: P4CPicture): ClickableToggle { - const imgNonSelected = NearbyImages.asAttributedImage(info) - const imageSelected = NearbyImages.asAttributedImage(info) - - const nonSelected = new Combine([imgNonSelected]).SetClass("relative block") - const hoveringCheckmark = new Combine([ - Svg.confirm_svg().SetClass("block w-24 h-24 -ml-12 -mt-12"), - ]).SetClass("absolute left-1/2 top-1/2 w-0") - const selected = new Combine([imageSelected, hoveringCheckmark]).SetClass("relative block") - - return new ClickableToggle(selected, nonSelected).SetClass("").ToggleOnClick() - } -} - -export class SelectOneNearbyImage extends NearbyImages implements InputElement { - private readonly value: UIEventSource - - constructor( - options: NearbyImageOptions & { value?: UIEventSource }, - state?: IndexedFeatureSource - ) { - super(options, state) - this.value = options.value ?? new UIEventSource(undefined) - } - - GetValue(): UIEventSource { - return this.value - } - - IsValid(t: P4CPicture): boolean { - return false - } - - protected prepareElement(info: P4CPicture): BaseUIElement { - const toggle = super.asToggle(info) - toggle.isEnabled.addCallback((enabled) => { - if (enabled) { - this.value.setData(info) - } else if (this.value.data === info) { - this.value.setData(undefined) - } - }) - - this.value.addCallback((inf) => { - if (inf !== info) { - toggle.isEnabled.setData(false) - } - }) - - return toggle - } -} diff --git a/src/UI/Popup/NearbyImagesCollapsed.svelte b/src/UI/Popup/NearbyImagesCollapsed.svelte new file mode 100644 index 000000000..ae1402672 --- /dev/null +++ b/src/UI/Popup/NearbyImagesCollapsed.svelte @@ -0,0 +1,32 @@ + + +{#if expanded} + +{:else} + +{/if} diff --git a/src/UI/SpecialVisualizations.ts b/src/UI/SpecialVisualizations.ts index 035841065..ef3771175 100644 --- a/src/UI/SpecialVisualizations.ts +++ b/src/UI/SpecialVisualizations.ts @@ -58,18 +58,6 @@ import LanguagePicker from "./LanguagePicker" import Link from "./Base/Link" import LayerConfig from "../Models/ThemeConfig/LayerConfig" import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig" -import NearbyImages, { - NearbyImageOptions, - P4CPicture, - SelectOneNearbyImage, -} from "./Popup/NearbyImages" -import { Tag } from "../Logic/Tags/Tag" -import ChangeTagAction from "../Logic/Osm/Actions/ChangeTagAction" -import { And } from "../Logic/Tags/And" -import { SaveButton } from "./Popup/SaveButton" -import Lazy from "./Base/Lazy" -import { CheckBox } from "./Input/Checkboxes" -import Slider from "./Input/Slider" import { OsmTags, WayId } from "../Models/OsmFeature" import MoveWizard from "./Popup/MoveWizard" import SplitRoadWizard from "./Popup/SplitRoadWizard" @@ -84,131 +72,25 @@ import { OpenJosm } from "./BigComponents/OpenJosm" import OpenIdEditor from "./BigComponents/OpenIdEditor.svelte" import FediverseValidator from "./InputElement/Validators/FediverseValidator" import SendEmail from "./Popup/SendEmail.svelte" +import NearbyImages from "./Popup/NearbyImages.svelte" +import NearbyImagesCollapsed from "./Popup/NearbyImagesCollapsed.svelte" class NearbyImageVis implements SpecialVisualization { // Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests - args: { name: string; defaultValue?: string; doc: string; required?: boolean }[] = [ - { - name: "mode", - defaultValue: "expandable", - doc: "Indicates how this component is initialized. Options are: \n\n- `open`: always show and load the pictures\n- `collapsable`: show the pictures, but a user can collapse them\n- `expandable`: shown by default; but a user can collapse them.", - }, - { - name: "mapillary", - defaultValue: "true", - doc: "If 'true', includes a link to mapillary on this location.", - }, - ] + args: { name: string; defaultValue?: string; doc: string; required?: boolean }[] = [] 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" constr( state: SpecialVisualizationState, - tagSource: UIEventSource>, + tags: UIEventSource>, args: string[], feature: Feature, layer: LayerConfig ): BaseUIElement { - const t = Translations.t.image.nearbyPictures - const mode: "open" | "expandable" | "collapsable" = args[0] const [lon, lat] = GeoOperations.centerpointCoordinates(feature) - const id: string = tagSource.data["id"] - const canBeEdited: boolean = !!id?.match("(node|way|relation)/-?[0-9]+") - const selectedImage = new UIEventSource(undefined) - - let saveButton: BaseUIElement = undefined - if (canBeEdited) { - const confirmText: BaseUIElement = new SubstitutedTranslation( - t.confirm, - tagSource, - state - ) - - const onSave = async () => { - console.log("Selected a picture...", selectedImage.data) - const osmTags = selectedImage.data.osmTags - const tags: Tag[] = [] - for (const key in osmTags) { - tags.push(new Tag(key, osmTags[key])) - } - await state?.changes?.applyAction( - new ChangeTagAction(id, new And(tags), tagSource.data, { - theme: state?.layout.id, - changeType: "link-image", - }) - ) - } - saveButton = new SaveButton(selectedImage, state, confirmText, t.noImageSelected) - .onClick(onSave) - .SetClass("flex justify-end") - } - - const nearby = new Lazy(() => { - const towardsCenter = new CheckBox(t.onlyTowards, false) - - const maxSearchRadius = 100 - const stepSize = 10 - const defaultValue = Math.floor(maxSearchRadius / (2 * stepSize)) * stepSize - const fromOsmPreferences = state?.osmConnection - ?.GetPreference("nearby-images-radius", "" + defaultValue) - .sync( - (s) => Number(s), - [], - (i) => "" + i - ) - const radiusValue = new UIEventSource(fromOsmPreferences.data) - radiusValue.addCallbackAndRunD((v) => fromOsmPreferences.setData(v)) - - const radius = new Slider(stepSize, maxSearchRadius, { - value: radiusValue, - step: 10, - }) - const alreadyInTheImage = AllImageProviders.LoadImagesFor(tagSource) - const options: NearbyImageOptions & { value } = { - lon, - lat, - searchRadius: maxSearchRadius, - shownRadius: radius.GetValue(), - value: selectedImage, - blacklist: alreadyInTheImage, - towardscenter: towardsCenter.GetValue(), - maxDaysOld: 365 * 3, - } - const slideshow = canBeEdited - ? new SelectOneNearbyImage(options, state.indexedFeatures) - : new NearbyImages(options, state.indexedFeatures) - const controls = new Combine([ - towardsCenter, - new Combine([ - new VariableUiElement( - radius.GetValue().map((radius) => t.withinRadius.Subs({ radius })) - ), - radius, - ]).SetClass("flex justify-between"), - ]).SetClass("flex flex-col") - return new Combine([ - slideshow, - controls, - saveButton, - new MapillaryLinkVis().constr(state, tagSource, [], feature).SetClass("mt-6"), - ]) - }) - - let withEdit: BaseUIElement = nearby - if (canBeEdited) { - withEdit = new Combine([t.hasMatchingPicture, nearby]).SetClass("flex flex-col") - } - - if (mode === "open") { - return withEdit - } - const toggleState = new UIEventSource(mode === "collapsable") - return new Toggle( - new Combine([new Title(t.title), withEdit]), - new Title(t.browseNearby).onClick(() => toggleState.setData(true)), - toggleState - ) + return new SvelteUIElement(NearbyImagesCollapsed, { tags, state, lon, lat, feature, layer }) } }