319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
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 { ElementStorage } from "../../Logic/ElementStorage"
|
|
import Lazy from "../Base/Lazy"
|
|
|
|
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 allElements: ElementStorage
|
|
|
|
constructor(state: { allElements: ElementStorage }) {
|
|
this.allElements = state.allElements
|
|
}
|
|
|
|
public fetchAround(loc: { lon: number; lat: number; searchRadius?: number }): P4CPicture[] {
|
|
const foundImages: P4CPicture[] = []
|
|
this.allElements.ContainingFeatures.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?: { allElements: ElementStorage }) {
|
|
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?: { allElements: ElementStorage }
|
|
) {
|
|
const P4C = require("../../vendor/P4C.min")
|
|
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?: { allElements: ElementStorage }
|
|
) {
|
|
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
|
|
}
|
|
}
|