forked from MapComplete/MapComplete
Refactoring: port image link functionality to Svelte
This commit is contained in:
parent
633c0e7d74
commit
48a7c45416
9 changed files with 407 additions and 441 deletions
|
@ -86,7 +86,7 @@ export class Mapillary extends ImageProvider {
|
||||||
|
|
||||||
public async DownloadAttribution(url: string): Promise<LicenseInfo> {
|
public async DownloadAttribution(url: string): Promise<LicenseInfo> {
|
||||||
const license = new LicenseInfo()
|
const license = new LicenseInfo()
|
||||||
license.artist = "Contributor name unavailable"
|
license.artist = undefined
|
||||||
license.license = "CC BY-SA 4.0"
|
license.license = "CC BY-SA 4.0"
|
||||||
// license.license = "Creative Commons Attribution-ShareAlike 4.0 International License";
|
// license.license = "Creative Commons Attribution-ShareAlike 4.0 International License";
|
||||||
license.attributionRequired = true
|
license.attributionRequired = true
|
||||||
|
|
|
@ -28,7 +28,7 @@ export default class ChangeTagAction extends OsmChangeAction {
|
||||||
currentTags: Record<string, string>,
|
currentTags: Record<string, string>,
|
||||||
meta: {
|
meta: {
|
||||||
theme: string
|
theme: string
|
||||||
changeType: "answer" | "soft-delete" | "add-image" | string
|
changeType: "answer" | "soft-delete" | "add-image" | "link-image" | string
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
super(elementId, true)
|
super(elementId, true)
|
||||||
|
|
32
src/Logic/Osm/Actions/LinkPicture.ts
Normal file
32
src/Logic/Osm/Actions/LinkPicture.ts
Normal file
|
@ -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<string, string>,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
222
src/Logic/Web/NearbyImagesSearch.ts
Normal file
222
src/Logic/Web/NearbyImagesSearch.ts
Normal file
|
@ -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<number>
|
||||||
|
towardscenter?: UIEventSource<boolean>
|
||||||
|
allowSpherical?: UIEventSource<boolean>
|
||||||
|
// Radius of what is shown. Useless to select a value > searchRadius; defaults to searchRadius
|
||||||
|
shownRadius?: UIEventSource<number>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<P4CPicture[]> = new UIEventSource<P4CPicture[]>([])
|
||||||
|
public readonly store: Store<P4CPicture[]> = 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<P4CPicture[]>(
|
||||||
|
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<string> = new Set<string>(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
|
||||||
|
}
|
||||||
|
}
|
73
src/UI/Popup/LinkableImage.svelte
Normal file
73
src/UI/Popup/LinkableImage.svelte
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
<script lang="ts">
|
||||||
|
|
||||||
|
import { Store } from "../../Logic/UIEventSource";
|
||||||
|
import type { OsmTags } from "../../Models/OsmFeature";
|
||||||
|
import type { SpecialVisualizationState } from "../SpecialVisualization";
|
||||||
|
import type { P4CPicture } from "../../Logic/Web/NearbyImagesSearch";
|
||||||
|
import ToSvelte from "../Base/ToSvelte.svelte";
|
||||||
|
import { AttributedImage } from "../Image/AttributedImage";
|
||||||
|
import AllImageProviders from "../../Logic/ImageProviders/AllImageProviders";
|
||||||
|
import LinkPicture from "../../Logic/Osm/Actions/LinkPicture";
|
||||||
|
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction";
|
||||||
|
import { Tag } from "../../Logic/Tags/Tag";
|
||||||
|
import { GeoOperations } from "../../Logic/GeoOperations";
|
||||||
|
import type { Feature } from "geojson";
|
||||||
|
import Translations from "../i18n/Translations";
|
||||||
|
import SpecialTranslation from "./TagRendering/SpecialTranslation.svelte";
|
||||||
|
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
|
||||||
|
|
||||||
|
export let tags: Store<OsmTags>;
|
||||||
|
export let lon: number;
|
||||||
|
export let lat: number;
|
||||||
|
export let state: SpecialVisualizationState;
|
||||||
|
export let image: P4CPicture;
|
||||||
|
export let feature: Feature;
|
||||||
|
export let layer: LayerConfig;
|
||||||
|
|
||||||
|
export let linkable = true;
|
||||||
|
let isLinked = false;
|
||||||
|
|
||||||
|
const t = Translations.t.image.nearby;
|
||||||
|
const c = [lon, lat];
|
||||||
|
let attributedImage = new AttributedImage({
|
||||||
|
url: image.thumbUrl ?? image.pictureUrl,
|
||||||
|
provider: AllImageProviders.byName(image.provider),
|
||||||
|
date: new Date(image.date)
|
||||||
|
});
|
||||||
|
let distance = Math.round(GeoOperations.distanceBetween([image.coordinates.lng, image.coordinates.lat], c));
|
||||||
|
$: {
|
||||||
|
const currentTags = tags.data;
|
||||||
|
const key = Object.keys(image.osmTags)[0];
|
||||||
|
const url = image.osmTags[key];
|
||||||
|
if (isLinked) {
|
||||||
|
const action = new LinkPicture(
|
||||||
|
currentTags.id,
|
||||||
|
key,
|
||||||
|
url,
|
||||||
|
currentTags,
|
||||||
|
{
|
||||||
|
theme: state.layout.id,
|
||||||
|
changeType: "link-image"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
state.changes.applyAction(action);
|
||||||
|
} else {
|
||||||
|
for (const k in currentTags) {
|
||||||
|
const v = currentTags[k];
|
||||||
|
if (v === url) {
|
||||||
|
const action = new ChangeTagAction(currentTags.id, new Tag(k, ""), currentTags, { theme: state.layout.id, changeType: "remove-image" });
|
||||||
|
state.changes.applyAction(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<div class="flex flex-col w-fit shrink-0">
|
||||||
|
<ToSvelte construct={attributedImage.SetClass("h-48 w-fit")} />
|
||||||
|
{#if linkable}
|
||||||
|
<label>
|
||||||
|
<input bind:checked={isLinked} type="checkbox">
|
||||||
|
<SpecialTranslation t={t.link} {tags} {state} {layer} {feature} />
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
41
src/UI/Popup/NearbyImages.svelte
Normal file
41
src/UI/Popup/NearbyImages.svelte
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
<script lang="ts">/**
|
||||||
|
* Show nearby images which can be clicked
|
||||||
|
*/
|
||||||
|
import type { OsmTags } from "../../Models/OsmFeature";
|
||||||
|
import { 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";
|
||||||
|
import Loading from "../Base/Loading.svelte";
|
||||||
|
import AllImageProviders from "../../Logic/ImageProviders/AllImageProviders";
|
||||||
|
|
||||||
|
export let tags: Store<OsmTags>;
|
||||||
|
export let state: SpecialVisualizationState;
|
||||||
|
export let lon: number;
|
||||||
|
export let lat: number;
|
||||||
|
export let feature: Feature;
|
||||||
|
|
||||||
|
export let linkable: boolean = true;
|
||||||
|
export let layer: LayerConfig;
|
||||||
|
|
||||||
|
let imagesProvider = new NearbyImagesSearch({
|
||||||
|
lon, lat, allowSpherical: new UIEventSource<boolean>(false),
|
||||||
|
blacklist: AllImageProviders.LoadImagesFor(tags)
|
||||||
|
}, state.indexedFeatures);
|
||||||
|
|
||||||
|
let images: Store<P4CPicture[]> = imagesProvider.store.map(images => images.slice(0, 20));
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $images.length === 0}
|
||||||
|
<Loading />
|
||||||
|
{:else}
|
||||||
|
<div class="py-2 interactive overflow-x-auto w-full flex space-x-1">
|
||||||
|
{#each $images as image (image.pictureUrl)}
|
||||||
|
<LinkableImage {tags} {image} {state} {lon} {lat} {feature} {layer} {linkable} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
|
@ -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<number>
|
|
||||||
towardscenter?: UIEventSource<boolean>
|
|
||||||
allowSpherical?: UIEventSource<boolean>
|
|
||||||
// Radius of what is shown. Useless to select a value > searchRadius; defaults to searchRadius
|
|
||||||
shownRadius?: UIEventSource<number>
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string>()
|
|
||||||
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<P4CPicture[]>(
|
|
||||||
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<P4CPicture> {
|
|
||||||
private readonly value: UIEventSource<P4CPicture>
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
options: NearbyImageOptions & { value?: UIEventSource<P4CPicture> },
|
|
||||||
state?: IndexedFeatureSource
|
|
||||||
) {
|
|
||||||
super(options, state)
|
|
||||||
this.value = options.value ?? new UIEventSource<P4CPicture>(undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
GetValue(): UIEventSource<P4CPicture> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
32
src/UI/Popup/NearbyImagesCollapsed.svelte
Normal file
32
src/UI/Popup/NearbyImagesCollapsed.svelte
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Store } from "../../Logic/UIEventSource";
|
||||||
|
import type { OsmTags } from "../../Models/OsmFeature";
|
||||||
|
import type { SpecialVisualizationState } from "../SpecialVisualization";
|
||||||
|
import type { Feature } from "geojson";
|
||||||
|
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
|
||||||
|
import Translations from "../i18n/Translations";
|
||||||
|
import Tr from "../Base/Tr.svelte";
|
||||||
|
import NearbyImages from "./NearbyImages.svelte";
|
||||||
|
import Svg from "../../Svg";
|
||||||
|
import ToSvelte from "../Base/ToSvelte.svelte";
|
||||||
|
|
||||||
|
export let tags: Store<OsmTags>;
|
||||||
|
export let state: SpecialVisualizationState;
|
||||||
|
export let lon: number;
|
||||||
|
export let lat: number;
|
||||||
|
export let feature: Feature;
|
||||||
|
|
||||||
|
export let linkable: boolean = true;
|
||||||
|
export let layer: LayerConfig;
|
||||||
|
const t = Translations.t.image.nearby;
|
||||||
|
|
||||||
|
let expanded = false;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if expanded}
|
||||||
|
<NearbyImages {tags} {state} {lon} {lat} {feature} {linkable}/>
|
||||||
|
{:else}
|
||||||
|
<button class="w-full flex items-center" on:click={() => { expanded = true; }}>
|
||||||
|
<ToSvelte construct={ Svg.camera_plus_svg().SetClass("block w-8 h-8 p-1 mr-2 ")}/>
|
||||||
|
<Tr t={t.seeNearby}/></button>
|
||||||
|
{/if}
|
|
@ -58,18 +58,6 @@ import LanguagePicker from "./LanguagePicker"
|
||||||
import Link from "./Base/Link"
|
import Link from "./Base/Link"
|
||||||
import LayerConfig from "../Models/ThemeConfig/LayerConfig"
|
import LayerConfig from "../Models/ThemeConfig/LayerConfig"
|
||||||
import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig"
|
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 { OsmTags, WayId } from "../Models/OsmFeature"
|
||||||
import MoveWizard from "./Popup/MoveWizard"
|
import MoveWizard from "./Popup/MoveWizard"
|
||||||
import SplitRoadWizard from "./Popup/SplitRoadWizard"
|
import SplitRoadWizard from "./Popup/SplitRoadWizard"
|
||||||
|
@ -84,131 +72,25 @@ import { OpenJosm } from "./BigComponents/OpenJosm"
|
||||||
import OpenIdEditor from "./BigComponents/OpenIdEditor.svelte"
|
import OpenIdEditor from "./BigComponents/OpenIdEditor.svelte"
|
||||||
import FediverseValidator from "./InputElement/Validators/FediverseValidator"
|
import FediverseValidator from "./InputElement/Validators/FediverseValidator"
|
||||||
import SendEmail from "./Popup/SendEmail.svelte"
|
import SendEmail from "./Popup/SendEmail.svelte"
|
||||||
|
import NearbyImages from "./Popup/NearbyImages.svelte"
|
||||||
|
import NearbyImagesCollapsed from "./Popup/NearbyImagesCollapsed.svelte"
|
||||||
|
|
||||||
class NearbyImageVis implements SpecialVisualization {
|
class NearbyImageVis implements SpecialVisualization {
|
||||||
// Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests
|
// Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests
|
||||||
args: { name: string; defaultValue?: string; doc: string; required?: boolean }[] = [
|
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.",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
docs =
|
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"
|
"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"
|
funcName = "nearby_images"
|
||||||
|
|
||||||
constr(
|
constr(
|
||||||
state: SpecialVisualizationState,
|
state: SpecialVisualizationState,
|
||||||
tagSource: UIEventSource<Record<string, string>>,
|
tags: UIEventSource<Record<string, string>>,
|
||||||
args: string[],
|
args: string[],
|
||||||
feature: Feature,
|
feature: Feature,
|
||||||
layer: LayerConfig
|
layer: LayerConfig
|
||||||
): BaseUIElement {
|
): BaseUIElement {
|
||||||
const t = Translations.t.image.nearbyPictures
|
|
||||||
const mode: "open" | "expandable" | "collapsable" = <any>args[0]
|
|
||||||
const [lon, lat] = GeoOperations.centerpointCoordinates(feature)
|
const [lon, lat] = GeoOperations.centerpointCoordinates(feature)
|
||||||
const id: string = tagSource.data["id"]
|
return new SvelteUIElement(NearbyImagesCollapsed, { tags, state, lon, lat, feature, layer })
|
||||||
const canBeEdited: boolean = !!id?.match("(node|way|relation)/-?[0-9]+")
|
|
||||||
const selectedImage = new UIEventSource<P4CPicture>(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<boolean>(mode === "collapsable")
|
|
||||||
return new Toggle(
|
|
||||||
new Combine([new Title(t.title), withEdit]),
|
|
||||||
new Title(t.browseNearby).onClick(() => toggleState.setData(true)),
|
|
||||||
toggleState
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue