Merge develop

This commit is contained in:
Pieter Vander Vennet 2023-10-01 13:13:07 +02:00
commit 55c6442cac
388 changed files with 16178 additions and 17860 deletions

View file

@ -0,0 +1,73 @@
import { Store, UIEventSource } from "../UIEventSource"
import { RasterLayerPolygon } from "../../Models/RasterLayers"
/**
* Selects the appropriate raster layer as background for the given query parameter, theme setting, user preference or default value.
*
* It the requested layer is not available, a layer of the same type will be selected.
*/
export class PreferredRasterLayerSelector {
private readonly _rasterLayerSetting: UIEventSource<RasterLayerPolygon>
private readonly _availableLayers: Store<RasterLayerPolygon[]>
private readonly _preferredBackgroundLayer: UIEventSource<
string | "photo" | "map" | "osmbasedmap" | undefined
>
private readonly _queryParameter: UIEventSource<string>
constructor(
rasterLayerSetting: UIEventSource<RasterLayerPolygon>,
availableLayers: Store<RasterLayerPolygon[]>,
queryParameter: UIEventSource<string>,
preferredBackgroundLayer: UIEventSource<
string | "photo" | "map" | "osmbasedmap" | undefined
>
) {
this._rasterLayerSetting = rasterLayerSetting
this._availableLayers = availableLayers
this._queryParameter = queryParameter
this._preferredBackgroundLayer = preferredBackgroundLayer
const self = this
this._rasterLayerSetting.addCallbackD((layer) => {
if (layer.properties.id !== this._queryParameter.data) {
this._queryParameter.setData(undefined)
return true
}
})
this._queryParameter.addCallbackAndRunD((_) => {
const isApplied = self.updateLayer()
if (!isApplied) {
// A different layer was set as background
// We remove this queryParameter instead
self._queryParameter.setData(undefined)
return true // Unregister
}
})
this._preferredBackgroundLayer.addCallbackD((_) => self.updateLayer())
this._availableLayers.addCallbackD((_) => self.updateLayer())
}
/**
* Returns 'true' if the target layer is set or is the current layer
* @private
*/
private updateLayer() {
// What is the ID of the layer we have to (try to) load?
const targetLayerId = this._queryParameter.data ?? this._preferredBackgroundLayer.data
const available = this._availableLayers.data
const isCategory =
targetLayerId === "photo" || targetLayerId === "osmbasedmap" || targetLayerId === "map"
const foundLayer = isCategory
? available.find((l) => l.properties.category === targetLayerId)
: available.find((l) => l.properties.id === targetLayerId)
if (foundLayer) {
this._rasterLayerSetting.setData(foundLayer)
return true
}
// The current layer is not in view
}
}

View file

@ -454,12 +454,16 @@ export class ExtraFunctions {
"To enable this feature, add a field `calculatedTags` in the layer object, e.g.:",
"````",
'"calculatedTags": [',
' "_someKey=javascript-expression",',
' "_someKey=javascript-expression (lazy execution)",',
' "_some_other_key:=javascript expression (strict execution)',
' "name=feat.properties.name ?? feat.properties.ref ?? feat.properties.operator",',
" \"_distanceCloserThen3Km=distanceTo(feat)( some_lon, some_lat) < 3 ? 'yes' : 'no'\" ",
" ]",
"````",
"",
"By using `:=` as separator, the attribute will be calculated as soone as the data is loaded (strict evaluation)",
"The default behaviour, using `=` as separator, is lazy loading",
"",
"The above code will be executed for every feature in the layer. The feature is accessible as `feat` and is an amended geojson object:",
new List([

View file

@ -1,52 +1,19 @@
import { FeatureSource } from "../FeatureSource"
import { UIEventSource } from "../../UIEventSource"
import { OsmTags } from "../../../Models/OsmFeature"
/**
* Constructs a UIEventStore for the properties of every Feature, indexed by id
*/
export default class FeaturePropertiesStore {
private readonly _elements = new Map<string, UIEventSource<Record<string, string>>>()
public readonly aliases = new Map<string, string>()
constructor(...sources: FeatureSource[]) {
for (const source of sources) {
this.trackFeatureSource(source)
}
}
public getStore(id: string): UIEventSource<Record<string, string>> {
return this._elements.get(id)
}
public trackFeatureSource(source: FeatureSource) {
const self = this
source.features.addCallbackAndRunD((features) => {
for (const feature of features) {
const id = feature.properties.id
if (id === undefined) {
console.trace("Error: feature without ID:", feature)
throw "Error: feature without ID"
}
const source = self._elements.get(id)
if (source === undefined) {
self._elements.set(id, new UIEventSource<any>(feature.properties))
continue
}
if (source.data === feature.properties) {
continue
}
// Update the tags in the old store and link them
const changeMade = FeaturePropertiesStore.mergeTags(source.data, feature.properties)
feature.properties = source.data
if (changeMade) {
source.ping()
}
}
})
}
/**
* Overwrites the tags of the old properties object, returns true if a change was made.
* Metatags are overriden if they are in the new properties, but not removed
@ -67,7 +34,7 @@ export default class FeaturePropertiesStore {
}
if (newProperties[oldPropertiesKey] === undefined) {
changeMade = true
delete oldProperties[oldPropertiesKey]
// delete oldProperties[oldPropertiesKey]
}
}
@ -83,7 +50,48 @@ export default class FeaturePropertiesStore {
return changeMade
}
// noinspection JSUnusedGlobalSymbols
public getStore(id: string): UIEventSource<Record<string, string>> {
const store = this._elements.get(id)
if (store === undefined) {
console.error("PANIC: no store for", id)
}
return store
}
public trackFeature(feature: { properties: OsmTags }) {
const id = feature.properties.id
if (id === undefined) {
console.trace("Error: feature without ID:", feature)
throw "Error: feature without ID"
}
const source = this._elements.get(id)
if (source === undefined) {
this._elements.set(id, new UIEventSource<any>(feature.properties))
return
}
if (source.data === feature.properties) {
return
}
// Update the tags in the old store and link them
const changeMade = FeaturePropertiesStore.mergeTags(source.data, feature.properties)
feature.properties = <any>source.data
if (changeMade) {
source.ping()
}
}
public trackFeatureSource(source: FeatureSource) {
const self = this
source.features.addCallbackAndRunD((features) => {
for (const feature of features) {
self.trackFeature(<any>feature)
}
})
}
public addAlias(oldId: string, newId: string): void {
if (newId === undefined) {
// We removed the node/way/relation with type 'type' and id 'oldId' on openstreetmap!
@ -103,6 +111,7 @@ export default class FeaturePropertiesStore {
}
element.data.id = newId
this._elements.set(newId, element)
this.aliases.set(newId, oldId)
element.ping()
}

View file

@ -82,7 +82,7 @@ export default class FeatureSourceMerger implements IndexedFeatureSource {
}
const newList = []
all.forEach((value, key) => {
all.forEach((value) => {
newList.push(value)
})
this.features.setData(newList)

View file

@ -4,8 +4,9 @@ import { IndexedFeatureSource, WritableFeatureSource } from "../FeatureSource"
import { UIEventSource } from "../../UIEventSource"
import { ChangeDescription } from "../../Osm/Actions/ChangeDescription"
import { OsmId, OsmTags } from "../../../Models/OsmFeature"
import { Feature } from "geojson"
import OsmObjectDownloader from "../../Osm/OsmObjectDownloader"
import { Feature, Point } from "geojson"
import { TagUtils } from "../../Tags/TagUtils"
import FeaturePropertiesStore from "../Actors/FeaturePropertiesStore"
export class NewGeometryFromChangesFeatureSource implements WritableFeatureSource {
// This class name truly puts the 'Java' into 'Javascript'
@ -15,115 +16,145 @@ export class NewGeometryFromChangesFeatureSource implements WritableFeatureSourc
*
* These elements are probably created by the 'SimpleAddUi' which generates a new point, but the import functionality might create a line or polygon too.
* Other sources of new points are e.g. imports from nodes
*
* Alternatively, an already existing point might suddenly match the layer, especially if a point in a wall is reused
*
* Note that the FeaturePropertiesStore will track a featuresource, such as this one
*/
public readonly features: UIEventSource<Feature[]> = new UIEventSource<Feature[]>([])
private readonly _seenChanges: Set<ChangeDescription>
private readonly _features: Feature[]
private readonly _backend: string
private readonly _allElementStorage: IndexedFeatureSource
private _featureProperties: FeaturePropertiesStore
constructor(changes: Changes, allElementStorage: IndexedFeatureSource, backendUrl: string) {
const seenChanges = new Set<ChangeDescription>()
const features = this.features.data
constructor(
changes: Changes,
allElementStorage: IndexedFeatureSource,
featureProperties: FeaturePropertiesStore
) {
this._allElementStorage = allElementStorage
this._featureProperties = featureProperties
this._seenChanges = new Set<ChangeDescription>()
this._features = this.features.data
this._backend = changes.backend
const self = this
const backend = changes.backend
changes.pendingChanges.addCallbackAndRunD((changes) => {
if (changes.length === 0) {
return
changes.pendingChanges.addCallbackAndRunD((changes) => self.handleChanges(changes))
}
private addNewFeature(feature: Feature) {
const features = this._features
feature.id = feature.properties.id
features.push(feature)
}
/**
* Handles a single pending change
* @returns true if something changed
* @param change
* @private
*/
private handleChange(change: ChangeDescription): boolean {
const backend = this._backend
const allElementStorage = this._allElementStorage
console.log("Handling pending change")
if (change.id > 0) {
// This is an already existing object
// In _most_ of the cases, this means that this _isn't_ a new object
// However, when a point is snapped to an already existing point, we have to create a representation for this point!
// For this, we introspect the change
if (allElementStorage.featuresById.data.has(change.type + "/" + change.id)) {
// The current point already exists, we don't have to do anything here
return false
}
console.debug("Detected a reused point, for", change)
// The 'allElementsStore' does _not_ have this point yet, so we have to create it
// However, we already create a store for it
const { lon, lat } = <{ lon: number; lat: number }>change.changes
const feature = <Feature<Point, OsmTags>>{
type: "Feature",
properties: {
id: <OsmId>change.type + "/" + change.id,
...TagUtils.changeAsProperties(change.tags),
},
geometry: {
type: "Point",
coordinates: [lon, lat],
},
}
this._featureProperties.trackFeature(feature)
this.addNewFeature(feature)
return true
} else if (change.changes === undefined) {
// The geometry is not described - not a new point or geometry change, but probably a tagchange to a newly created point
// Not something that should be handled here
return false
}
try {
const tags: OsmTags & { id: OsmId & string } = {
id: <OsmId & string>(change.type + "/" + change.id),
}
for (const kv of change.tags) {
tags[kv.k] = kv.v
}
let somethingChanged = false
tags["_backend"] = this._backend
function add(feature) {
feature.id = feature.properties.id
features.push(feature)
somethingChanged = true
switch (change.type) {
case "node":
const n = new OsmNode(change.id)
n.tags = tags
n.lat = change.changes["lat"]
n.lon = change.changes["lon"]
const geojson = n.asGeoJson()
this.addNewFeature(geojson)
break
case "way":
const w = new OsmWay(change.id)
w.tags = tags
w.nodes = change.changes["nodes"]
w.coordinates = change.changes["coordinates"].map(([lon, lat]) => [lat, lon])
this.addNewFeature(w.asGeoJson())
break
case "relation":
const r = new OsmRelation(change.id)
r.tags = tags
r.members = change.changes["members"]
this.addNewFeature(r.asGeoJson())
break
}
return true
} catch (e) {
console.error("Could not generate a new geometry to render on screen for:", e)
}
}
private handleChanges(changes: ChangeDescription[]) {
const seenChanges = this._seenChanges
if (changes.length === 0) {
return
}
let somethingChanged = false
for (const change of changes) {
if (seenChanges.has(change)) {
// Already handled
continue
}
seenChanges.add(change)
if (change.tags === undefined) {
// If tags is undefined, this is probably a new point that is part of a split road
continue
}
for (const change of changes) {
if (seenChanges.has(change)) {
// Already handled
continue
}
seenChanges.add(change)
if (change.tags === undefined) {
// If tags is undefined, this is probably a new point that is part of a split road
continue
}
console.log("Handling pending change")
if (change.id > 0) {
// This is an already existing object
// In _most_ of the cases, this means that this _isn't_ a new object
// However, when a point is snapped to an already existing point, we have to create a representation for this point!
// For this, we introspect the change
if (allElementStorage.featuresById.data.has(change.type + "/" + change.id)) {
// The current point already exists, we don't have to do anything here
continue
}
console.debug("Detected a reused point")
// The 'allElementsStore' does _not_ have this point yet, so we have to create it
new OsmObjectDownloader(backend)
.DownloadObjectAsync(change.type + "/" + change.id)
.then((feat) => {
console.log("Got the reused point:", feat)
if (feat === "deleted") {
throw "Panic: snapping to a point, but this point has been deleted in the meantime"
}
for (const kv of change.tags) {
feat.tags[kv.k] = kv.v
}
const geojson = feat.asGeoJson()
self.features.data.push(geojson)
self.features.ping()
})
continue
} else if (change.changes === undefined) {
// The geometry is not described - not a new point or geometry change, but probably a tagchange to a newly created point
// Not something that should be handled here
continue
}
try {
const tags: OsmTags & { id: OsmId & string } = {
id: <OsmId & string>(change.type + "/" + change.id),
}
for (const kv of change.tags) {
tags[kv.k] = kv.v
}
tags["_backend"] = backendUrl
switch (change.type) {
case "node":
const n = new OsmNode(change.id)
n.tags = tags
n.lat = change.changes["lat"]
n.lon = change.changes["lon"]
const geojson = n.asGeoJson()
add(geojson)
break
case "way":
const w = new OsmWay(change.id)
w.tags = tags
w.nodes = change.changes["nodes"]
w.coordinates = change.changes["coordinates"].map(([lon, lat]) => [
lat,
lon,
])
add(w.asGeoJson())
break
case "relation":
const r = new OsmRelation(change.id)
r.tags = tags
r.members = change.changes["members"]
add(r.asGeoJson())
break
}
} catch (e) {
console.error("Could not generate a new geometry to render on screen for:", e)
}
}
if (somethingChanged) {
self.features.ping()
}
})
somethingChanged ||= this.handleChange(change)
}
if (somethingChanged) {
this.features.ping()
}
}
}

View file

@ -20,7 +20,7 @@ export default class OsmFeatureSource extends FeatureSourceMerger {
private options: {
bounds: Store<BBox>
readonly allowedFeatures: TagsFilter
backend?: "https://openstreetmap.org/" | string
backend?: "https://api.openstreetmap.org/" | string
/**
* If given: this featureSwitch will not update if the store contains 'false'
*/
@ -41,7 +41,7 @@ export default class OsmFeatureSource extends FeatureSourceMerger {
constructor(options: {
bounds: Store<BBox>
readonly allowedFeatures: TagsFilter
backend?: "https://openstreetmap.org/" | string
backend?: "https://api.openstreetmap.org/" | string
/**
* If given: this featureSwitch will not update if the store contains 'false'
*/
@ -54,7 +54,7 @@ export default class OsmFeatureSource extends FeatureSourceMerger {
this._bounds = options.bounds
this.allowedTags = options.allowedFeatures
this.isActive = options.isActive ?? new ImmutableStore(true)
this._backend = options.backend ?? "https://www.openstreetmap.org"
this._backend = options.backend ?? "https://api.openstreetmap.org"
this._bounds.addCallbackAndRunD((bbox) => this.loadData(bbox))
this._patchRelations = options?.patchRelations ?? true
}

View file

@ -1,7 +1,6 @@
import { OsmNode, OsmObject, OsmWay } from "../../Osm/OsmObject"
import { UIEventSource } from "../../UIEventSource"
import { BBox } from "../../BBox"
import StaticFeatureSource from "../Sources/StaticFeatureSource"
import { Tiles } from "../../../Models/TileRange"
export default class FullNodeDatabaseSource {
@ -48,11 +47,7 @@ export default class FullNodeDatabaseSource {
src.ping()
}
}
const asGeojsonFeatures = Array.from(nodesById.values()).map((osmNode) =>
osmNode.asGeoJson()
)
const featureSource = new StaticFeatureSource(asGeojsonFeatures)
const tileId = Tiles.tile_index(z, x, y)
this.loadedTiles.set(tileId, nodesById)
}

View file

@ -771,7 +771,6 @@ export class GeoOperations {
const splitup = turf.lineSplit(<Feature<LineString>>toSplit, boundary)
const kept = []
for (const f of splitup.features) {
const ls = <Feature<LineString>>f
if (!GeoOperations.inside(GeoOperations.centerpointCoordinates(f), boundary)) {
continue
}

View file

@ -0,0 +1,159 @@
import { ImageUploader } from "./ImageUploader"
import LinkImageAction from "../Osm/Actions/LinkImageAction"
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
import { OsmId, OsmTags } from "../../Models/OsmFeature"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { Store, UIEventSource } from "../UIEventSource"
import { OsmConnection } from "../Osm/OsmConnection"
import { Changes } from "../Osm/Changes"
import Translations from "../../UI/i18n/Translations"
import NoteCommentElement from "../../UI/Popup/NoteCommentElement"
/**
* The ImageUploadManager has a
*/
export class ImageUploadManager {
private readonly _uploader: ImageUploader
private readonly _featureProperties: FeaturePropertiesStore
private readonly _layout: LayoutConfig
private readonly _uploadStarted: Map<string, UIEventSource<number>> = new Map()
private readonly _uploadFinished: Map<string, UIEventSource<number>> = new Map()
private readonly _uploadFailed: Map<string, UIEventSource<number>> = new Map()
private readonly _uploadRetried: Map<string, UIEventSource<number>> = new Map()
private readonly _uploadRetriedSuccess: Map<string, UIEventSource<number>> = new Map()
private readonly _osmConnection: OsmConnection
private readonly _changes: Changes
constructor(
layout: LayoutConfig,
uploader: ImageUploader,
featureProperties: FeaturePropertiesStore,
osmConnection: OsmConnection,
changes: Changes
) {
this._uploader = uploader
this._featureProperties = featureProperties
this._layout = layout
this._osmConnection = osmConnection
this._changes = changes
}
/**
* Gets various counters.
* Note that counters can only increase
* If a retry was a success, both 'retrySuccess' _and_ 'uploadFinished' will be increased
* @param featureId: the id of the feature you want information for. '*' has a global counter
*/
public getCountsFor(featureId: string | "*"): {
retried: Store<number>
uploadStarted: Store<number>
retrySuccess: Store<number>
failed: Store<number>
uploadFinished: Store<number>
} {
return {
uploadStarted: this.getCounterFor(this._uploadStarted, featureId),
uploadFinished: this.getCounterFor(this._uploadFinished, featureId),
retried: this.getCounterFor(this._uploadRetried, featureId),
failed: this.getCounterFor(this._uploadFailed, featureId),
retrySuccess: this.getCounterFor(this._uploadRetriedSuccess, featureId),
}
}
/**
* Uploads the given image, applies the correct title and license for the known user.
* Will then add this image to the OSM-feature or the OSM-note
*/
public async uploadImageAndApply(file: File, tagsStore: UIEventSource<OsmTags>): Promise<void> {
const sizeInBytes = file.size
const tags = tagsStore.data
const featureId = <OsmId>tags.id
const self = this
if (sizeInBytes > this._uploader.maxFileSizeInMegabytes * 1000000) {
this.increaseCountFor(this._uploadStarted, featureId)
this.increaseCountFor(this._uploadFailed, featureId)
throw Translations.t.image.toBig.Subs({
actual_size: Math.floor(sizeInBytes / 1000000) + "MB",
max_size: self._uploader.maxFileSizeInMegabytes + "MB",
}).txt
}
const licenseStore = this._osmConnection?.GetPreference("pictures-license", "CC0")
const license = licenseStore?.data ?? "CC0"
const matchingLayer = this._layout?.getMatchingLayer(tags)
const title =
matchingLayer?.title?.GetRenderValue(tags)?.Subs(tags)?.textFor("en") ??
tags.name ??
"https//osm.org/" + tags.id
const description = [
"author:" + this._osmConnection.userDetails.data.name,
"license:" + license,
"osmid:" + tags.id,
].join("\n")
console.log("Upload done, creating ")
const action = await this.uploadImageWithLicense(featureId, title, description, file)
if (!isNaN(Number(featureId))) {
// THis is a map note
const url = action._url
await this._osmConnection.addCommentToNote(featureId, url)
NoteCommentElement.addCommentTo(url, <UIEventSource<any>>tagsStore, {
osmConnection: this._osmConnection,
})
return
}
await this._changes.applyAction(action)
}
private async uploadImageWithLicense(
featureId: OsmId,
title: string,
description: string,
blob: File
): Promise<LinkImageAction> {
this.increaseCountFor(this._uploadStarted, featureId)
const properties = this._featureProperties.getStore(featureId)
let key: string
let value: string
try {
;({ key, value } = await this._uploader.uploadImage(title, description, blob))
} catch (e) {
this.increaseCountFor(this._uploadRetried, featureId)
console.error("Could not upload image, trying again:", e)
try {
;({ key, value } = await this._uploader.uploadImage(title, description, blob))
this.increaseCountFor(this._uploadRetriedSuccess, featureId)
} catch (e) {
console.error("Could again not upload image due to", e)
this.increaseCountFor(this._uploadFailed, featureId)
}
}
console.log("Uploading done, creating action for", featureId)
const action = new LinkImageAction(featureId, key, value, properties, {
theme: this._layout.id,
changeType: "add-image",
})
this.increaseCountFor(this._uploadFinished, featureId)
return action
}
private getCounterFor(collection: Map<string, UIEventSource<number>>, key: string | "*") {
if (this._featureProperties.aliases.has(key)) {
key = this._featureProperties.aliases.get(key)
}
if (!collection.has(key)) {
collection.set(key, new UIEventSource<number>(0))
}
return collection.get(key)
}
private increaseCountFor(collection: Map<string, UIEventSource<number>>, key: string | "*") {
const counter = this.getCounterFor(collection, key)
counter.setData(counter.data + 1)
const global = this.getCounterFor(collection, "*")
global.setData(counter.data + 1)
}
}

View file

@ -0,0 +1,15 @@
export interface ImageUploader {
maxFileSizeInMegabytes?: number
/**
* Uploads the 'blob' as image, with some metadata.
* Returns the URL to be linked + the appropriate key to add this to OSM
* @param title
* @param description
* @param blob
*/
uploadImage(
title: string,
description: string,
blob: File
): Promise<{ key: string; value: string }>
}

View file

@ -3,58 +3,28 @@ import BaseUIElement from "../../UI/BaseUIElement"
import { Utils } from "../../Utils"
import Constants from "../../Models/Constants"
import { LicenseInfo } from "./LicenseInfo"
import { ImageUploader } from "./ImageUploader"
export class Imgur extends ImageProvider {
export class Imgur extends ImageProvider implements ImageUploader {
public static readonly defaultValuePrefix = ["https://i.imgur.com"]
public static readonly singleton = new Imgur()
public readonly defaultKeyPrefixes: string[] = ["image"]
public readonly maxFileSizeInMegabytes = 10
private constructor() {
super()
}
static uploadMultiple(
/**
* Uploads an image, returns the URL where to find the image
* @param title
* @param description
* @param blob
*/
public async uploadImage(
title: string,
description: string,
blobs: FileList,
handleSuccessfullUpload: (imageURL: string) => Promise<void>,
allDone: () => void,
onFail: (reason: string) => void,
offset: number = 0
) {
if (blobs.length == offset) {
allDone()
return
}
const blob = blobs.item(offset)
const self = this
this.uploadImage(
title,
description,
blob,
async (imageUrl) => {
await handleSuccessfullUpload(imageUrl)
self.uploadMultiple(
title,
description,
blobs,
handleSuccessfullUpload,
allDone,
onFail,
offset + 1
)
},
onFail
)
}
static uploadImage(
title: string,
description: string,
blob: File,
handleSuccessfullUpload: (imageURL: string) => Promise<void>,
onFail: (reason: string) => void
) {
blob: File
): Promise<{ key: string; value: string }> {
const apiUrl = "https://api.imgur.com/3/image"
const apiKey = Constants.ImgurApiKey
@ -74,17 +44,9 @@ export class Imgur extends ImageProvider {
}
// Response contains stringified JSON
// Image URL available at response.data.link
fetch(apiUrl, settings)
.then(async function (response) {
const content = await response.json()
await handleSuccessfullUpload(content.data.link)
})
.catch((reason) => {
console.log("Uploading to IMGUR failed", reason)
// @ts-ignore
onFail(reason)
})
const response = await fetch(apiUrl, settings)
const content = await response.json()
return { key: "image", value: content.data.link }
}
SourceIcon(): BaseUIElement {

View file

@ -1,43 +0,0 @@
import { UIEventSource } from "../UIEventSource"
import { Imgur } from "./Imgur"
export default class ImgurUploader {
public readonly queue: UIEventSource<string[]> = new UIEventSource<string[]>([])
public readonly failed: UIEventSource<string[]> = new UIEventSource<string[]>([])
public readonly success: UIEventSource<string[]> = new UIEventSource<string[]>([])
public maxFileSizeInMegabytes = 10
private readonly _handleSuccessUrl: (string) => Promise<void>
constructor(handleSuccessUrl: (string) => Promise<void>) {
this._handleSuccessUrl = handleSuccessUrl
}
public uploadMany(title: string, description: string, files: FileList): void {
for (let i = 0; i < files.length; i++) {
this.queue.data.push(files.item(i).name)
}
this.queue.ping()
const self = this
this.queue.setData([...self.queue.data])
Imgur.uploadMultiple(
title,
description,
files,
async function (url) {
console.log("File saved at", url)
self.success.data.push(url)
self.success.ping()
await self._handleSuccessUrl(url)
},
function () {
console.log("All uploads completed")
},
function (failReason) {
console.log("Upload failed due to ", failReason)
self.failed.setData([...self.failed.data, failReason])
}
)
}
}

View file

@ -9,7 +9,6 @@ import { IndexedFeatureSource } from "./FeatureSource/FeatureSource"
import OsmObjectDownloader from "./Osm/OsmObjectDownloader"
import { Utils } from "../Utils"
import { Store, UIEventSource } from "./UIEventSource"
import { SpecialVisualizationState } from "../UI/SpecialVisualization"
/**
* Metatagging adds various tags to the elements, e.g. lat, lon, surface area, ...
@ -19,6 +18,7 @@ import { SpecialVisualizationState } from "../UI/SpecialVisualization"
export default class MetaTagging {
private static errorPrintCount = 0
private static readonly stopErrorOutputAt = 10
private static metataggingObject: any = undefined
private static retaggingFuncCache = new Map<
string,
((feature: Feature, propertiesStore: UIEventSource<any>) => void)[]
@ -77,6 +77,23 @@ export default class MetaTagging {
})
}
// noinspection JSUnusedGlobalSymbols
/**
* The 'metaTagging'-object is an object which contains some functions.
* Those functions are named `metaTaggging_for_<layer_name>` and are constructed based on the 'calculatedField' for this layer.
*
* If they are set, those functions will be used instead of parsing them at runtime.
*
* This means that we can avoid using eval, resulting in faster and safer code (at the cost of more complexity) - at least for official themes.
*
* Note: this function might appear unused while developing, it is used in the generated `index_<themename>.ts` files.
*
* @param metatagging
*/
public static setThemeMetatagging(metatagging: any) {
MetaTagging.metataggingObject = metatagging
}
/**
* This method (re)calculates all metatags and calculated tags on every given feature.
* The given features should be part of the given layer
@ -298,6 +315,40 @@ export default class MetaTagging {
layer: LayerConfig,
helpers: Record<ExtraFuncType, (feature: Feature) => Function>
): (feature: Feature, tags: UIEventSource<Record<string, any>>) => boolean {
if (MetaTagging.metataggingObject) {
const id = layer.id.replace(/[^a-zA-Z0-9_]/g, "_")
const funcName = "metaTaggging_for_" + id
if (typeof MetaTagging.metataggingObject[funcName] !== "function") {
console.log(MetaTagging.metataggingObject)
throw (
"Error: metatagging-object for this theme does not have an entry at " +
funcName +
" (or it is not a function)"
)
}
// public metaTaggging_for_walls_and_buildings(feat: Feature, helperFunctions: Record<ExtraFuncType, (feature: Feature) => Function>) {
//
const func: (feat: Feature, helperFunctions: Record<string, any>) => void =
MetaTagging.metataggingObject[funcName]
return (feature: Feature) => {
const tags = feature.properties
if (tags === undefined) {
return
}
try {
func(feature, helpers)
} catch (e) {
console.error("Could not calculate calculated tags in exported class: ", e)
}
return true // Something changed
}
}
console.warn(
"Static MetataggingObject for theme is not set; using `new Function` (aka `eval`) to get calculated tags. This might trip up the CSP"
)
const calculatedTags: [string, string, boolean][] = layer.calculatedTags
if (calculatedTags === undefined || calculatedTags.length === 0) {
return undefined

View file

@ -97,7 +97,7 @@ export default class CreateNewNodeAction extends OsmCreateAction {
},
meta: this.meta,
}
if (this._snapOnto === undefined) {
if (this._snapOnto?.coordinates === undefined) {
return [newPointChange]
}
@ -113,6 +113,7 @@ export default class CreateNewNodeAction extends OsmCreateAction {
console.log("Attempting to snap:", { geojson, projected, projectedCoor, index })
// We check that it isn't close to an already existing point
let reusedPointId = undefined
let reusedPointCoordinates: [number, number] = undefined
let outerring: [number, number][]
if (geojson.geometry.type === "LineString") {
@ -125,11 +126,13 @@ export default class CreateNewNodeAction extends OsmCreateAction {
if (GeoOperations.distanceBetween(prev, projectedCoor) < this._reusePointDistance) {
// We reuse this point instead!
reusedPointId = this._snapOnto.nodes[index]
reusedPointCoordinates = this._snapOnto.coordinates[index]
}
const next = outerring[index + 1]
if (GeoOperations.distanceBetween(next, projectedCoor) < this._reusePointDistance) {
// We reuse this point instead!
reusedPointId = this._snapOnto.nodes[index + 1]
reusedPointCoordinates = this._snapOnto.coordinates[index + 1]
}
if (reusedPointId !== undefined) {
this.setElementId(reusedPointId)
@ -139,12 +142,13 @@ export default class CreateNewNodeAction extends OsmCreateAction {
type: "node",
id: reusedPointId,
meta: this.meta,
changes: { lat: reusedPointCoordinates[0], lon: reusedPointCoordinates[1] },
},
]
}
const locations = [
...this._snapOnto.coordinates.map(([lat, lon]) => <[number, number]>[lon, lat]),
...this._snapOnto.coordinates?.map(([lat, lon]) => <[number, number]>[lon, lat]),
]
const ids = [...this._snapOnto.nodes]

View file

@ -0,0 +1,57 @@
import ChangeTagAction from "./ChangeTagAction"
import { Tag } from "../../Tags/Tag"
import OsmChangeAction from "./OsmChangeAction"
import { Changes } from "../Changes"
import { ChangeDescription } from "./ChangeDescription"
import { Store } from "../../UIEventSource"
export default class LinkImageAction extends OsmChangeAction {
private readonly _proposedKey: "image" | "mapillary" | "wiki_commons" | string
public readonly _url: string
private readonly _currentTags: Store<Record<string, string>>
private readonly _meta: { theme: string; changeType: "add-image" | "link-image" }
/**
* Adds an image-link to a feature
* @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: Store<Record<string, string>>,
meta: {
theme: string
changeType: "add-image" | "link-image"
}
) {
super(elementId, true)
this._proposedKey = proposedKey
this._url = url
this._currentTags = currentTags
this._meta = meta
}
protected CreateChangeDescriptions(): Promise<ChangeDescription[]> {
let key = this._proposedKey
let i = 0
const currentTags = this._currentTags.data
const url = this._url
while (currentTags[key] !== undefined && currentTags[key] !== url) {
key = this._proposedKey + ":" + i
i++
}
const tagChangeAction = new ChangeTagAction(
this.mainObjectId,
new Tag(key, url),
currentTags,
this._meta
)
return tagChangeAction.CreateChangeDescriptions()
}
}

View file

@ -1,32 +0,0 @@
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)
}
}

View file

@ -215,7 +215,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction implements Pr
throw "Invalid ID to conflate: " + this.wayToReplaceId
}
const url = `${
this.state.osmConnection?._oauth_config?.url ?? "https://openstreetmap.org"
this.state.osmConnection?._oauth_config?.url ?? "https://api.openstreetmap.org"
}/api/0.6/${this.wayToReplaceId}/full`
const rawData = await Utils.downloadJsonCached(url, 1000)
parsed = OsmObject.ParseObjects(rawData.elements)

View file

@ -5,6 +5,7 @@ import Locale from "../../UI/i18n/Locale"
import Constants from "../../Models/Constants"
import { Changes } from "./Changes"
import { Utils } from "../../Utils"
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
export interface ChangesetTag {
key: string
@ -13,7 +14,7 @@ export interface ChangesetTag {
}
export class ChangesetHandler {
private readonly allElements: { addAlias: (id0: String, id1: string) => void }
private readonly allElements: FeaturePropertiesStore
private osmConnection: OsmConnection
private readonly changes: Changes
private readonly _dryRun: Store<boolean>
@ -29,11 +30,14 @@ export class ChangesetHandler {
constructor(
dryRun: Store<boolean>,
osmConnection: OsmConnection,
allElements: { addAlias: (id0: string, id1: string) => void } | undefined,
allElements:
| FeaturePropertiesStore
| { addAlias: (id0: string, id1: string) => void }
| undefined,
changes: Changes
) {
this.osmConnection = osmConnection
this.allElements = allElements
this.allElements = <FeaturePropertiesStore>allElements
this.changes = changes
this._dryRun = dryRun
this.userDetails = osmConnection.userDetails

View file

@ -5,6 +5,7 @@ import { OsmPreferences } from "./OsmPreferences"
import { Utils } from "../../Utils"
import { LocalStorageSource } from "../Web/LocalStorageSource"
import * as config from "../../../package.json"
export default class UserDetails {
public loggedIn = false
public name = "Not logged in"
@ -85,7 +86,7 @@ export class OsmConnection {
this._oauth_config = {
oauth_client_id: import.meta.env.VITE_OSM_OAUTH_CLIENT_ID,
oauth_secret: import.meta.env.VITE_OSM_OAUTH_SECRET,
url: "https://www.openstreetmap.org",
url: "https://api.openstreetmap.org",
}
}
@ -227,8 +228,6 @@ export class OsmConnection {
// details is an XML DOM of user details
let userInfo = details.getElementsByTagName("user")[0]
// let moreDetails = new DOMParser().parseFromString(userInfo.innerHTML, "text/xml");
let data = self.userDetails.data
data.loggedIn = true
console.log("Login completed, userinfo is ", userInfo)
@ -365,10 +364,10 @@ export class OsmConnection {
)
})
}
const auth = this.auth
const content = { lat, lon, text }
const response = await this.post("notes.json", JSON.stringify(content), {
"Content-Type": "application/json",
// Lat and lon must be strings for the API to accept it
const content = `lat=${lat}&lon=${lon}&text=${encodeURIComponent(text)}`
const response = await this.post("notes.json", content, {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
})
const parsed = JSON.parse(response)
const id = parsed.properties
@ -414,7 +413,6 @@ export class OsmConnection {
'"\r\nContent-Type: application/gpx+xml',
}
const auth = this.auth
const boundary = "987654"
let body = ""
@ -467,6 +465,18 @@ export class OsmConnection {
})
}
/**
* To be called by land.html
*/
public finishLogin(callback: (previousURL: string) => void) {
this.auth.authenticate(function () {
// Fully authed at this point
console.log("Authentication successful!")
const previousLocation = LocalStorageSource.Get("location_before_login")
callback(previousLocation.data)
})
}
private updateAuthObject() {
let pwaStandAloneMode = false
try {
@ -502,18 +512,6 @@ export class OsmConnection {
})
}
/**
* To be called by land.html
*/
public finishLogin(callback: (previousURL: string) => void) {
this.auth.authenticate(function () {
// Fully authed at this point
console.log("Authentication successful!")
const previousLocation = LocalStorageSource.Get("location_before_login")
callback(previousLocation.data)
})
}
private CheckForMessagesContinuously() {
const self = this
if (this.isChecking) {

View file

@ -5,7 +5,7 @@ import OsmToGeoJson from "osmtogeojson"
import { Feature, LineString, Polygon } from "geojson"
export abstract class OsmObject {
private static defaultBackend = "https://www.openstreetmap.org/"
private static defaultBackend = "https://api.openstreetmap.org/"
protected static backendURL = OsmObject.defaultBackend
private static polygonFeatures = OsmObject.constructPolygonFeatures()
type: "node" | "way" | "relation"

View file

@ -17,7 +17,7 @@ export default class OsmObjectDownloader {
private historyCache = new Map<string, UIEventSource<OsmObject[]>>()
constructor(
backend: string = "https://www.openstreetmap.org",
backend: string = "https://api.openstreetmap.org",
changes?: {
readonly pendingChanges: UIEventSource<ChangeDescription[]>
readonly isUploading: Store<boolean>

View file

@ -219,7 +219,7 @@ class RewriteMetaInfoTags extends SimpleMetaTagger {
move("changeset", "_last_edit:changeset")
move("timestamp", "_last_edit:timestamp")
move("version", "_version_number")
feature.properties._backend = feature.properties._backend ?? "https://openstreetmap.org"
feature.properties._backend = feature.properties._backend ?? "https://api.openstreetmap.org"
return movedSomething
}
}
@ -339,21 +339,37 @@ export default class SimpleMetaTaggers {
)
private static levels = new InlineMetaTagger(
{
doc: "Extract the 'level'-tag into a normalized, ';'-separated value",
doc: "Extract the 'level'-tag into a normalized, ';'-separated value called '_level' (which also includes 'repeat_on'). The `level` tag (without underscore) will be normalized with only the value of `level`.",
keys: ["_level"],
},
(feature) => {
if (feature.properties["level"] === undefined) {
return false
let somethingChanged = false
if (feature.properties["level"] !== undefined) {
const l = feature.properties["level"]
const newValue = TagUtils.LevelsParser(l).join(";")
if (l !== newValue) {
feature.properties["level"] = newValue
somethingChanged = true
}
}
const l = feature.properties["level"]
const newValue = TagUtils.LevelsParser(l).join(";")
if (l === newValue) {
return false
if (feature.properties["repeat_on"] !== undefined) {
const l = feature.properties["repeat_on"]
const newValue = TagUtils.LevelsParser(l).join(";")
if (l !== newValue) {
feature.properties["repeat_on"] = newValue
somethingChanged = true
}
}
feature.properties["level"] = newValue
return true
const combined = TagUtils.LevelsParser(
(feature.properties.repeat_on ?? "") + ";" + (feature.properties.level ?? "")
).join(";")
if (feature.properties["_level"] !== combined) {
feature.properties["_level"] = combined
somethingChanged = true
}
return somethingChanged
}
)
private static canonicalize = new InlineMetaTagger(

View file

@ -198,7 +198,7 @@ export default class FeatureSwitchState extends OsmConnectionFeatureSwitches {
this.backgroundLayerId = QueryParameters.GetQueryParameter(
"background",
layoutToUse?.defaultBackgroundId ?? "osm",
layoutToUse?.defaultBackgroundId,
"The id of the background layer to start with"
)
}

View file

@ -56,6 +56,7 @@ export class GeoLocationState {
* Used to detect a permission retraction
*/
private readonly _grantedThisSession: UIEventSource<boolean> = new UIEventSource<boolean>(false)
constructor() {
const self = this
@ -67,7 +68,6 @@ export class GeoLocationState {
if (state === "prompt" && self._grantedThisSession.data) {
// This is _really_ weird: we had a grant earlier, but it's 'prompt' now?
// This means that the rights have been revoked again!
// self.permission.setData("denied")
self._previousLocationGrant.setData("false")
self.permission.setData("denied")
self.currentGPSLocation.setData(undefined)
@ -93,6 +93,54 @@ export class GeoLocationState {
}
}
/**
* Requests the user to allow access to their position.
* When granted, will be written to the 'geolocationState'.
* This class will start watching
*/
public requestPermission() {
this.requestPermissionAsync()
}
/**
* Requests the user to allow access to their position.
* When granted, will be written to the 'geolocationState'.
* This class will start watching
*/
public async requestPermissionAsync() {
if (typeof navigator === "undefined") {
// Not compatible with this browser
this.permission.setData("denied")
return
}
if (this.permission.data !== "prompt" && this.permission.data !== "requested") {
// If the user denies the first prompt, revokes the deny and then tries again, we have to run the flow as well
// Hence that we continue the flow if it is "requested"
return
}
this.permission.setData("requested")
try {
const status = await navigator?.permissions?.query({ name: "geolocation" })
const self = this
console.log("Got geolocation state", status.state)
if (status.state === "granted" || status.state === "denied") {
self.permission.setData(status.state)
self.startWatching()
return
}
status.addEventListener("change", (e) => {
self.permission.setData(status.state)
})
// The code above might have reset it to 'prompt', but we _did_ request permission!
this.permission.setData("requested")
// We _must_ call 'startWatching', as that is the actual trigger for the popup...
self.startWatching()
} catch (e) {
console.error("Could not get permission:", e)
}
}
/**
* Installs the listener for updates
* @private
@ -112,42 +160,4 @@ export class GeoLocationState {
}
)
}
/**
* Requests the user to allow access to their position.
* When granted, will be written to the 'geolocationState'.
* This class will start watching
*/
public requestPermission() {
if (typeof navigator === "undefined") {
// Not compatible with this browser
this.permission.setData("denied")
return
}
if (this.permission.data !== "prompt" && this.permission.data !== "requested") {
// If the user denies the first prompt, revokes the deny and then tries again, we have to run the flow as well
// Hence that we continue the flow if it is "requested"
return
}
this.permission.setData("requested")
try {
navigator?.permissions
?.query({ name: "geolocation" })
.then((status) => {
console.log("Status update: received geolocation permission is ", status.state)
this.permission.setData(status.state)
const self = this
status.onchange = function () {
self.permission.setData(status.state)
}
this.permission.setData("requested")
// We _must_ call 'startWatching', as that is the actual trigger for the popup...
self.startWatching()
})
.catch((e) => console.error("Could not get geopermission", e))
} catch (e) {
console.error("Could not get permission:", e)
}
}
}

View file

@ -66,11 +66,11 @@ export default class LayerState {
}
const t = Translations.t.general.levelSelection
const conditionsOrred = [
new Tag("level", "" + level),
new RegexTag("level", new RegExp("(.*;)?" + level + "(;.*)?")),
new Tag("_level", "" + level),
new RegexTag("_level", new RegExp("(.*;)?" + level + "(;.*)?")),
]
if (level === "0") {
conditionsOrred.push(new Tag("level", "")) // No level tag is the same as level '0'
conditionsOrred.push(new Tag("_level", "")) // No level tag is the same as level '0'
}
console.log("Setting levels filter to", conditionsOrred)
this.globalFilters.data.push({

View file

@ -16,6 +16,8 @@ import LinkToWeblate from "../../UI/Base/LinkToWeblate"
import FeatureSwitchState from "./FeatureSwitchState"
import Constants from "../../Models/Constants"
import { QueryParameters } from "../Web/QueryParameters"
import { ThemeMetaTagging } from "./UserSettingsMetaTagging"
import { MapProperties } from "../../Models/MapProperties"
/**
* The part of the state which keeps track of user-related stuff, e.g. the OSM-connection,
@ -40,6 +42,10 @@ export default class UserRelatedState {
public readonly fixateNorth: UIEventSource<undefined | "yes">
public readonly homeLocation: FeatureSource
public readonly language: UIEventSource<string>
public readonly preferredBackgroundLayer: UIEventSource<
string | "photo" | "map" | "osmbasedmap" | undefined
>
public readonly imageLicense: UIEventSource<string>
/**
* The number of seconds that the GPS-locations are stored in memory.
* Time in seconds
@ -51,16 +57,23 @@ export default class UserRelatedState {
/**
* Preferences as tags exposes many preferences and state properties as record.
* This is used to bridge the internal state with the usersettings.json layerconfig file
*
* Some metainformation that should not be edited starts with a single underscore
* Constants and query parameters start with two underscores
* Note: these are linked via OsmConnection.preferences which exports all preferences as UIEventSource
*/
public readonly preferencesAsTags: UIEventSource<Record<string, string>>
private readonly _mapProperties: MapProperties
constructor(
osmConnection: OsmConnection,
availableLanguages?: string[],
layout?: LayoutConfig,
featureSwitches?: FeatureSwitchState
featureSwitches?: FeatureSwitchState,
mapProperties?: MapProperties
) {
this.osmConnection = osmConnection
this._mapProperties = mapProperties
{
const translationMode: UIEventSource<undefined | "true" | "false" | "mobile" | string> =
this.osmConnection.GetPreference("translation-mode", "false")
@ -89,11 +102,22 @@ export default class UserRelatedState {
)
this.language = this.osmConnection.GetPreference("language")
this.showTags = <UIEventSource<any>>this.osmConnection.GetPreference("show_tags")
this.fixateNorth = <any>this.osmConnection.GetPreference("fixate-north")
this.fixateNorth = <UIEventSource<"yes">>this.osmConnection.GetPreference("fixate-north")
this.mangroveIdentity = new MangroveIdentity(
this.osmConnection.GetLongPreference("identity", "mangrove")
)
this.preferredBackgroundLayer = this.osmConnection.GetPreference(
"preferred-background-layer",
undefined,
{
documentation:
"The ID of a layer or layer category that MapComplete uses by default",
}
)
this.imageLicense = this.osmConnection.GetPreference("pictures-license", "CC0", {
documentation: "The license under which new images are uploaded",
})
this.installedUserThemes = this.InitInstalledUserThemes()
this.homeLocation = this.initHomeLocation()
@ -245,6 +269,7 @@ export default class UserRelatedState {
): UIEventSource<Record<string, string>> {
const amendedPrefs = new UIEventSource<Record<string, string>>({
_theme: layout?.id,
"_theme:backgroundLayer": layout?.defaultBackgroundId,
_backend: this.osmConnection.Backend(),
_applicationOpened: new Date().toISOString(),
_supports_sharing:
@ -279,7 +304,6 @@ export default class UserRelatedState {
amendedPrefs.ping()
console.log("Amended prefs are:", amendedPrefs.data)
})
const usersettingsConfig = UserRelatedState.usersettingsConfig
const translationMode = osmConnection.GetPreference("translation-mode")
Locale.language.mapD(
@ -326,30 +350,14 @@ export default class UserRelatedState {
},
[translationMode]
)
const usersettingMetaTagging = new ThemeMetaTagging()
osmConnection.userDetails.addCallback((userDetails) => {
for (const k in userDetails) {
amendedPrefs.data["_" + k] = "" + userDetails[k]
}
for (const [name, code, _] of usersettingsConfig.calculatedTags) {
try {
let result = new Function("feat", "return " + code + ";")({
properties: amendedPrefs.data,
})
if (result !== undefined && result !== "" && result !== null) {
if (typeof result !== "string") {
result = JSON.stringify(result)
}
amendedPrefs.data[name] = result
}
} catch (e) {
console.error(
"Calculating a tag for userprofile-settings failed for variable",
name,
e
)
}
}
usersettingMetaTagging.metaTaggging_for_usersettings({ properties: amendedPrefs.data })
const simplifiedName = userDetails.name.toLowerCase().replace(/\s+/g, "")
const isTranslator = translators.contributors.find(
@ -403,6 +411,11 @@ export default class UserRelatedState {
}
}
this._mapProperties?.rasterLayer?.addCallbackAndRun((l) => {
amendedPrefs.data["__current_background"] = l?.properties?.id
amendedPrefs.ping()
})
return amendedPrefs
}
}

View file

@ -0,0 +1,42 @@
import { Utils } from "../../Utils"
/** This code is autogenerated - do not edit. Edit ./assets/layers/usersettings/usersettings.json instead */
export class ThemeMetaTagging {
public static readonly themeName = "usersettings"
public metaTaggging_for_usersettings(feat: { properties: Record<string, string> }) {
Utils.AddLazyProperty(feat.properties, "_mastodon_candidate_md", () =>
feat.properties._description
.match(/\[[^\]]*\]\((.*(mastodon|en.osm.town).*)\).*/)
?.at(1)
)
Utils.AddLazyProperty(
feat.properties,
"_d",
() => feat.properties._description?.replace(/&lt;/g, "<")?.replace(/&gt;/g, ">") ?? ""
)
Utils.AddLazyProperty(feat.properties, "_mastodon_candidate_a", () =>
((feat) => {
const e = document.createElement("div")
e.innerHTML = feat.properties._d
return Array.from(e.getElementsByTagName("a")).filter(
(a) => a.href.match(/mastodon|en.osm.town/) !== null
)[0]?.href
})(feat)
)
Utils.AddLazyProperty(feat.properties, "_mastodon_link", () =>
((feat) => {
const e = document.createElement("div")
e.innerHTML = feat.properties._d
return Array.from(e.getElementsByTagName("a")).filter(
(a) => a.getAttribute("rel")?.indexOf("me") >= 0
)[0]?.href
})(feat)
)
Utils.AddLazyProperty(
feat.properties,
"_mastodon_candidate",
() => feat.properties._mastodon_candidate_md ?? feat.properties._mastodon_candidate_a
)
feat.properties["__current_backgroun"] = "initial_value"
}
}

View file

@ -286,7 +286,7 @@ export class TagUtils {
"Invalid type to flatten the multiAnswer: key is a regex too",
tagsFilter
)
throw "Invalid type to FlattenMultiAnswer"
throw "Invalid type to FlattenMultiAnswer: key is a regex too"
}
const keystr = <string>key
if (keyValues[keystr] === undefined) {
@ -297,7 +297,10 @@ export class TagUtils {
}
console.error("Invalid type to flatten the multiAnswer", tagsFilter)
throw "Invalid type to FlattenMultiAnswer"
throw (
"Invalid type to FlattenMultiAnswer, not one of RegexTag, Tag or And: " +
tagsFilter.asHumanString(false, false, {})
)
}
return keyValues
}
@ -320,6 +323,9 @@ export class TagUtils {
/**
* Given multiple tagsfilters which can be used as answer, will take the tags with the same keys together as set.
*
* @see MatchesMultiAnswer to do the reverse
*
* E.g:
*
* const tag = TagUtils.ParseUploadableTag({"and": [
@ -665,13 +671,22 @@ export class TagUtils {
* TagUtils.LevelsParser("-1") // => ["-1"]
* TagUtils.LevelsParser("0;-1") // => ["0", "-1"]
* TagUtils.LevelsParser(undefined) // => []
* TagUtils.LevelsParser("") // => []
* TagUtils.LevelsParser(";") // => []
*
*/
public static LevelsParser(level: string): string[] {
if (level === undefined || level === null) {
return []
}
let spec = Utils.NoNull([level])
spec = [].concat(...spec.map((s) => s?.split(";")))
spec = [].concat(
...spec.map((s) => {
s = s.trim()
if (s === "") {
return undefined
}
if (s.indexOf("-") < 0 || s.startsWith("-")) {
return s
}

View file

@ -357,14 +357,18 @@ class ListenerTracker<T> {
let toDelete = undefined
let startTime = new Date().getTime() / 1000
for (const callback of this._callbacks) {
if (callback(data) === true) {
// This callback wants to be deleted
// Note: it has to return precisely true in order to avoid accidental deletions
if (toDelete === undefined) {
toDelete = [callback]
} else {
toDelete.push(callback)
try {
if (callback(data) === true) {
// This callback wants to be deleted
// Note: it has to return precisely true in order to avoid accidental deletions
if (toDelete === undefined) {
toDelete = [callback]
} else {
toDelete.push(callback)
}
}
} catch (e) {
console.error("Got an error while running a callback:", e)
}
}
let endTime = new Date().getTime() / 1000
@ -511,7 +515,7 @@ class MappedStore<TIn, T> extends Store<T> {
}
private unregisterFromUpstream() {
console.log("Unregistering callbacks for", this.tag)
console.debug("Unregistering callbacks for", this.tag)
this._callbacksAreRegistered = false
this._unregisterFromUpstream()
this._unregisterFromExtraStores?.forEach((unr) => unr())

View file

@ -54,6 +54,7 @@ export class MangroveIdentity {
export default class FeatureReviews {
private static readonly _featureReviewsCache: Record<string, FeatureReviews> = {}
public readonly subjectUri: Store<string>
public readonly average: Store<number | null>
private readonly _reviews: UIEventSource<(Review & { madeByLoggedInUser: Store<boolean> })[]> =
new UIEventSource([])
public readonly reviews: Store<(Review & { madeByLoggedInUser: Store<boolean> })[]> =
@ -124,6 +125,23 @@ export default class FeatureReviews {
console.log("Could not fetch reviews for partially incorrect query ", sub)
}
})
this.average = this._reviews.map((reviews) => {
if (!reviews) {
return null
}
if (reviews.length === 0) {
return null
}
let sum = 0
let count = 0
for (const review of reviews) {
if (review.rating !== undefined) {
count++
sum += review.rating
}
}
return Math.round(sum / count)
})
}
/**
@ -211,6 +229,8 @@ export default class FeatureReviews {
hasNew = true
}
if (hasNew) {
self._reviews.data.sort((a, b) => b.iat - a.iat) // Sort with most recent first
self._reviews.ping()
}
}

View file

@ -985,6 +985,27 @@ export default class PlantNet {
}
}
export interface PlantNetSpeciesMatch {
score: number
gbif: { id: string /*Actually a number*/ }
species: {
scientificNameWithoutAuthor: string
scientificNameAuthorship: string
genus: {
scientificNameWithoutAuthor: string
scientificNameAuthorship: string
scientificName: string
}
family: {
scientificNameWithoutAuthor: string
scientificNameAuthorship: string
scientificName: string
}
commonNames: string[]
scientificName: string
}
}
export interface PlantNetResult {
query: {
project: string
@ -995,26 +1016,7 @@ export interface PlantNetResult {
language: string
preferedReferential: string
bestMatch: string
results: {
score: number
gbif: { id: string /*Actually a number*/ }
species: {
scientificNameWithoutAuthor: string
scientificNameAuthorship: string
genus: {
scientificNameWithoutAuthor: string
scientificNameAuthorship: string
scientificName: string
}
family: {
scientificNameWithoutAuthor: string
scientificNameAuthorship: string
scientificName: string
}
commonNames: string[]
scientificName: string
}
}[]
results: PlantNetSpeciesMatch[]
version: string
remainingIdentificationRequests: number
}

View file

@ -159,7 +159,7 @@ export default class Wikidata {
*/
public static async searchAdvanced(
text: string,
options: WikidataAdvancedSearchoptions
options?: WikidataAdvancedSearchoptions
): Promise<
{
id: string
@ -185,7 +185,7 @@ export default class Wikidata {
?num wikibase:apiOrdinal true .
bd:serviceParam wikibase:limit ${
Math.round(
(options.maxCount ?? 20) * 1.5
(options?.maxCount ?? 20) * 1.5
) /*Some padding for disambiguation pages */
} .
?label wikibase:apiOutput mwapi:label .
@ -193,7 +193,7 @@ export default class Wikidata {
}
${instanceOf}
${minusPhrases.join("\n ")}
} ORDER BY ASC(?num) LIMIT ${options.maxCount ?? 20}`
} ORDER BY ASC(?num) LIMIT ${options?.maxCount ?? 20}`
const url = wds.sparqlQuery(sparql)
const result = await Utils.downloadJson(url)

View file

@ -73,7 +73,6 @@ export default class Wikipedia {
if (cached) {
return cached
}
console.log("Constructing store for", cachekey)
const store = new UIEventSource<FullWikipediaDetails>({}, cachekey)
Wikipedia._fullDetailsCache.set(cachekey, store)
@ -123,12 +122,15 @@ export default class Wikipedia {
}
const wikipedia = new Wikipedia({ language: data.language })
wikipedia.GetArticleHtml(data.pagename).then((article) => {
article = Utils.purify(article)
data.fullArticle = article
const content = document.createElement("div")
content.innerHTML = article
const firstParagraph = content.getElementsByTagName("p").item(0)
data.firstParagraph = firstParagraph.innerHTML
content.removeChild(firstParagraph)
if (firstParagraph) {
data.firstParagraph = firstParagraph.innerHTML
content.removeChild(firstParagraph)
}
data.restOfArticle = content.innerHTML
store.ping()
})
@ -194,53 +196,6 @@ export default class Wikipedia {
encodeURIComponent(searchTerm)
return (await Utils.downloadJson(url))["query"]["search"]
}
/**
* Searches via 'index.php' and scrapes the result.
* This gives better results then via the API
* @param searchTerm
*/
public async searchViaIndex(
searchTerm: string
): Promise<{ title: string; snippet: string; url: string }[]> {
const url = `${this.backend}/w/index.php?search=${encodeURIComponent(searchTerm)}&ns0=1`
const result = await Utils.downloadAdvanced(url)
if (result["redirect"]) {
const targetUrl = result["redirect"]
// This is an exact match
return [
{
title: this.extractPageName(targetUrl)?.trim(),
url: targetUrl,
snippet: "",
},
]
}
if (result["error"]) {
throw "Could not download: " + JSON.stringify(result)
}
const el = document.createElement("html")
el.innerHTML = result["content"].replace(/href="\//g, 'href="' + this.backend + "/")
const searchResults = el.getElementsByClassName("mw-search-results")
const individualResults = Array.from(
searchResults[0]?.getElementsByClassName("mw-search-result") ?? []
)
return individualResults.map((result) => {
const toRemove = Array.from(result.getElementsByClassName("searchalttitle"))
for (const toRm of toRemove) {
toRm.parentElement.removeChild(toRm)
}
return {
title: result
.getElementsByClassName("mw-search-result-heading")[0]
.textContent.trim(),
url: result.getElementsByTagName("a")[0].href,
snippet: result.getElementsByClassName("searchresult")[0].textContent,
}
})
}
/**
* Returns the innerHTML for the given article as string.
* Some cleanup is applied to this.
@ -262,7 +217,7 @@ export default class Wikipedia {
if (response?.parse?.text === undefined) {
return undefined
}
const html = response["parse"]["text"]["*"]
const html = Utils.purify(response["parse"]["text"]["*"])
if (html === undefined) {
return undefined
}

View file

@ -1,14 +1,11 @@
import * as meta from "../../package.json"
import * as packagefile from "../../package.json"
import * as extraconfig from "../../config.json"
import { Utils } from "../Utils"
export type PriviligedLayerType = (typeof Constants.priviliged_layers)[number]
export default class Constants {
public static vNumber = meta.version
public static ImgurApiKey = meta.config.api_keys.imgur
public static readonly mapillary_client_token_v4 = meta.config.api_keys.mapillary_v4
public static vNumber = packagefile.version
/**
* API key for Maproulette
*
@ -17,9 +14,6 @@ export default class Constants {
* Using an empty string however does work for most actions, but will attribute all actions to the Superuser.
*/
public static readonly MaprouletteApiKey = ""
public static defaultOverpassUrls = meta.config.default_overpass_urls
public static readonly added_by_default = [
"selected_element",
"gps_location",
@ -37,7 +31,6 @@ export default class Constants {
"split_point",
"split_road",
"current_view",
"matchpoint",
"import_candidate",
"usersettings",
] as const
@ -48,7 +41,6 @@ export default class Constants {
...Constants.added_by_default,
...Constants.no_include,
] as const
// The user journey states thresholds when a new feature gets unlocked
public static userJourney = {
moreScreenUnlock: 1,
@ -105,7 +97,14 @@ export default class Constants {
* In seconds
*/
static zoomToLocationTimeout = 15
static countryCoderEndpoint: string = meta.config.country_coder_host
private static readonly config = (() => {
const defaultConfig = packagefile.config
return { ...defaultConfig, ...extraconfig }
})()
public static ImgurApiKey = Constants.config.api_keys.imgur
public static readonly mapillary_client_token_v4 = Constants.config.api_keys.mapillary_v4
public static defaultOverpassUrls = Constants.config.default_overpass_urls
static countryCoderEndpoint: string = Constants.config.country_coder_host
/**
* These are the values that are allowed to use as 'backdrop' icon for a map pin

View file

@ -37,7 +37,7 @@ export class AvailableRasterLayers {
geometry: BBox.global.asGeometry(),
}
public static readonly maplibre: RasterLayerPolygon = {
public static readonly maptilerDefaultLayer: RasterLayerPolygon = {
type: "Feature",
properties: {
name: "MapTiler",
@ -53,6 +53,37 @@ export class AvailableRasterLayers {
geometry: BBox.global.asGeometry(),
}
public static readonly maptilerCarto: RasterLayerPolygon = {
type: "Feature",
properties: {
name: "MapTiler Carto",
url: "https://api.maptiler.com/maps/openstreetmap/style.json?key=GvoVAJgu46I5rZapJuAy",
category: "osmbasedmap",
id: "maptiler.carto",
type: "vector",
attribution: {
text: "Maptiler",
url: "https://www.maptiler.com/copyright/",
},
},
geometry: BBox.global.asGeometry(),
}
public static readonly maptilerBackdrop: RasterLayerPolygon = {
type: "Feature",
properties: {
name: "MapTiler Backdrop",
url: "https://api.maptiler.com/maps/backdrop/style.json?key=GvoVAJgu46I5rZapJuAy",
category: "osmbasedmap",
id: "maptiler.backdrop",
type: "vector",
attribution: {
text: "Maptiler",
url: "https://www.maptiler.com/copyright/",
},
},
geometry: BBox.global.asGeometry(),
}
public static readonly americana: RasterLayerPolygon = {
type: "Feature",
properties: {
@ -90,10 +121,14 @@ export class AvailableRasterLayers {
}
return GeoOperations.inside(lonlat, eliPolygon)
})
matching.unshift(AvailableRasterLayers.osmCarto)
matching.unshift(AvailableRasterLayers.americana)
matching.unshift(AvailableRasterLayers.maplibre)
matching.push(...AvailableRasterLayers.globalLayers)
matching.unshift(
AvailableRasterLayers.maptilerDefaultLayer,
AvailableRasterLayers.osmCarto,
AvailableRasterLayers.maptilerCarto,
AvailableRasterLayers.maptilerBackdrop,
AvailableRasterLayers.americana
)
return matching
})
)

View file

@ -41,7 +41,6 @@ export class UpdateLegacyLayer extends DesugaringStep<
delete preset["preciseInput"]
} else if (preciseInput !== undefined) {
delete preciseInput["preferredBackground"]
console.log("Precise input:", preciseInput)
preset.snapToLayer = preciseInput.snapToLayer
delete preciseInput.snapToLayer
if (preciseInput.maxSnapDistance) {
@ -232,6 +231,10 @@ class UpdateLegacyTheme extends DesugaringStep<LayoutConfigJson> {
delete oldThemeConfig.socialImage
}
if (oldThemeConfig.defaultBackgroundId === "osm") {
console.log("Removing old background in", json.id)
}
if (oldThemeConfig["roamingRenderings"] !== undefined) {
if (oldThemeConfig["roamingRenderings"].length == 0) {
delete oldThemeConfig["roamingRenderings"]

File diff suppressed because it is too large Load diff

View file

@ -203,12 +203,6 @@ export default class LayerConfig extends WithContextLoader {
}
const code = kv.substring(index + 1)
try {
new Function("feat", "return " + code + ";")
} catch (e) {
throw `Invalid function definition: the custom javascript is invalid:${e} (at ${context}). The offending javascript code is:\n ${code}`
}
this.calculatedTags.push([key, code, isStrict])
}
}
@ -357,7 +351,7 @@ export default class LayerConfig extends WithContextLoader {
}
{
const duplicateIds = Utils.Dupiclates(this.filters.map((f) => f.id))
const duplicateIds = Utils.Duplicates(this.filters.map((f) => f.id))
if (duplicateIds.length > 0) {
throw `Some filters have a duplicate id: ${duplicateIds} (at ${context}.filters)`
}

View file

@ -652,6 +652,16 @@ export default class TagRenderingConfig {
/**
* Given a value for the freeform key and an overview of the selected mappings, construct the correct tagsFilter to apply
*
* const config = new TagRenderingConfig({"id":"bookcase-booktypes","render":{"en":"This place mostly serves {books}" },
* "question":{"en":"What kind of books can be found in this public bookcase?"},
* "freeform":{"key":"books","addExtraTags":["fixme=Freeform tag `books` used, to be doublechecked"],
* "inline":true},
* "multiAnswer":true,
* "mappings":[{"if":"books=children","then":"Mostly children books"},
* {"if":"books=adults","then": "Mostly books for adults"}]}
* , "testcase")
* config.constructChangeSpecification(undefined, undefined, [false, true, false], {amenity: "public_bookcase"}) // => new And([new Tag("books","adults")])
*
* @param freeformValue The freeform value which will be applied as 'freeform.key'. Ignored if 'freeform.key' is not set
*
* @param singleSelectedMapping (Only used if multiAnswer == false): the single mapping to apply. Use (mappings.length) for the freeform

View file

@ -1,55 +1,62 @@
import LayoutConfig from "./ThemeConfig/LayoutConfig";
import { SpecialVisualizationState } from "../UI/SpecialVisualization";
import { Changes } from "../Logic/Osm/Changes";
import { ImmutableStore, Store, UIEventSource } from "../Logic/UIEventSource";
import { FeatureSource, IndexedFeatureSource, WritableFeatureSource } from "../Logic/FeatureSource/FeatureSource";
import { OsmConnection } from "../Logic/Osm/OsmConnection";
import { ExportableMap, MapProperties } from "./MapProperties";
import LayerState from "../Logic/State/LayerState";
import { Feature, Point, Polygon } from "geojson";
import FullNodeDatabaseSource from "../Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource";
import { Map as MlMap } from "maplibre-gl";
import InitialMapPositioning from "../Logic/Actors/InitialMapPositioning";
import { MapLibreAdaptor } from "../UI/Map/MapLibreAdaptor";
import { GeoLocationState } from "../Logic/State/GeoLocationState";
import FeatureSwitchState from "../Logic/State/FeatureSwitchState";
import { QueryParameters } from "../Logic/Web/QueryParameters";
import UserRelatedState from "../Logic/State/UserRelatedState";
import LayerConfig from "./ThemeConfig/LayerConfig";
import GeoLocationHandler from "../Logic/Actors/GeoLocationHandler";
import { AvailableRasterLayers, RasterLayerPolygon, RasterLayerUtils } from "./RasterLayers";
import LayoutSource from "../Logic/FeatureSource/Sources/LayoutSource";
import StaticFeatureSource from "../Logic/FeatureSource/Sources/StaticFeatureSource";
import FeaturePropertiesStore from "../Logic/FeatureSource/Actors/FeaturePropertiesStore";
import PerLayerFeatureSourceSplitter from "../Logic/FeatureSource/PerLayerFeatureSourceSplitter";
import FilteringFeatureSource from "../Logic/FeatureSource/Sources/FilteringFeatureSource";
import ShowDataLayer from "../UI/Map/ShowDataLayer";
import TitleHandler from "../Logic/Actors/TitleHandler";
import ChangeToElementsActor from "../Logic/Actors/ChangeToElementsActor";
import PendingChangesUploader from "../Logic/Actors/PendingChangesUploader";
import SelectedElementTagsUpdater from "../Logic/Actors/SelectedElementTagsUpdater";
import { BBox } from "../Logic/BBox";
import Constants from "./Constants";
import Hotkeys from "../UI/Base/Hotkeys";
import Translations from "../UI/i18n/Translations";
import { GeoIndexedStoreForLayer } from "../Logic/FeatureSource/Actors/GeoIndexedStore";
import { LastClickFeatureSource } from "../Logic/FeatureSource/Sources/LastClickFeatureSource";
import { MenuState } from "./MenuState";
import MetaTagging from "../Logic/MetaTagging";
import ChangeGeometryApplicator from "../Logic/FeatureSource/Sources/ChangeGeometryApplicator";
import LayoutConfig from "./ThemeConfig/LayoutConfig"
import { SpecialVisualizationState } from "../UI/SpecialVisualization"
import { Changes } from "../Logic/Osm/Changes"
import { Store, UIEventSource } from "../Logic/UIEventSource"
import {
NewGeometryFromChangesFeatureSource
} from "../Logic/FeatureSource/Sources/NewGeometryFromChangesFeatureSource";
import OsmObjectDownloader from "../Logic/Osm/OsmObjectDownloader";
import ShowOverlayRasterLayer from "../UI/Map/ShowOverlayRasterLayer";
import { Utils } from "../Utils";
import { EliCategory } from "./RasterLayerProperties";
import BackgroundLayerResetter from "../Logic/Actors/BackgroundLayerResetter";
import SaveFeatureSourceToLocalStorage from "../Logic/FeatureSource/Actors/SaveFeatureSourceToLocalStorage";
import BBoxFeatureSource from "../Logic/FeatureSource/Sources/TouchesBboxFeatureSource";
import ThemeViewStateHashActor from "../Logic/Web/ThemeViewStateHashActor";
import NoElementsInViewDetector, { FeatureViewState } from "../Logic/Actors/NoElementsInViewDetector";
import FilteredLayer from "./FilteredLayer";
FeatureSource,
IndexedFeatureSource,
WritableFeatureSource,
} from "../Logic/FeatureSource/FeatureSource"
import { OsmConnection } from "../Logic/Osm/OsmConnection"
import { ExportableMap, MapProperties } from "./MapProperties"
import LayerState from "../Logic/State/LayerState"
import { Feature, Point, Polygon } from "geojson"
import FullNodeDatabaseSource from "../Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource"
import { Map as MlMap } from "maplibre-gl"
import InitialMapPositioning from "../Logic/Actors/InitialMapPositioning"
import { MapLibreAdaptor } from "../UI/Map/MapLibreAdaptor"
import { GeoLocationState } from "../Logic/State/GeoLocationState"
import FeatureSwitchState from "../Logic/State/FeatureSwitchState"
import { QueryParameters } from "../Logic/Web/QueryParameters"
import UserRelatedState from "../Logic/State/UserRelatedState"
import LayerConfig from "./ThemeConfig/LayerConfig"
import GeoLocationHandler from "../Logic/Actors/GeoLocationHandler"
import { AvailableRasterLayers, RasterLayerPolygon, RasterLayerUtils } from "./RasterLayers"
import LayoutSource from "../Logic/FeatureSource/Sources/LayoutSource"
import StaticFeatureSource from "../Logic/FeatureSource/Sources/StaticFeatureSource"
import FeaturePropertiesStore from "../Logic/FeatureSource/Actors/FeaturePropertiesStore"
import PerLayerFeatureSourceSplitter from "../Logic/FeatureSource/PerLayerFeatureSourceSplitter"
import FilteringFeatureSource from "../Logic/FeatureSource/Sources/FilteringFeatureSource"
import ShowDataLayer from "../UI/Map/ShowDataLayer"
import TitleHandler from "../Logic/Actors/TitleHandler"
import ChangeToElementsActor from "../Logic/Actors/ChangeToElementsActor"
import PendingChangesUploader from "../Logic/Actors/PendingChangesUploader"
import SelectedElementTagsUpdater from "../Logic/Actors/SelectedElementTagsUpdater"
import { BBox } from "../Logic/BBox"
import Constants from "./Constants"
import Hotkeys from "../UI/Base/Hotkeys"
import Translations from "../UI/i18n/Translations"
import { GeoIndexedStoreForLayer } from "../Logic/FeatureSource/Actors/GeoIndexedStore"
import { LastClickFeatureSource } from "../Logic/FeatureSource/Sources/LastClickFeatureSource"
import { MenuState } from "./MenuState"
import MetaTagging from "../Logic/MetaTagging"
import ChangeGeometryApplicator from "../Logic/FeatureSource/Sources/ChangeGeometryApplicator"
import { NewGeometryFromChangesFeatureSource } from "../Logic/FeatureSource/Sources/NewGeometryFromChangesFeatureSource"
import OsmObjectDownloader from "../Logic/Osm/OsmObjectDownloader"
import ShowOverlayRasterLayer from "../UI/Map/ShowOverlayRasterLayer"
import { Utils } from "../Utils"
import { EliCategory } from "./RasterLayerProperties"
import BackgroundLayerResetter from "../Logic/Actors/BackgroundLayerResetter"
import SaveFeatureSourceToLocalStorage from "../Logic/FeatureSource/Actors/SaveFeatureSourceToLocalStorage"
import BBoxFeatureSource from "../Logic/FeatureSource/Sources/TouchesBboxFeatureSource"
import ThemeViewStateHashActor from "../Logic/Web/ThemeViewStateHashActor"
import NoElementsInViewDetector, {
FeatureViewState,
} from "../Logic/Actors/NoElementsInViewDetector"
import FilteredLayer from "./FilteredLayer"
import { PreferredRasterLayerSelector } from "../Logic/Actors/PreferredRasterLayerSelector"
import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager"
import { Imgur } from "../Logic/ImageProviders/Imgur"
/**
*
@ -98,6 +105,8 @@ export default class ThemeViewState implements SpecialVisualizationState {
readonly userRelatedState: UserRelatedState
readonly geolocation: GeoLocationHandler
readonly imageUploadManager: ImageUploadManager
readonly lastClickObject: WritableFeatureSource
readonly overlayLayerStates: ReadonlyMap<
string,
@ -109,6 +118,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
readonly floors: Store<string[]>
constructor(layout: LayoutConfig) {
Utils.initDomPurify()
this.layout = layout
this.featureSwitches = new FeatureSwitchState(layout)
this.guistate = new MenuState(
@ -137,7 +147,8 @@ export default class ThemeViewState implements SpecialVisualizationState {
this.osmConnection,
layout?.language,
layout,
this.featureSwitches
this.featureSwitches,
this.mapProperties
)
this.userRelatedState.fixateNorth.addCallbackAndRunD((fixated) => {
this.mapProperties.allowRotating.setData(fixated !== "yes")
@ -239,7 +250,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
this.newFeatures = new NewGeometryFromChangesFeatureSource(
this.changes,
indexedElements,
this.osmConnection.Backend()
this.featureProperties
)
layoutSource.addSource(this.newFeatures)
@ -280,7 +291,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
}
const floors = new Set<string>()
for (const feature of features) {
const level = feature.properties["level"]
let level = feature.properties["_level"]
if (level) {
const levels = level.split(";")
for (const l of levels) {
@ -319,6 +330,13 @@ export default class ThemeViewState implements SpecialVisualizationState {
this.perLayerFiltered = this.showNormalDataOn(this.map)
this.hasDataInView = new NoElementsInViewDetector(this).hasFeatureInView
this.imageUploadManager = new ImageUploadManager(
layout,
Imgur.singleton,
this.featureProperties,
this.osmConnection,
this.changes
)
this.initActors()
this.addLastClick(lastClick)
@ -599,5 +617,11 @@ export default class ThemeViewState implements SpecialVisualizationState {
new PendingChangesUploader(this.changes, this.selectedElement)
new SelectedElementTagsUpdater(this)
new BackgroundLayerResetter(this.mapProperties.rasterLayer, this.availableLayers)
new PreferredRasterLayerSelector(
this.mapProperties.rasterLayer,
this.availableLayers,
this.featureSwitches.backgroundLayerId,
this.userRelatedState.preferredBackgroundLayer
)
}
}

View file

@ -10,12 +10,13 @@
const dispatch = createEventDispatcher<{ click }>()
export let clss: string | undefined = undefined
export let imageClass: string | undefined = undefined
</script>
<SubtleButton
on:click={() => dispatch("click")}
options={{ extraClasses: twMerge("flex items-center", clss) }}
>
<ChevronLeftIcon class="h-12 w-12" slot="image" />
<ChevronLeftIcon class={imageClass ?? "h-12 w-12"} slot="image" />
<slot slot="message" />
</SubtleButton>

View file

@ -1,32 +0,0 @@
import BaseUIElement from "../BaseUIElement"
export class CenterFlexedElement extends BaseUIElement {
private _html: string
constructor(html: string) {
super()
this._html = html ?? ""
}
InnerRender(): string {
return this._html
}
AsMarkdown(): string {
return this._html
}
protected InnerConstructElement(): HTMLElement {
const e = document.createElement("div")
e.innerHTML = this._html
e.style.display = "flex"
e.style.height = "100%"
e.style.width = "100%"
e.style.flexDirection = "column"
e.style.flexWrap = "nowrap"
e.style.alignContent = "center"
e.style.justifyContent = "center"
e.style.alignItems = "center"
return e
}
}

View file

@ -1,12 +1,15 @@
<script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource.js"
import type { Writable } from "svelte/store"
/**
* For some stupid reason, it is very hard to bind inputs
*/
export let selected: UIEventSource<boolean>
export let selected: Writable<boolean>
let _c: boolean = selected.data ?? true
$: selected.setData(_c)
$: selected.set(_c)
</script>
<input type="checkbox" bind:checked={_c} />
<label class="no-image-background flex gap-1">
<input bind:checked={_c} type="checkbox" />
<slot />
</label>

View file

@ -0,0 +1,48 @@
<script lang="ts">
import { createEventDispatcher } from "svelte"
import { twMerge } from "tailwind-merge"
export let accept: string
export let multiple: boolean = true
const dispatcher = createEventDispatcher<{ submit: FileList }>()
export let cls: string = ""
let drawAttention = false
let inputElement: HTMLInputElement
let id = Math.random() * 1000000000 + ""
</script>
<form>
<label class={twMerge(cls, drawAttention ? "glowing-shadow" : "")} for={"fileinput" + id}>
<slot />
</label>
<input
{accept}
bind:this={inputElement}
class="hidden"
id={"fileinput" + id}
{multiple}
name="file-input"
on:change|preventDefault={() => {
drawAttention = false
dispatcher("submit", inputElement.files)
}}
on:dragend={() => {
drawAttention = false
}}
on:dragover|preventDefault|stopPropagation={(e) => {
console.log("Dragging over!")
drawAttention = true
e.dataTransfer.drop = "copy"
}}
on:dragstart={() => {
drawAttention = false
}}
on:drop|preventDefault|stopPropagation={(e) => {
console.log("Got a 'drop'")
drawAttention = false
dispatcher("submit", e.dataTransfer.files)
}}
type="file"
/>
</form>

View file

@ -1,5 +1,8 @@
import BaseUIElement from "../BaseUIElement"
import { Utils } from "../../Utils"
/**
* @deprecated
*/
export class FixedUiElement extends BaseUIElement {
public readonly content: string
@ -8,10 +11,6 @@ export class FixedUiElement extends BaseUIElement {
this.content = html ?? ""
}
InnerRender(): string {
return this.content
}
AsMarkdown(): string {
if (this.HasClass("code")) {
if (this.content.indexOf("\n") > 0 || this.HasClass("block")) {
@ -27,7 +26,7 @@ export class FixedUiElement extends BaseUIElement {
protected InnerConstructElement(): HTMLElement {
const e = document.createElement("span")
e.innerHTML = this.content
e.innerHTML = Utils.purify(this.content)
return e
}
}

View file

@ -2,12 +2,14 @@
/**
* Given an HTML string, properly shows this
*/
import { Utils } from "../../Utils"
export let src: string
let htmlElem: HTMLElement
$: {
if (htmlElem) {
htmlElem.innerHTML = src
htmlElem.innerHTML = Utils.purify(src)
}
}

View file

@ -1,18 +1,22 @@
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
import { Store } from "../../Logic/UIEventSource"
import { Utils } from "../../Utils"
export default class Link extends BaseUIElement {
private readonly _href: string | Store<string>
private readonly _embeddedShow: BaseUIElement
private readonly _newTab: boolean
private readonly _download: string
constructor(
embeddedShow: BaseUIElement | string,
href: string | Store<string>,
newTab: boolean = false
newTab: boolean = false,
download: string = undefined
) {
super()
this._download = download
this._embeddedShow = Translations.W(embeddedShow)
this._href = href
this._newTab = newTab
@ -49,15 +53,18 @@ export default class Link extends BaseUIElement {
}
const el = document.createElement("a")
if (typeof this._href === "string") {
el.href = this._href
el.setAttribute("href", this._href)
} else {
this._href.addCallbackAndRun((href) => {
el.href = href
el.setAttribute("href", href)
})
}
if (this._newTab) {
el.target = "_blank"
}
if (this._download) {
el.setAttribute("download", this._download)
}
el.appendChild(embeddedShow)
return el
}

View file

@ -1,9 +1,12 @@
<script>
<script lang="ts">
import ToSvelte from "./ToSvelte.svelte"
import Svg from "../../Svg"
import { twMerge } from "tailwind-merge"
export let cls: string = undefined
</script>
<div class="flex p-1 pl-2">
<div class={twMerge("flex p-1 pl-2", cls)}>
<div class="min-w-6 h-6 w-6 animate-spin self-center">
<ToSvelte construct={Svg.loading_svg()} />
</div>

View file

@ -20,6 +20,6 @@
<slot name="image" slot="image" />
<div class="flex w-full items-center justify-between" slot="message">
<slot />
<ChevronRightIcon class="h-12 w-12" />
<ChevronRightIcon class="h-12 w-12 shrink-0" />
</div>
</SubtleButton>

View file

@ -29,7 +29,15 @@ export default class Table extends BaseUIElement {
const header = Utils.NoNull(headerMarkdownParts).join(" | ")
const headerSep = headerMarkdownParts.map((part) => "-".repeat(part.length + 2)).join(" | ")
const table = this._contents
.map((row) => row.map((el) => el?.AsMarkdown()?.replaceAll("\\","\\\\")?.replaceAll("|", "\\|") ?? " ").join(" | "))
.map((row) =>
row
.map(
(el) =>
el?.AsMarkdown()?.replaceAll("\\", "\\\\")?.replaceAll("|", "\\|") ??
" "
)
.join(" | ")
)
.join("\n")
return "\n\n" + [header, headerSep, table, ""].join("\n")

View file

@ -1,7 +1,11 @@
import { Store } from "../../Logic/UIEventSource"
import BaseUIElement from "../BaseUIElement"
import Combine from "./Combine"
import { Utils } from "../../Utils"
/**
* @deprecated
*/
export class VariableUiElement extends BaseUIElement {
private readonly _contents?: Store<string | BaseUIElement | BaseUIElement[]>
@ -42,7 +46,7 @@ export class VariableUiElement extends BaseUIElement {
return
}
if (typeof contents === "string") {
el.innerHTML = contents
el.innerHTML = Utils.purify(contents)
} else if (contents instanceof Array) {
for (const content of contents) {
const c = content?.ConstructElement()

View file

@ -37,7 +37,7 @@
function updatedAltLayer() {
const available = availableRasterLayers.data
const current = rasterLayer.data
const defaultLayer = AvailableRasterLayers.maplibre
const defaultLayer = AvailableRasterLayers.maptilerDefaultLayer
const firstOther = available.find((l) => l !== defaultLayer)
const secondOther = available.find((l) => l !== defaultLayer && l !== firstOther)
raster0.setData(firstOther === current ? defaultLayer : firstOther)

View file

@ -35,7 +35,12 @@
src={`https://raw.githubusercontent.com/pietervdvn/MapComplete-data/main/community_index/${resource.type}.svg`}
/>
<div class="flex flex-col">
<a href={resource.resolved.url} target="_blank" rel="noreferrer nofollow noopener" class="font-bold">
<a
href={resource.resolved.url}
target="_blank"
rel="noreferrer nofollow noopener"
class="font-bold"
>
{resource.resolved.name ?? resource.resolved.url}
</a>
{resource.resolved?.description}

View file

@ -102,7 +102,11 @@ export default class CopyrightPanel extends Combine {
let bgAttr: BaseUIElement | string = undefined
if (attrText && attrUrl) {
bgAttr =
"<a href='" + attrUrl + "' target='_blank' rel='noopener'>" + attrText + "</a>"
"<a href='" +
attrUrl +
"' target='_blank' rel='noopener'>" +
attrText +
"</a>"
} else if (attrUrl) {
bgAttr = attrUrl
} else {

View file

@ -55,8 +55,7 @@
{#if filteredLayer.layerDef.name}
<div bind:this={mainElem} class="mb-1.5">
<label class="no-image-background flex gap-1">
<Checkbox selected={isDisplayed} />
<Checkbox selected={isDisplayed}>
<If condition={filteredLayer.isDisplayed}>
<ToSvelte
construct={() => layer.defaultIcon()?.SetClass("block h-6 w-6 no-image-background")}
@ -75,7 +74,7 @@
<Tr t={Translations.t.general.layerSelection.zoomInToSeeThisLayer} />
</span>
{/if}
</label>
</Checkbox>
{#if $isDisplayed && filteredLayer.layerDef.filters?.length > 0}
<div id="subfilters" class="ml-4 flex flex-col gap-y-1">
@ -83,10 +82,9 @@
<div>
<!-- There are three (and a half) modes of filters: a single checkbox, a radio button/dropdown or with searchable fields -->
{#if filter.options.length === 1 && filter.options[0].fields.length === 0}
<label>
<Checkbox selected={getBooleanStateFor(filter)} />
<Checkbox selected={getBooleanStateFor(filter)}>
{filter.options[0].question}
</label>
</Checkbox>
{/if}
{#if filter.options.length === 1 && filter.options[0].fields.length > 0}

View file

@ -1,127 +0,0 @@
import { VariableUiElement } from "../Base/VariableUIElement"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import PlantNet from "../../Logic/Web/PlantNet"
import Loading from "../Base/Loading"
import Wikidata from "../../Logic/Web/Wikidata"
import WikidataPreviewBox from "../Wikipedia/WikidataPreviewBox"
import { Button } from "../Base/Button"
import Combine from "../Base/Combine"
import Title from "../Base/Title"
import Translations from "../i18n/Translations"
import List from "../Base/List"
import Svg from "../../Svg"
export default class PlantNetSpeciesSearch extends VariableUiElement {
/***
* Given images, queries plantnet to search a species matching those images.
* A list of species will be presented to the user, after which they can confirm an item.
* The wikidata-url is returned in the callback when the user selects one
*/
constructor(images: Store<string[]>, onConfirm: (wikidataUrl: string) => Promise<void>) {
const t = Translations.t.plantDetection
super(
images
.bind((images) => {
if (images.length === 0) {
return null
}
return UIEventSource.FromPromiseWithErr(PlantNet.query(images.slice(0, 5)))
})
.map((result) => {
if (images.data.length === 0) {
return new Combine([
t.takeImages,
t.howTo.intro,
new List([t.howTo.li0, t.howTo.li1, t.howTo.li2, t.howTo.li3]),
]).SetClass("flex flex-col")
}
if (result === undefined) {
return new Loading(t.querying.Subs(images.data))
}
if (result["error"] !== undefined) {
return t.error.Subs(<any>result).SetClass("alert")
}
console.log(result)
const success = result["success"]
const selectedSpecies = new UIEventSource<string>(undefined)
const speciesInformation = success.results
.filter((species) => species.score >= 0.005)
.map((species) => {
const wikidata = UIEventSource.FromPromise(
Wikidata.Sparql<{ species }>(
["?species", "?speciesLabel"],
['?species wdt:P846 "' + species.gbif.id + '"']
)
)
const confirmButton = new Button(t.seeInfo, async () => {
await selectedSpecies.setData(wikidata.data[0].species?.value)
}).SetClass("btn")
const match = t.matchPercentage
.Subs({ match: Math.round(species.score * 100) })
.SetClass("font-bold")
const extraItems = new Combine([match, confirmButton]).SetClass(
"flex flex-col"
)
return new WikidataPreviewBox(
wikidata.map((wd) =>
wd == undefined ? undefined : wd[0]?.species?.value
),
{
whileLoading: new Loading(
t.loadingWikidata.Subs({
species: species.species.scientificNameWithoutAuthor,
})
),
extraItems: [new Combine([extraItems])],
imageStyle: "max-width: 8rem; width: unset; height: 8rem",
}
).SetClass("border-2 border-subtle rounded-xl block mb-2")
})
const plantOverview = new Combine([
new Title(t.overviewTitle),
t.overviewIntro,
t.overviewVerify.SetClass("font-bold"),
...speciesInformation,
]).SetClass("flex flex-col")
return new VariableUiElement(
selectedSpecies.map((wikidataSpecies) => {
if (wikidataSpecies === undefined) {
return plantOverview
}
return new Combine([
new Button(
new Combine([
Svg.back_svg().SetClass(
"w-6 mr-1 bg-white rounded-full p-1"
),
t.back,
]).SetClass("flex"),
() => {
selectedSpecies.setData(undefined)
}
).SetClass("btn btn-secondary"),
new Button(
new Combine([
Svg.confirm_svg().SetClass("w-6 mr-1"),
t.confirm,
]).SetClass("flex"),
() => {
onConfirm(wikidataSpecies)
}
).SetClass("btn"),
]).SetClass("flex justify-between")
})
)
})
)
}
}

View file

@ -46,7 +46,7 @@
>
{#each layer.titleIcons as titleIconConfig}
{#if (titleIconConfig.condition?.matchesProperties(_tags) ?? true) && (titleIconConfig.metacondition?.matchesProperties( { ..._metatags, ..._tags } ) ?? true) && titleIconConfig.IsKnown(_tags)}
<div class="flex h-8 w-8 items-center">
<div class={titleIconConfig.renderIconClass ?? "flex h-8 w-8 items-center"}>
<TagRenderingAnswer
config={titleIconConfig}
{tags}

View file

@ -4,14 +4,13 @@
import Tr from "../Base/Tr.svelte"
import NextButton from "../Base/NextButton.svelte"
import Geosearch from "./Geosearch.svelte"
import IfNot from "../Base/IfNot.svelte"
import ToSvelte from "../Base/ToSvelte.svelte"
import ThemeViewState from "../../Models/ThemeViewState"
import If from "../Base/If.svelte"
import { UIEventSource } from "../../Logic/UIEventSource"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { SearchIcon } from "@rgossiaux/svelte-heroicons/solid"
import { twJoin } from "tailwind-merge"
import { Utils } from "../../Utils"
import type { GeolocationPermissionState } from "../../Logic/State/GeoLocationState"
/**
* The theme introduction panel
@ -24,6 +23,12 @@
let triggerSearch: UIEventSource<any> = new UIEventSource<any>(undefined)
let searchEnabled = false
let geopermission: Store<GeolocationPermissionState> =
state.geolocation.geolocationState.permission
let currentGPSLocation = state.geolocation.geolocationState.currentGPSLocation
geopermission.addCallback((perm) => console.log(">>>> Permission", perm))
function jumpToCurrentLocation() {
const glstate = state.geolocation.geolocationState
if (glstate.currentGPSLocation.data !== undefined) {
@ -58,12 +63,37 @@
</NextButton>
<div class="flex w-full flex-wrap sm:flex-nowrap">
<IfNot condition={state.geolocation.geolocationState.permission.map((p) => p === "denied")}>
{#if $currentGPSLocation !== undefined || $geopermission === "prompt"}
<button class="flex w-full items-center gap-x-2" on:click={jumpToCurrentLocation}>
<ToSvelte construct={Svg.crosshair_svg().SetClass("w-8 h-8")} />
<Tr t={Translations.t.general.openTheMapAtGeolocation} />
</button>
</IfNot>
<!-- No geolocation granted - we don't show the button -->
{:else if $geopermission === "requested"}
<button class="disabled flex w-full items-center gap-x-2" on:click={jumpToCurrentLocation}>
<!-- Even though disabled, when clicking we request the location again in case the contributor dismissed the location popup -->
<ToSvelte
construct={Svg.crosshair_svg()
.SetClass("w-8 h-8")
.SetStyle("animation: 3s linear 0s infinite normal none running spin;")}
/>
<Tr t={Translations.t.general.waitingForGeopermission} />
</button>
{:else if $geopermission === "denied"}
<button class="disabled flex w-full items-center gap-x-2">
<ToSvelte construct={Svg.location_refused_svg().SetClass("w-8 h-8")} />
<Tr t={Translations.t.general.geopermissionDenied} />
</button>
{:else}
<button class="disabled flex w-full items-center gap-x-2">
<ToSvelte
construct={Svg.crosshair_svg()
.SetClass("w-8 h-8")
.SetStyle("animation: 3s linear 0s infinite normal none running spin;")}
/>
<Tr t={Translations.t.general.waitingForLocation} />
</button>
{/if}
<div class=".button low-interaction m-1 flex w-full items-center gap-x-2 rounded border p-2">
<div class="w-full">

View file

@ -29,7 +29,7 @@
const templateUrls = SvgToPdf.templates[templateName].pages
const templates: string[] = await Promise.all(templateUrls.map((url) => Utils.download(url)))
console.log("Templates are", templates)
const bg = state.mapProperties.rasterLayer.data ?? AvailableRasterLayers.maplibre
const bg = state.mapProperties.rasterLayer.data ?? AvailableRasterLayers.maptilerDefaultLayer
const creator = new SvgToPdf(title, templates, {
state,
freeComponentId: "belowmap",

View file

@ -1,202 +0,0 @@
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import Svg from "../../Svg"
import { Tag } from "../../Logic/Tags/Tag"
import BaseUIElement from "../BaseUIElement"
import Toggle from "../Input/Toggle"
import FileSelectorButton from "../Input/FileSelectorButton"
import ImgurUploader from "../../Logic/ImageProviders/ImgurUploader"
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { FixedUiElement } from "../Base/FixedUiElement"
import { VariableUiElement } from "../Base/VariableUIElement"
import Loading from "../Base/Loading"
import { LoginToggle } from "../Popup/LoginButton"
import Constants from "../../Models/Constants"
import { SpecialVisualizationState } from "../SpecialVisualization"
export class ImageUploadFlow extends Toggle {
private static readonly uploadCountsPerId = new Map<string, UIEventSource<number>>()
constructor(
tagsSource: Store<any>,
state: SpecialVisualizationState,
imagePrefix: string = "image",
text: string = undefined
) {
const perId = ImageUploadFlow.uploadCountsPerId
const id = tagsSource.data.id
if (!perId.has(id)) {
perId.set(id, new UIEventSource<number>(0))
}
const uploadedCount = perId.get(id)
const uploader = new ImgurUploader(async (url) => {
// A file was uploaded - we add it to the tags of the object
const tags = tagsSource.data
let key = imagePrefix
if (tags[imagePrefix] !== undefined) {
let freeIndex = 0
while (tags[imagePrefix + ":" + freeIndex] !== undefined) {
freeIndex++
}
key = imagePrefix + ":" + freeIndex
}
await state.changes.applyAction(
new ChangeTagAction(tags.id, new Tag(key, url), tagsSource.data, {
changeType: "add-image",
theme: state.layout.id,
})
)
console.log("Adding image:" + key, url)
uploadedCount.data++
uploadedCount.ping()
})
const t = Translations.t.image
let labelContent: BaseUIElement
if (text === undefined) {
labelContent = Translations.t.image.addPicture
.Clone()
.SetClass("block align-middle mt-1 ml-3 text-4xl ")
} else {
labelContent = new FixedUiElement(text).SetClass(
"block align-middle mt-1 ml-3 text-2xl "
)
}
const label = new Combine([
Svg.camera_plus_svg().SetClass("block w-12 h-12 p-1 text-4xl "),
labelContent,
]).SetClass("w-full flex justify-center items-center")
const licenseStore = state?.osmConnection?.GetPreference(
"pictures-license",
"CC0"
)
const fileSelector = new FileSelectorButton(label, {
acceptType: "image/*",
allowMultiple: true,
labelClasses: "rounded-full border-2 border-black font-bold",
})
/* fileSelector.SetClass(
"p-2 border-4 border-detail rounded-full font-bold h-full align-middle w-full flex justify-center"
)
.SetStyle(" border-color: var(--foreground-color);")*/
fileSelector.GetValue().addCallback((filelist) => {
if (filelist === undefined || filelist.length === 0) {
return
}
for (var i = 0; i < filelist.length; i++) {
const sizeInBytes = filelist[i].size
console.log(filelist[i].name + " has a size of " + sizeInBytes + " Bytes")
if (sizeInBytes > uploader.maxFileSizeInMegabytes * 1000000) {
alert(
Translations.t.image.toBig.Subs({
actual_size: Math.floor(sizeInBytes / 1000000) + "MB",
max_size: uploader.maxFileSizeInMegabytes + "MB",
}).txt
)
return
}
}
const license = licenseStore?.data ?? "CC0"
const tags = tagsSource.data
const layout = state?.layout
let matchingLayer: LayerConfig = undefined
for (const layer of layout?.layers ?? []) {
if (layer.source.osmTags.matchesProperties(tags)) {
matchingLayer = layer
break
}
}
const title =
matchingLayer?.title?.GetRenderValue(tags)?.Subs(tags)?.ConstructElement()
?.textContent ??
tags.name ??
"https//osm.org/" + tags.id
const description = [
"author:" + state.osmConnection.userDetails.data.name,
"license:" + license,
"osmid:" + tags.id,
].join("\n")
uploader.uploadMany(title, description, filelist)
})
const uploadFlow: BaseUIElement = new Combine([
new VariableUiElement(
uploader.queue
.map((q) => q.length)
.map((l) => {
if (l == 0) {
return undefined
}
if (l == 1) {
return new Loading(t.uploadingPicture).SetClass("alert")
} else {
return new Loading(
t.uploadingMultiple.Subs({ count: "" + l })
).SetClass("alert")
}
})
),
new VariableUiElement(
uploader.failed
.map((q) => q.length)
.map((l) => {
if (l == 0) {
return undefined
}
console.log(l)
return t.uploadFailed.SetClass("block alert")
})
),
new VariableUiElement(
uploadedCount.map((l) => {
if (l == 0) {
return undefined
}
if (l == 1) {
return t.uploadDone.Clone().SetClass("thanks block")
}
return t.uploadMultipleDone.Subs({ count: l }).SetClass("thanks block")
})
),
fileSelector,
new Combine([
Translations.t.image.respectPrivacy,
new VariableUiElement(
licenseStore.map((license) =>
Translations.t.image.currentLicense.Subs({ license })
)
)
.onClick(() => {
console.log("Opening the license settings... ")
state.guistate.openUsersettings("picture-license")
})
.SetClass("underline"),
]).SetStyle("font-size:small;"),
]).SetClass("flex flex-col image-upload-flow mt-4 mb-8 text-center leading-none")
super(
new LoginToggle(
/*We can show the actual upload button!*/
uploadFlow,
/* User not logged in*/ t.pleaseLogin.Clone(),
state
),
undefined /* Nothing as the user badge is disabled*/,
state?.featureSwitchUserbadge
)
}
}

View file

@ -0,0 +1,81 @@
<script lang="ts">
/**
* Shows an 'upload'-button which will start the upload for this feature
*/
import type { SpecialVisualizationState } from "../SpecialVisualization"
import { Store } from "../../Logic/UIEventSource"
import type { OsmTags } from "../../Models/OsmFeature"
import LoginToggle from "../Base/LoginToggle.svelte"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
import UploadingImageCounter from "./UploadingImageCounter.svelte"
import FileSelector from "../Base/FileSelector.svelte"
import ToSvelte from "../Base/ToSvelte.svelte"
import Svg from "../../Svg"
export let state: SpecialVisualizationState
export let tags: Store<OsmTags>
/**
* Image to show in the button
* NOT the image to upload!
*/
export let image: string = undefined
if (image === "") {
image = undefined
}
export let labelText: string = undefined
const t = Translations.t.image
let licenseStore = state.userRelatedState.imageLicense
function handleFiles(files: FileList) {
for (let i = 0; i < files.length; i++) {
const file = files.item(i)
console.log("Got file", file.name)
try {
state.imageUploadManager.uploadImageAndApply(file, tags)
} catch (e) {
alert(e)
}
}
}
</script>
<LoginToggle {state}>
<Tr slot="not-logged-in" t={t.pleaseLogin} />
<div class="flex flex-col">
<UploadingImageCounter {state} {tags} />
<FileSelector
accept="image/*"
cls="button border-2 text-2xl"
multiple={true}
on:submit={(e) => handleFiles(e.detail)}
>
<div class="flex items-center">
{#if image !== undefined}
<img src={image} />
{:else}
<ToSvelte construct={Svg.camera_plus_svg().SetClass("block w-12 h-12 p-1 text-4xl ")} />
{/if}
{#if labelText}
{labelText}
{:else}
<Tr t={t.addPicture} />
{/if}
</div>
</FileSelector>
<div class="text-sm">
<Tr t={t.respectPrivacy} />
<a
class="cursor-pointer"
on:click={() => {
state.guistate.openUsersettings("picture-license")
}}
>
<Tr t={t.currentLicense.Subs({ license: $licenseStore })} />
</a>
</div>
</div>
</LoginToggle>

View file

@ -0,0 +1,67 @@
<script lang="ts">
/**
* Shows information about how much images are uploaded for the given feature
*/
import type { SpecialVisualizationState } from "../SpecialVisualization"
import { Store } from "../../Logic/UIEventSource"
import type { OsmTags } from "../../Models/OsmFeature"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
import Loading from "../Base/Loading.svelte"
export let state: SpecialVisualizationState
export let tags: Store<OsmTags>
const featureId = tags.data.id
const { uploadStarted, uploadFinished, retried, failed } =
state.imageUploadManager.getCountsFor(featureId)
const t = Translations.t.image
</script>
{#if $uploadStarted == 1}
{#if $uploadFinished == 1}
<Tr cls="thanks" t={t.upload.one.done} />
{:else if $failed == 1}
<div class="alert flex flex-col">
<Tr cls="self-center" t={t.upload.one.failed} />
<Tr t={t.upload.failReasons} />
<Tr t={t.upload.failReasonsAdvanced} />
</div>
{:else if $retried == 1}
<Loading cls="alert">
<Tr t={t.upload.one.retrying} />
</Loading>
{:else}
<Loading cls="alert">
<Tr t={t.upload.one.uploading} />
</Loading>
{/if}
{:else if $uploadStarted > 1}
{#if $uploadFinished + $failed == $uploadStarted && $uploadFinished > 0}
<Tr cls="thanks" t={t.upload.multiple.done.Subs({ count: $uploadFinished })} />
{:else if $uploadFinished == 0}
<Loading cls="alert">
<Tr t={t.upload.multiple.uploading.Subs({ count: $uploadStarted })} />
</Loading>
{:else if $uploadFinished > 0}
<Loading cls="alert">
<Tr
t={t.upload.multiple.partiallyDone.Subs({
count: $uploadStarted - $uploadFinished,
done: $uploadFinished,
})}
/>
</Loading>
{/if}
{#if $failed > 0}
<div class="alert flex flex-col">
{#if failed === 1}
<Tr cls="self-center" t={t.upload.one.failed} />
{:else}
<Tr cls="self-center" t={t.upload.multiple.someFailed.Subs({ count: $failed })} />
{/if}
<Tr t={t.upload.failReasons} />
<Tr t={t.upload.failReasonsAdvanced} />
</div>
{/if}
{/if}

View file

@ -1,111 +0,0 @@
import BaseUIElement from "../BaseUIElement"
import { InputElement } from "./InputElement"
import { UIEventSource } from "../../Logic/UIEventSource"
/**
* @deprecated
*/
export default class FileSelectorButton extends InputElement<FileList> {
private static _nextid = 0
private readonly _value = new UIEventSource<FileList>(undefined)
private readonly _label: BaseUIElement
private readonly _acceptType: string
private readonly allowMultiple: boolean
private readonly _labelClasses: string
constructor(
label: BaseUIElement,
options?: {
acceptType: "image/*" | string
allowMultiple: true | boolean
labelClasses?: string
}
) {
super()
this._label = label
this._acceptType = options?.acceptType ?? "image/*"
this._labelClasses = options?.labelClasses ?? ""
this.SetClass("block cursor-pointer")
label.SetClass("cursor-pointer")
this.allowMultiple = options?.allowMultiple ?? true
}
GetValue(): UIEventSource<FileList> {
return this._value
}
IsValid(t: FileList): boolean {
return true
}
protected InnerConstructElement(): HTMLElement {
const self = this
const el = document.createElement("form")
const label = document.createElement("label")
label.appendChild(this._label.ConstructElement())
label.classList.add(...this._labelClasses.split(" ").filter((t) => t !== ""))
el.appendChild(label)
const actualInputElement = document.createElement("input")
actualInputElement.style.cssText = "display:none"
actualInputElement.type = "file"
actualInputElement.accept = this._acceptType
actualInputElement.name = "picField"
actualInputElement.multiple = this.allowMultiple
actualInputElement.id = "fileselector" + FileSelectorButton._nextid
FileSelectorButton._nextid++
label.htmlFor = actualInputElement.id
actualInputElement.onchange = () => {
if (actualInputElement.files !== null) {
self._value.setData(actualInputElement.files)
}
}
el.addEventListener("submit", (e) => {
if (actualInputElement.files !== null) {
self._value.setData(actualInputElement.files)
}
actualInputElement.classList.remove("glowing-shadow")
e.preventDefault()
})
el.appendChild(actualInputElement)
function setDrawAttention(isOn: boolean) {
if (isOn) {
label.classList.add("glowing-shadow")
} else {
label.classList.remove("glowing-shadow")
}
}
el.addEventListener("dragover", (event) => {
event.stopPropagation()
event.preventDefault()
setDrawAttention(true)
// Style the drag-and-drop as a "copy file" operation.
event.dataTransfer.dropEffect = "copy"
})
window.document.addEventListener("dragenter", () => {
setDrawAttention(true)
})
window.document.addEventListener("dragend", () => {
setDrawAttention(false)
})
el.addEventListener("drop", (event) => {
event.stopPropagation()
event.preventDefault()
label.classList.remove("glowing-shadow")
const fileList = event.dataTransfer.files
this._value.setData(fileList)
})
return el
}
}

View file

@ -1,62 +0,0 @@
import { InputElement } from "./InputElement"
import { UIEventSource } from "../../Logic/UIEventSource"
/**
* @deprecated
*/
export default class Slider extends InputElement<number> {
private readonly _value: UIEventSource<number>
private readonly min: number
private readonly max: number
private readonly step: number
private readonly vertical: boolean
/**
* Constructs a slider input element for natural numbers
* @param min: the minimum value that is allowed, inclusive
* @param max: the max value that is allowed, inclusive
* @param options: value: injectable value; step: the step size of the slider
*/
constructor(
min: number,
max: number,
options?: {
value?: UIEventSource<number>
step?: 1 | number
vertical?: false | boolean
}
) {
super()
this.max = max
this.min = min
this._value = options?.value ?? new UIEventSource<number>(min)
this.step = options?.step ?? 1
this.vertical = options?.vertical ?? false
}
GetValue(): UIEventSource<number> {
return this._value
}
protected InnerConstructElement(): HTMLElement {
const el = document.createElement("input")
el.type = "range"
el.min = "" + this.min
el.max = "" + this.max
el.step = "" + this.step
const valuestore = this._value
el.oninput = () => {
valuestore.setData(Number(el.value))
}
if (this.vertical) {
el.classList.add("vertical")
el.setAttribute("orient", "vertical") // firefox only workaround...
}
valuestore.addCallbackAndRunD((v) => (el.value = "" + valuestore.data))
return el
}
IsValid(t: number): boolean {
return Math.round(t) == t && t >= this.min && t <= this.max
}
}

View file

@ -40,7 +40,7 @@ export default class FediverseValidator extends Validator {
if (match) {
const host = match[2]
try {
const url = new URL("https://" + host)
new URL("https://" + host)
return undefined
} catch (e) {
return Translations.t.validation.fediverse.invalidHost.Subs({ host })

View file

@ -12,7 +12,7 @@ import { QueryParameters } from "../Logic/Web/QueryParameters"
export default class LanguagePicker extends Toggle {
constructor(languages: string[], assignTo: UIEventSource<string>) {
console.log("Constructing a language pîcker for languages", languages)
console.log("Constructing a language picker for languages", languages)
if (
languages === undefined ||
languages.length <= 1 ||

View file

@ -92,7 +92,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
maplibreMap.addCallbackAndRunD((map) => {
map.on("load", () => {
self.setBackground()
map.resize()
self.MoveMapToCurrentLoc(self.location.data)
self.SetZoom(self.zoom.data)
self.setMaxBounds(self.maxbounds.data)
@ -102,8 +102,10 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
self.setMinzoom(self.minzoom.data)
self.setMaxzoom(self.maxzoom.data)
self.setBounds(self.bounds.data)
self.setBackground()
this.updateStores(true)
})
map.resize()
self.MoveMapToCurrentLoc(self.location.data)
self.SetZoom(self.zoom.data)
self.setMaxBounds(self.maxbounds.data)
@ -113,6 +115,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
self.setMinzoom(self.minzoom.data)
self.setMaxzoom(self.maxzoom.data)
self.setBounds(self.bounds.data)
self.setBackground()
this.updateStores(true)
map.on("moveend", () => this.updateStores())
map.on("click", (e) => {
@ -126,7 +129,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
})
})
this.rasterLayer.addCallback((_) =>
this.rasterLayer.addCallbackAndRun((_) =>
self.setBackground().catch((_) => {
console.error("Could not set background")
})
@ -376,12 +379,6 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
}
const background: RasterLayerProperties = this.rasterLayer?.data?.properties
if (!background) {
console.error(
"Attempting to 'setBackground', but the background is",
background,
"for",
map.getCanvas()
)
return
}
if (this._currentRasterLayer === background.id) {
@ -408,7 +405,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
this.removeCurrentLayer(map)
} else {
// Make sure that the default maptiler style is loaded as it gives an overlay with roads
const maptiler = AvailableRasterLayers.maplibre.properties
const maptiler = AvailableRasterLayers.maptilerDefaultLayer.properties
if (!map.getSource(maptiler.id)) {
this.removeCurrentLayer(map)
map.addSource(maptiler.id, MapLibreAdaptor.prepareWmsSource(maptiler))
@ -423,7 +420,6 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
if (!map.getSource(background.id)) {
map.addSource(background.id, MapLibreAdaptor.prepareWmsSource(background))
}
map.resize()
if (!map.getLayer(background.id)) {
map.addLayer(
{
@ -436,7 +432,9 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
)
}
await this.awaitStyleIsLoaded()
this.removeCurrentLayer(map)
if (this._currentRasterLayer !== background?.id) {
this.removeCurrentLayer(map)
}
this._currentRasterLayer = background?.id
}
@ -457,13 +455,14 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
if (!map) {
return
}
console.log("Rotation allowed:", allow)
if (allow === false) {
map.rotateTo(0, { duration: 0 })
map.setPitch(0)
map.dragRotate.disable()
map.touchZoomRotate.disableRotation()
} else {
map.dragRotate.enable()
map.touchZoomRotate.enableRotation()
}
}

View file

@ -24,7 +24,7 @@
writable({ lng: 0, lat: 0 })
export let zoom: Readable<number> = writable(1)
const styleUrl = AvailableRasterLayers.maplibre.properties.url
const styleUrl = AvailableRasterLayers.maptilerDefaultLayer.properties.url
let _map: Map
onMount(() => {

View file

@ -1,21 +1,21 @@
import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
import type { Map as MlMap } from "maplibre-gl"
import { GeoJSONSource, Marker } from "maplibre-gl"
import { ShowDataLayerOptions } from "./ShowDataLayerOptions"
import { GeoOperations } from "../../Logic/GeoOperations"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import PointRenderingConfig from "../../Models/ThemeConfig/PointRenderingConfig"
import { OsmTags } from "../../Models/OsmFeature"
import { FeatureSource, FeatureSourceForLayer } from "../../Logic/FeatureSource/FeatureSource"
import { BBox } from "../../Logic/BBox"
import { Feature, Point } from "geojson"
import LineRenderingConfig from "../../Models/ThemeConfig/LineRenderingConfig"
import { Utils } from "../../Utils"
import * as range_layer from "../../../assets/layers/range/range.json"
import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson"
import PerLayerFeatureSourceSplitter from "../../Logic/FeatureSource/PerLayerFeatureSourceSplitter"
import FilteredLayer from "../../Models/FilteredLayer"
import SimpleFeatureSource from "../../Logic/FeatureSource/Sources/SimpleFeatureSource"
import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource";
import type { Map as MlMap } from "maplibre-gl";
import { GeoJSONSource, Marker } from "maplibre-gl";
import { ShowDataLayerOptions } from "./ShowDataLayerOptions";
import { GeoOperations } from "../../Logic/GeoOperations";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import PointRenderingConfig from "../../Models/ThemeConfig/PointRenderingConfig";
import { OsmTags } from "../../Models/OsmFeature";
import { FeatureSource, FeatureSourceForLayer } from "../../Logic/FeatureSource/FeatureSource";
import { BBox } from "../../Logic/BBox";
import { Feature, Point } from "geojson";
import LineRenderingConfig from "../../Models/ThemeConfig/LineRenderingConfig";
import { Utils } from "../../Utils";
import * as range_layer from "../../../assets/layers/range/range.json";
import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson";
import PerLayerFeatureSourceSplitter from "../../Logic/FeatureSource/PerLayerFeatureSourceSplitter";
import FilteredLayer from "../../Models/FilteredLayer";
import SimpleFeatureSource from "../../Logic/FeatureSource/Sources/SimpleFeatureSource";
class PointRenderingLayer {
private readonly _config: PointRenderingConfig
@ -284,86 +284,94 @@ class LineRenderingLayer {
// Already up to date
return
}
if (src === undefined) {
this.currentSourceData = features
map.addSource(this._layername, {
type: "geojson",
data: {
type: "FeatureCollection",
features,
},
promoteId: "id",
})
// @ts-ignore
const linelayer = this._layername + "_line"
map.addLayer({
source: this._layername,
id: linelayer,
type: "line",
paint: {
"line-color": ["feature-state", "color"],
"line-opacity": ["feature-state", "color-opacity"],
"line-width": ["feature-state", "width"],
"line-offset": ["feature-state", "offset"],
},
layout: {
"line-cap": "round",
},
})
map.on("click", linelayer, (e) => {
// line-layer-listener
e.originalEvent["consumed"] = true
this._onClick(e.features[0])
})
const polylayer = this._layername + "_polygon"
map.addLayer({
source: this._layername,
id: polylayer,
type: "fill",
filter: ["in", ["geometry-type"], ["literal", ["Polygon", "MultiPolygon"]]],
layout: {},
paint: {
"fill-color": ["feature-state", "fillColor"],
"fill-opacity": ["feature-state", "fillColor-opacity"],
},
})
if (this._onClick) {
map.on("click", polylayer, (e) => {
// polygon-layer-listener
if (e.originalEvent["consumed"]) {
// This is a polygon beneath a marker, we can ignore it
return
{// Add source to the map or update the features
if (src === undefined) {
this.currentSourceData = features;
map.addSource(this._layername, {
type: "geojson",
data: {
type: "FeatureCollection",
features
},
promoteId: "id"
});
const linelayer = this._layername + "_line";
map.addLayer({
source: this._layername,
id: linelayer,
type: "line",
paint: {
"line-color": ["feature-state", "color"],
"line-opacity": ["feature-state", "color-opacity"],
"line-width": ["feature-state", "width"],
"line-offset": ["feature-state", "offset"]
},
layout: {
"line-cap": "round"
}
e.originalEvent["consumed"] = true
console.log("Got features:", e.features, e)
this._onClick(e.features[0])
})
}
});
this._visibility?.addCallbackAndRunD((visible) => {
try {
map.setLayoutProperty(linelayer, "visibility", visible ? "visible" : "none")
map.setLayoutProperty(polylayer, "visibility", visible ? "visible" : "none")
} catch (e) {
console.warn(
"Error while setting visibility of layers ",
linelayer,
polylayer,
e
for (const feature of features) {
map.setFeatureState(
{ source: this._layername, id: feature.properties.id },
this.calculatePropsFor(feature.properties)
)
}
})
} else {
this.currentSourceData = features
src.setData({
type: "FeatureCollection",
features: this.currentSourceData,
})
}
map.on("click", linelayer, (e) => {
// line-layer-listener
e.originalEvent["consumed"] = true;
this._onClick(e.features[0]);
});
const polylayer = this._layername + "_polygon";
map.addLayer({
source: this._layername,
id: polylayer,
type: "fill",
filter: ["in", ["geometry-type"], ["literal", ["Polygon", "MultiPolygon"]]],
layout: {},
paint: {
"fill-color": ["feature-state", "fillColor"],
"fill-opacity": ["feature-state", "fillColor-opacity"]
}
});
if (this._onClick) {
map.on("click", polylayer, (e) => {
// polygon-layer-listener
if (e.originalEvent["consumed"]) {
// This is a polygon beneath a marker, we can ignore it
return;
}
e.originalEvent["consumed"] = true;
console.log("Got features:", e.features, e);
this._onClick(e.features[0]);
});
}
this._visibility?.addCallbackAndRunD((visible) => {
try {
map.setLayoutProperty(linelayer, "visibility", visible ? "visible" : "none");
map.setLayoutProperty(polylayer, "visibility", visible ? "visible" : "none");
} catch (e) {
console.warn(
"Error while setting visibility of layers ",
linelayer,
polylayer,
e
);
}
});
} else {
this.currentSourceData = features;
src.setData({
type: "FeatureCollection",
features: this.currentSourceData
});
}
}
for (let i = 0; i < features.length; i++) {
// Installs a listener on the 'Tags' of every individual feature to update the rendering
const feature = features[i]
const id = feature.properties.id ?? feature.id
if (id === undefined) {
@ -392,6 +400,9 @@ class LineRenderingLayer {
const tags = this._fetchStore(id)
this._listenerInstalledOn.add(id)
tags.addCallbackAndRunD((properties) => {
if(!map.getLayer(this._layername)){
return
}
map.setFeatureState(
{ source: this._layername, id },
this.calculatePropsFor(properties)

View file

@ -0,0 +1,150 @@
<script lang="ts">
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
import PlantNetSpeciesList from "./PlantNetSpeciesList.svelte"
import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
import type { PlantNetSpeciesMatch } from "../../Logic/Web/PlantNet"
import PlantNet from "../../Logic/Web/PlantNet"
import { XCircleIcon } from "@babeard/svelte-heroicons/solid"
import BackButton from "../Base/BackButton.svelte"
import NextButton from "../Base/NextButton.svelte"
import WikipediaPanel from "../Wikipedia/WikipediaPanel.svelte"
import { createEventDispatcher } from "svelte"
import ToSvelte from "../Base/ToSvelte.svelte"
import Svg from "../../Svg"
/**
* The main entry point for the plantnet wizard
*/
const t = Translations.t.plantDetection
/**
* All the URLs pointing to images of the selected feature.
* We need to feed them into Plantnet when applicable
*/
export let imageUrls: Store<string[]>
export let onConfirm: (wikidataId: string) => void
const dispatch = createEventDispatcher<{ selected: string }>()
let collapsedMode = true
let options: UIEventSource<PlantNetSpeciesMatch[]> = new UIEventSource<PlantNetSpeciesMatch[]>(
undefined
)
let error: string = undefined
/**
* The Wikidata-id of the species to apply
*/
let selectedOption: string
let done = false
function speciesSelected(species: PlantNetSpeciesMatch) {
console.log("Selected:", species)
selectedOption = species
}
async function detectSpecies() {
collapsedMode = false
try {
const result = await PlantNet.query(imageUrls.data.slice(0, 5))
options.set(result.results.filter((r) => r.score > 0.005).slice(0, 8))
} catch (e) {
error = e
}
}
</script>
<div class="flex flex-col">
{#if collapsedMode}
<button class="w-full" on:click={detectSpecies}>
<Tr t={t.button} />
</button>
{:else if $error !== undefined}
<Tr cls="alert" t={t.error.Subs({ error })} />
{:else if $imageUrls.length === 0}
<!-- No urls are available, show the explanation instead-->
<div class=" border-region relative mb-1 p-2">
<XCircleIcon
class="absolute top-0 right-0 m-4 h-8 w-8 cursor-pointer"
on:click={() => {
collapsedMode = true
}}
/>
<Tr t={t.takeImages} />
<Tr t={t.howTo.intro} />
<ul>
<li>
<Tr t={t.howTo.li0} />
</li>
<li>
<Tr t={t.howTo.li1} />
</li>
<li>
<Tr t={t.howTo.li2} />
</li>
<li>
<Tr t={t.howTo.li3} />
</li>
</ul>
</div>
{:else if selectedOption === undefined}
<PlantNetSpeciesList
{options}
numberOfImages={$imageUrls.length}
on:selected={(species) => speciesSelected(species.detail)}
>
<XCircleIcon
slot="upper-right"
class="m-4 h-8 w-8 cursor-pointer"
on:click={() => {
collapsedMode = true
}}
/>
</PlantNetSpeciesList>
{:else if !done}
<div class="border-interactive flex flex-col">
<div class="m-2">
<WikipediaPanel wikiIds={new ImmutableStore([selectedOption])} />
</div>
<div class="flex flex-col items-stretch">
<BackButton
on:click={() => {
selectedOption = undefined
}}
>
<Tr t={t.back} />
</BackButton>
<NextButton
clss="primary"
on:click={() => {
done = true
onConfirm(selectedOption)
}}
>
<Tr t={t.confirm} />
</NextButton>
</div>
</div>
{:else}
<!-- done ! -->
<Tr t={t.done} cls="thanks w-full" />
<BackButton
imageClass="w-6 h-6 shrink-0"
clss="p-1 m-0"
on:click={() => {
done = false
selectedOption = undefined
}}
>
<Tr t={t.tryAgain} />
</BackButton>
{/if}
<div class="low-interaction flex self-end rounded-xl p-2">
<ToSvelte
construct={Svg.plantnet_logo_svg().SetClass("w-8 h-8 p-1 mr-1 bg-white rounded-full")}
/>
<Tr t={t.poweredByPlantnet} />
</div>
</div>

View file

@ -0,0 +1,36 @@
<script lang="ts">
/**
* Show the list of options to choose from
*/
import type { PlantNetSpeciesMatch } from "../../Logic/Web/PlantNet"
import { Store } from "../../Logic/UIEventSource"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
import Loading from "../Base/Loading.svelte"
import SpeciesButton from "./SpeciesButton.svelte"
const t = Translations.t.plantDetection
export let options: Store<PlantNetSpeciesMatch[]>
export let numberOfImages: number
</script>
{#if $options === undefined}
<Loading>
<Tr t={t.querying.Subs({ length: numberOfImages })} />
</Loading>
{:else}
<div class="low-interaction border-interactive relative flex flex-col p-2">
<div class="absolute top-0 right-0">
<slot name="upper-right" />
</div>
<h3>
<Tr t={t.overviewTitle} />
</h3>
<Tr t={t.overviewIntro} />
<Tr cls="font-bold" t={t.overviewVerify} />
{#each $options as species}
<SpeciesButton {species} on:selected />
{/each}
</div>
{/if}

View file

@ -0,0 +1,61 @@
<script lang="ts">
/**
* A button to select a single species
*/
import { createEventDispatcher } from "svelte"
import type { PlantNetSpeciesMatch } from "../../Logic/Web/PlantNet"
import { UIEventSource } from "../../Logic/UIEventSource"
import Wikidata from "../../Logic/Web/Wikidata"
import NextButton from "../Base/NextButton.svelte"
import Loading from "../Base/Loading.svelte"
import WikidataPreviewBox from "../Wikipedia/WikidataPreviewBox"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
import ToSvelte from "../Base/ToSvelte.svelte"
export let species: PlantNetSpeciesMatch
let wikidata = UIEventSource.FromPromise(
Wikidata.Sparql<{ species }>(
["?species", "?speciesLabel"],
['?species wdt:P846 "' + species.gbif.id + '"']
)
)
const dispatch = createEventDispatcher<{ selected: string /* wikidata-id*/ }>()
const t = Translations.t.plantDetection
/**
* PlantNet give us a GBIF-id, but we want the Wikidata-id instead.
* We look this up in wikidata
*/
const wikidataId: Store<string> = UIEventSource.FromPromise(
Wikidata.Sparql<{ species }>(
["?species", "?speciesLabel"],
['?species wdt:P846 "' + species.gbif.id + '"']
)
).mapD((wd) => wd[0]?.species?.value)
</script>
<NextButton on:click={() => dispatch("selected", $wikidataId)}>
{#if $wikidata === undefined}
<Loading>
<Tr
t={t.loadingWikidata.Subs({
species: species.species.scientificNameWithoutAuthor,
})}
/>
</Loading>
{:else}
<ToSvelte
construct={() =>
new WikidataPreviewBox(wikidataId, {
imageStyle: "max-width: 8rem; width: unset; height: 8rem",
extraItems: [
t.matchPercentage
.Subs({ match: Math.round(species.score * 100) })
.SetClass("thanks w-fit self-center"),
],
}).SetClass("w-full")}
/>
{/if}
</NextButton>

View file

@ -102,7 +102,7 @@
console.log("Creating new point at", location, "snapped to", snapTo, "with tags", tags)
let snapToWay: undefined | OsmWay = undefined
if (snapTo !== undefined) {
if (snapTo !== undefined && snapTo !== null) {
const downloaded = await state.osmObjectDownloader.DownloadObjectAsync(snapTo, 0)
if (downloaded !== "deleted") {
snapToWay = downloaded
@ -113,6 +113,7 @@
theme: state.layout?.id ?? "unkown",
changeType: "create",
snapOnto: snapToWay,
reusePointWithinMeters: 1,
})
await state.changes.applyAction(newElementAction)
state.newFeatures.features.ping()
@ -120,6 +121,9 @@
const newId = newElementAction.newElementId
console.log("Applied pending changes, fetching store for", newId)
const tagsStore = state.featureProperties.getStore(newId)
if (!tagsStore) {
console.error("Bug: no tagsStore found for", newId)
}
{
// Set some metainfo
const properties = tagsStore.data
@ -136,11 +140,18 @@
tagsStore.ping()
}
const feature = state.indexedFeatures.featuresById.data.get(newId)
console.log("Selecting feature", feature, "and opening their popup")
abort()
state.selectedLayer.setData(selectedPreset.layer)
state.selectedElement.setData(feature)
tagsStore.ping()
}
function confirmSync() {
confirm()
.then((_) => console.debug("New point successfully handled"))
.catch((e) => console.error("Handling the new point went wrong due to", e))
}
</script>
<LoginToggle ignoreLoading={true} {state}>
@ -328,7 +339,7 @@
"absolute top-0 flex w-full justify-center p-12"
)}
>
<NextButton on:click={confirm} clss="primary w-fit">
<NextButton on:click={confirmSync} clss="primary w-fit">
<div class="flex w-full justify-end gap-x-2">
<Tr t={Translations.t.general.add.confirmLocation} />
</div>

View file

@ -62,8 +62,13 @@
state.newFeatures.features.data.push(feature)
state.newFeatures.features.ping()
state.selectedElement?.setData(feature)
if (state.featureProperties.trackFeature) {
state.featureProperties.trackFeature(feature)
}
comment.setData("")
created = true
state.selectedElement.setData(feature)
state.selectedLayer.setData(state.layerState.filteredLayers.get("note"))
}
</script>

View file

@ -38,7 +38,6 @@
const hasSoftDeletion = deleteConfig.softDeletionTags !== undefined
let currentState: "start" | "confirm" | "applying" | "deleted" = "start"
$: {
console.log("Current state is", currentState, $canBeDeleted, canBeDeletedReason)
deleteAbility.CheckDeleteability(true)
}
@ -55,7 +54,6 @@
let actionToTake: OsmChangeAction
const changedProperties = TagUtils.changeAsProperties(selectedTags.asChange(tags?.data ?? {}))
const deleteReason = changedProperties[DeleteConfig.deleteReasonKey]
console.log("Deleting! Hard?:", canBeDeleted.data, deleteReason)
if (deleteReason) {
// This is a proper, hard deletion
actionToTake = new DeleteAction(

View file

@ -1,72 +1,72 @@
<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 LinkImageAction from "../../Logic/Osm/Actions/LinkImageAction"
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"
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 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 = Object.values(tags.data).some((v) => image.pictureUrl === v)
export let linkable = true;
let isLinked = false;
const t = Translations.t.image.nearby;
const c = [lon, lat];
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));
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];
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);
const action = new LinkImageAction(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];
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);
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">
<div class="flex w-fit shrink-0 flex-col">
<ToSvelte construct={attributedImage.SetClass("h-48 w-fit")} />
{#if linkable}
<label>
<input bind:checked={isLinked} type="checkbox">
<input bind:checked={isLinked} type="checkbox" />
<SpecialTranslation t={t.link} {tags} {state} {layer} {feature} />
</label>
{/if}

View file

@ -1,40 +1,44 @@
<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";
import Tr from "../Base/Tr.svelte";
import Translations from "../i18n/Translations";
<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"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
export let tags: Store<OsmTags>;
export let state: SpecialVisualizationState;
export let lon: number;
export let lat: number;
export let feature: Feature;
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;
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));
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>
<div class="interactive rounded-2xl border-interactive p-2">
<div class="interactive border-interactive rounded-2xl p-2">
<div class="flex justify-between">
<h4>
<Tr t={Translations.t.image.nearby.title} />
</h4>
@ -43,7 +47,7 @@ let images: Store<P4CPicture[]> = imagesProvider.store.map(images => images.slic
{#if $images.length === 0}
<Loading />
{:else}
<div class="overflow-x-auto w-full flex space-x-1" style="scroll-snap-type: x proximity">
<div class="flex w-full space-x-1 overflow-x-auto" style="scroll-snap-type: x proximity">
{#each $images as image (image.pictureUrl)}
<span class="w-fit shrink-0" style="scroll-snap-align: start">
<LinkableImage {tags} {image} {state} {lon} {lat} {feature} {layer} {linkable} />

View file

@ -1,36 +1,48 @@
<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";
import { XCircleIcon } from "@babeard/svelte-heroicons/solid";
import exp from "constants";
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"
import { XCircleIcon } from "@babeard/svelte-heroicons/solid"
import exp from "constants"
export let tags: Store<OsmTags>;
export let state: SpecialVisualizationState;
export let lon: number;
export let lat: number;
export let feature: Feature;
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;
export let linkable: boolean = true
export let layer: LayerConfig
const t = Translations.t.image.nearby
let expanded = false;
let expanded = false
</script>
{#if expanded}
<NearbyImages {tags} {state} {lon} {lat} {feature} {linkable}>
<XCircleIcon slot="corner" class="w-6 h-6 cursor-pointer" on:click={() => {expanded = false}}/>
<XCircleIcon
slot="corner"
class="h-6 w-6 cursor-pointer"
on:click={() => {
expanded = false
}}
/>
</NearbyImages>
{: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>
<button
class="flex w-full 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}

View file

@ -41,7 +41,7 @@ export default class NoteCommentElement extends Combine {
let userinfo = Stores.FromPromise(
Utils.downloadJsonCached(
"https://www.openstreetmap.org/api/0.6/user/" + comment.uid,
"https://api.openstreetmap.org/api/0.6/user/" + comment.uid,
24 * 60 * 60 * 1000
)
)
@ -56,7 +56,7 @@ export default class NoteCommentElement extends Combine {
)
const htmlElement = document.createElement("div")
htmlElement.innerHTML = comment.html
htmlElement.innerHTML = Utils.purify(comment.html)
const images = Array.from(htmlElement.getElementsByTagName("a"))
.map((link) => link.href)
.filter((link) => {

View file

@ -1,18 +1,13 @@
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Toggle from "../Input/Toggle"
import Lazy from "../Base/Lazy"
import { ProvidedImage } from "../../Logic/ImageProviders/ImageProvider"
import PlantNetSpeciesSearch from "../BigComponents/PlantNetSpeciesSearch"
import Wikidata from "../../Logic/Web/Wikidata"
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"
import { And } from "../../Logic/Tags/And"
import { Tag } from "../../Logic/Tags/Tag"
import { SubtleButton } from "../Base/SubtleButton"
import Combine from "../Base/Combine"
import Svg from "../../Svg"
import Translations from "../i18n/Translations"
import AllImageProviders from "../../Logic/ImageProviders/AllImageProviders"
import { SpecialVisualization, SpecialVisualizationState } from "../SpecialVisualization"
import SvelteUIElement from "../Base/SvelteUIElement"
import PlantNet from "../PlantNet/PlantNet.svelte"
export class PlantNetDetectionViz implements SpecialVisualization {
funcName = "plantnet_detection"
@ -37,45 +32,29 @@ export class PlantNetDetectionViz implements SpecialVisualization {
imagePrefixes = [].concat(...args.map((a) => a.split(",")))
}
const detect = new UIEventSource(false)
const toggle = new Toggle(
new Lazy(() => {
const allProvidedImages: Store<ProvidedImage[]> = AllImageProviders.LoadImagesFor(
tags,
imagePrefixes
)
const allImages: Store<string[]> = allProvidedImages.map((pi) =>
pi.map((pi) => pi.url)
)
return new PlantNetSpeciesSearch(allImages, async (selectedWikidata) => {
selectedWikidata = Wikidata.ExtractKey(selectedWikidata)
const change = new ChangeTagAction(
tags.data.id,
new And([
new Tag("species:wikidata", selectedWikidata),
new Tag("source:species:wikidata", "PlantNet.org AI"),
]),
tags.data,
{
theme: state.layout.id,
changeType: "plantnet-ai-detection",
}
)
await state.changes.applyAction(change)
})
}),
new SubtleButton(undefined, "Detect plant species with plantnet.org").onClick(() =>
detect.setData(true)
),
detect
const allProvidedImages: Store<ProvidedImage[]> = AllImageProviders.LoadImagesFor(
tags,
imagePrefixes
)
const imageUrls: Store<string[]> = allProvidedImages.map((pi) => pi.map((pi) => pi.url))
return new Combine([
toggle,
new Combine([
Svg.plantnet_logo_svg().SetClass("w-10 h-10 p-1 mr-1 bg-white rounded-full"),
Translations.t.plantDetection.poweredByPlantnet,
]).SetClass("flex p-2 bg-gray-200 rounded-xl self-end"),
]).SetClass("flex flex-col")
async function applySpecies(selectedWikidata) {
selectedWikidata = Wikidata.ExtractKey(selectedWikidata)
const change = new ChangeTagAction(
tags.data.id,
new And([
new Tag("species:wikidata", selectedWikidata),
new Tag("source:species:wikidata", "PlantNet.org AI"),
]),
tags.data,
{
theme: state.layout.id,
changeType: "plantnet-ai-detection",
}
)
await state.changes.applyAction(change)
}
return new SvelteUIElement(PlantNet, { imageUrls, onConfirm: applySpecies })
}
}

View file

@ -1,22 +1,18 @@
<script lang="ts">
import type { OsmTags } from "../../Models/OsmFeature";
import Svg from "../../Svg";
import ToSvelte from "../Base/ToSvelte.svelte";
import { Utils } from "../../Utils";
import type { OsmTags } from "../../Models/OsmFeature"
import Svg from "../../Svg"
import ToSvelte from "../Base/ToSvelte.svelte"
import { Utils } from "../../Utils"
export let tags: Store<OsmTags>
export let args: string[]
let [to, subject, body, button_text] = args.map(a => Utils.SubstituteKeys(a, $tags))
let url = "mailto:" +
to +
"?subject=" +
encodeURIComponent(subject) +
"&body=" +
encodeURIComponent(body)
let [to, subject, body, button_text] = args.map((a) => Utils.SubstituteKeys(a, $tags))
let url =
"mailto:" + to + "?subject=" + encodeURIComponent(subject) + "&body=" + encodeURIComponent(body)
</script>
<a class="button flex items-center w-full" href={url}>
<ToSvelte construct={Svg.envelope_svg().SetClass("w-8 h-8 mr-4 shrink-0")}/>
<a class="button flex w-full items-center" href={url}>
<ToSvelte construct={Svg.envelope_svg().SetClass("w-8 h-8 mr-4 shrink-0")} />
{button_text}
</a>

View file

@ -22,6 +22,7 @@
import { Unit } from "../../../Models/Unit";
import UserRelatedState from "../../../Logic/State/UserRelatedState";
import { twJoin } from "tailwind-merge";
import { TagUtils } from "../../../Logic/Tags/TagUtils";
export let config: TagRenderingConfig;
export let tags: UIEventSource<Record<string, string>>;
@ -34,12 +35,15 @@
let unit: Unit = layer?.units?.find((unit) => unit.appliesToKeys.has(config.freeform?.key));
// Will be bound if a freeform is available
let freeformInput = new UIEventSource<string>(tags?.[config.freeform?.key]);
let selectedMapping: number = undefined;
let checkedMappings: boolean[];
$: {
let tgs = $tags;
mappings = config.mappings?.filter((m) => {
let freeformInput = new UIEventSource<string>(tags?.[config.freeform?.key])
let selectedMapping: number = undefined
let checkedMappings: boolean[]
/**
* Prepares and fills the checkedMappings
*/
function initialize(tgs: Record<string, string>, confg: TagRenderingConfig) {
mappings = confg.mappings?.filter((m) => {
if (typeof m.hideInAnswer === "boolean") {
return !m.hideInAnswer;
}
@ -49,25 +53,58 @@
unit = layer?.units?.find((unit) => unit.appliesToKeys.has(config.freeform?.key));
if (
config.mappings?.length > 0 &&
(checkedMappings === undefined || checkedMappings?.length + 1 < config.mappings.length)
confg.mappings?.length > 0 &&
confg.multiAnswer &&
(checkedMappings === undefined ||
checkedMappings?.length < confg.mappings.length + (confg.freeform ? 1 : 0))
) {
const seenFreeforms = []
TagUtils.FlattenMultiAnswer()
checkedMappings = [
...config.mappings.map((_) => false),
false /*One element extra in case a freeform value is added*/
];
...confg.mappings.map((mapping) => {
const matches = TagUtils.MatchesMultiAnswer(mapping.if, tgs)
if (matches && confg.freeform) {
const newProps = TagUtils.changeAsProperties(mapping.if.asChange())
seenFreeforms.push(newProps[confg.freeform.key])
}
return matches
}),
]
if (tgs !== undefined && confg.freeform) {
const unseenFreeformValues = tgs[confg.freeform.key]?.split(";") ?? []
for (const seenFreeform of seenFreeforms) {
if (!seenFreeform) {
continue
}
const index = unseenFreeformValues.indexOf(seenFreeform)
if (index < 0) {
continue
}
unseenFreeformValues.splice(index, 1)
}
// TODO this has _to much_ values
freeformInput.setData(unseenFreeformValues.join(";"))
checkedMappings.push(unseenFreeformValues.length > 0)
}
}
if (config.freeform?.key) {
if (!config.multiAnswer) {
// Somehow, setting multianswer freeform values is broken if this is not set
freeformInput.setData(tgs[config.freeform.key]);
if (confg.freeform?.key) {
if (!confg.multiAnswer) {
// Somehow, setting multi-answer freeform values is broken if this is not set
freeformInput.setData(tgs[confg.freeform.key])
}
} else {
freeformInput.setData(undefined);
}
feedback.setData(undefined);
}
export let selectedTags: TagsFilter = undefined;
$: {
// Even though 'config' is not declared as a store, Svelte uses it as one to update the component
// We want to (re)-initialize whenever the 'tags' or 'config' change - but not when 'checkedConfig' changes
initialize($tags, config)
}
export let selectedTags: TagsFilter = undefined
let mappings: Mapping[] = config?.mappings;
let searchTerm: UIEventSource<string> = new UIEventSource("");

View file

@ -0,0 +1,46 @@
<script lang="ts">
import FeatureReviews from "../../Logic/Web/MangroveReviews"
import SingleReview from "./SingleReview.svelte"
import { Utils } from "../../Utils"
import StarsBar from "./StarsBar.svelte"
import ReviewForm from "./ReviewForm.svelte"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import { UIEventSource } from "../../Logic/UIEventSource"
import type { Feature } from "geojson"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import ToSvelte from "../Base/ToSvelte.svelte"
import Svg from "../../Svg"
/**
* An element showing all reviews
*/
export let reviews: FeatureReviews
export let state: SpecialVisualizationState
export let tags: UIEventSource<Record<string, string>>
export let feature: Feature
export let layer: LayerConfig
let average = reviews.average
let _reviews = []
reviews.reviews.addCallbackAndRunD((r) => {
_reviews = Utils.NoNull(r)
})
</script>
<div class="border-2 border-dashed border-gray-300">
{#if _reviews.length > 1}
<StarsBar score={$average} />
{/if}
{#if _reviews.length > 0}
{#each _reviews as review}
<SingleReview {review} />
{/each}
{:else}
<Tr t={Translations.t.reviews.no_reviews_yet} />
{/if}
<div class="flex justify-end">
<ToSvelte construct={Svg.mangrove_logo_svg().SetClass("w-12 h-12")} />
<Tr t={Translations.t.reviews.attribution} />
</div>
</div>

View file

@ -1,56 +0,0 @@
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import SingleReview from "./SingleReview"
import BaseUIElement from "../BaseUIElement"
import Img from "../Base/Img"
import { VariableUiElement } from "../Base/VariableUIElement"
import Link from "../Base/Link"
import FeatureReviews from "../../Logic/Web/MangroveReviews"
/**
* Shows the reviews and scoring base on mangrove.reviews
* The middle element is some other component shown in the middle, e.g. the review input element
*/
export default class ReviewElement extends VariableUiElement {
constructor(reviews: FeatureReviews, middleElement: BaseUIElement) {
super(
reviews.reviews.map(
(revs) => {
const elements = []
revs.sort((a, b) => b.iat - a.iat) // Sort with most recent first
const avg =
revs.map((review) => review.rating).reduce((a, b) => a + b, 0) / revs.length
elements.push(
new Combine([
SingleReview.GenStars(avg),
new Link(
revs.length === 1
? Translations.t.reviews.title_singular.Clone()
: Translations.t.reviews.title.Subs({
count: "" + revs.length,
}),
`https://mangrove.reviews/search?sub=${encodeURIComponent(
reviews.subjectUri.data
)}`,
true
),
]).SetClass("font-2xl flex justify-between items-center pl-2 pr-2")
)
elements.push(middleElement)
elements.push(...revs.map((review) => new SingleReview(review)))
elements.push(
new Combine([
Translations.t.reviews.attribution.Clone(),
new Img("./assets/mangrove_logo.png"),
]).SetClass("review-attribution")
)
return new Combine(elements).SetClass("block")
},
[reviews.subjectUri]
)
)
}
}

View file

@ -0,0 +1,106 @@
<script lang="ts">
import FeatureReviews from "../../Logic/Web/MangroveReviews"
import StarsBar from "./StarsBar.svelte"
import SpecialTranslation from "../Popup/TagRendering/SpecialTranslation.svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import { UIEventSource } from "../../Logic/UIEventSource"
import type { Feature } from "geojson"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import Translations from "../i18n/Translations"
import Checkbox from "../Base/Checkbox.svelte"
import Tr from "../Base/Tr.svelte"
import If from "../Base/If.svelte"
import Loading from "../Base/Loading.svelte"
import { Review } from "mangrove-reviews-typescript"
import { Utils } from "../../Utils"
export let state: SpecialVisualizationState
export let tags: UIEventSource<Record<string, string>>
export let feature: Feature
export let layer: LayerConfig
/**
* The form to create a new review.
* This is multi-stepped.
*/
export let reviews: FeatureReviews
let score = 0
let confirmedScore = undefined
let isAffiliated = new UIEventSource(false)
let opinion = new UIEventSource<string>(undefined)
const t = Translations.t.reviews
let _state: "ask" | "saving" | "done" = "ask"
const connection = state.osmConnection
async function save() {
_state = "saving"
let nickname = undefined
if (connection.isLoggedIn.data) {
nickname = connection.userDetails.data.name
}
const review: Omit<Review, "sub"> = {
rating: confirmedScore,
opinion: opinion.data,
metadata: { nickname, is_affiliated: isAffiliated.data },
}
if (state.featureSwitchIsTesting.data) {
console.log("Testing - not actually saving review", review)
await Utils.waitFor(1000)
} else {
await reviews.createReview(review)
}
_state = "done"
}
</script>
{#if _state === "done"}
<Tr cls="thanks w-full" t={t.saved} />
{:else if _state === "saving"}
<Loading>
<Tr t={t.saving_review} />
</Loading>
{:else}
<div class="interactive border-interactive p-1">
<div class="font-bold">
<SpecialTranslation {feature} {layer} {state} t={Translations.t.reviews.question} {tags} />
</div>
<StarsBar
on:click={(e) => {
confirmedScore = e.detail.score
}}
on:hover={(e) => {
score = e.detail.score
}}
on:mouseout={(e) => {
score = null
}}
score={score ?? confirmedScore ?? 0}
starSize="w-8 h-8"
/>
{#if confirmedScore !== undefined}
<Tr cls="font-bold mt-2" t={t.question_opinion} />
<textarea bind:value={$opinion} inputmode="text" rows="3" class="mb-1 w-full" />
<Checkbox selected={isAffiliated}>
<div class="flex flex-col">
<Tr t={t.i_am_affiliated} />
<Tr cls="subtle" t={t.i_am_affiliated_explanation} />
</div>
</Checkbox>
<div class="flex w-full flex-wrap items-center justify-between">
<If condition={state.osmConnection.isLoggedIn}>
<Tr t={t.reviewing_as.Subs({ nickname: state.osmConnection.userDetails.data.name })} />
<Tr slot="else" t={t.reviewing_as_anonymous} />
</If>
<button class="primary" on:click={save}>
<Tr t={t.save} />
</button>
</div>
<Tr cls="subtle mt-4" t={t.tos} />
{/if}
</div>
{/if}

View file

@ -1,101 +0,0 @@
import { Review } from "mangrove-reviews-typescript"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { TextField } from "../Input/TextField"
import Translations from "../i18n/Translations"
import Combine from "../Base/Combine"
import Svg from "../../Svg"
import { VariableUiElement } from "../Base/VariableUIElement"
import { CheckBox } from "../Input/Checkboxes"
import UserDetails, { OsmConnection } from "../../Logic/Osm/OsmConnection"
import Toggle from "../Input/Toggle"
import { LoginToggle } from "../Popup/LoginButton"
import { SubtleButton } from "../Base/SubtleButton"
export default class ReviewForm extends LoginToggle {
constructor(
onSave: (r: Omit<Review, "sub">) => Promise<void>,
state: {
readonly osmConnection: OsmConnection
readonly featureSwitchUserbadge: Store<boolean>
}
) {
/* made_by_user: new UIEventSource<boolean>(true),
rating: undefined,
comment: undefined,
author: osmConnection.userDetails.data.name,
affiliated: false,
date: new Date(),*/
const commentForm = new TextField({
placeholder: Translations.t.reviews.write_a_comment.Clone(),
htmlType: "area",
textAreaRows: 5,
})
const rating = new UIEventSource<number>(undefined)
const isAffiliated = new CheckBox(Translations.t.reviews.i_am_affiliated)
const reviewMade = new UIEventSource(false)
const postingAs = new Combine([
Translations.t.reviews.posting_as.Clone(),
new VariableUiElement(
state.osmConnection.userDetails.map((ud: UserDetails) => ud.name)
).SetClass("review-author"),
]).SetStyle("display:flex;flex-direction: column;align-items: flex-end;margin-left: auto;")
const saveButton = new Toggle(
Translations.t.reviews.no_rating.SetClass("block alert"),
new SubtleButton(Svg.confirm_svg(), Translations.t.reviews.save)
.OnClickWithLoading(
Translations.t.reviews.saving_review.SetClass("alert"),
async () => {
const review: Omit<Review, "sub"> = {
rating: rating.data,
opinion: commentForm.GetValue().data,
metadata: { nickname: state.osmConnection.userDetails.data.name },
}
await onSave(review)
}
)
.SetClass("break-normal"),
rating.map((r) => r === undefined, [commentForm.GetValue()])
)
const stars = []
for (let i = 1; i <= 5; i++) {
stars.push(
new VariableUiElement(
rating.map((score) => {
if (score === undefined) {
return Svg.star_outline.replace(/#000000/g, "#ccc")
}
return score < i * 20 ? Svg.star_outline : Svg.star
})
).onClick(() => {
rating.setData(i * 20)
})
)
}
const form = new Combine([
new Combine([new Combine(stars).SetClass("review-form-rating"), postingAs]).SetClass(
"flex"
),
commentForm,
new Combine([isAffiliated, saveButton]),
Translations.t.reviews.tos.Clone().SetClass("subtle"),
])
.SetClass("flex flex-col p-4")
.SetStyle(
"border-radius: 1em;" +
" background-color: var(--subtle-detail-color);" +
" color: var(--subtle-detail-color-contrast);" +
" border: 2px solid var(--subtle-detail-color-contrast)"
)
super(
new Toggle(Translations.t.reviews.saved.Clone().SetClass("thanks"), form, reviewMade),
Translations.t.reviews.plz_login,
state
)
}
}

View file

@ -0,0 +1,38 @@
<script lang="ts">
import { Review } from "mangrove-reviews-typescript"
import { Store } from "../../Logic/UIEventSource"
import StarsBar from "./StarsBar.svelte"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
export let review: Review & { madeByLoggedInUser: Store<boolean> }
let name = review.metadata.nickname
name ??= (review.metadata.given_name ?? "") + " " + (review.metadata.family_name ?? "").trim()
if (name.length === 0) {
name = "Anonymous"
}
let d = new Date()
d.setTime(review.iat * 1000)
let date = d.toDateString()
let byLoggedInUser = review.madeByLoggedInUser
</script>
<div class={"low-interaction rounded-lg p-1 px-2 " + ($byLoggedInUser ? "border-interactive" : "")}>
<div class="flex items-center justify-between">
<StarsBar score={review.rating} />
<div class="flex flex-wrap space-x-2">
<div class="font-bold">
{name}
</div>
<span class="subtle">
{date}
</span>
</div>
</div>
{#if review.opinion}
{review.opinion}
{/if}
{#if review.metadata.is_affiliated}
<Tr t={Translations.t.reviews.affiliated_reviewer_warning} />
{/if}
</div>

View file

@ -1,64 +0,0 @@
import Combine from "../Base/Combine"
import { FixedUiElement } from "../Base/FixedUiElement"
import Translations from "../i18n/Translations"
import { Utils } from "../../Utils"
import BaseUIElement from "../BaseUIElement"
import Img from "../Base/Img"
import { Review } from "mangrove-reviews-typescript"
import { Store } from "../../Logic/UIEventSource"
export default class SingleReview extends Combine {
constructor(review: Review & { madeByLoggedInUser: Store<boolean> }) {
const date = new Date(review.iat * 1000)
const reviewAuthor =
review.metadata.nickname ??
(review.metadata.given_name ?? "") + (review.metadata.family_name ?? "")
const authorElement = new FixedUiElement(reviewAuthor).SetClass("font-bold")
super([
new Combine([SingleReview.GenStars(review.rating)]),
new FixedUiElement(review.opinion),
new Combine([
new Combine([
authorElement,
review.metadata.is_affiliated
? Translations.t.reviews.affiliated_reviewer_warning
: "",
]).SetStyle("margin-right: 0.5em"),
new FixedUiElement(
`${date.getFullYear()}-${Utils.TwoDigits(
date.getMonth() + 1
)}-${Utils.TwoDigits(date.getDate())} ${Utils.TwoDigits(
date.getHours()
)}:${Utils.TwoDigits(date.getMinutes())}`
).SetClass("subtle"),
]).SetClass("flex mb-4 justify-end"),
])
this.SetClass("block p-2 m-4 rounded-xl subtle-background review-element")
review.madeByLoggedInUser.addCallbackAndRun((madeByUser) => {
if (madeByUser) {
authorElement.SetClass("thanks")
} else {
authorElement.RemoveClass("thanks")
}
})
}
public static GenStars(rating: number): BaseUIElement {
if (rating === undefined) {
return Translations.t.reviews.no_rating
}
if (rating < 10) {
rating = 10
}
const scoreTen = Math.round(rating / 10)
return new Combine([
...Utils.TimesT(scoreTen / 2, (_) =>
new Img("./assets/svg/star.svg").SetClass("'h-8 w-8 md:h-12")
),
scoreTen % 2 == 1
? new Img("./assets/svg/star_half.svg").SetClass("h-8 w-8 md:h-12")
: undefined,
]).SetClass("flex w-max")
}
}

View file

@ -0,0 +1,32 @@
<script lang="ts">
import ToSvelte from "../Base/ToSvelte.svelte"
import Svg from "../../Svg"
import { createEventDispatcher } from "svelte"
export let score: number
export let cutoff: number
export let starSize = "w-h h-4"
let dispatch = createEventDispatcher<{ hover: { score: number } }>()
let container: HTMLElement
function getScore(e: MouseEvent): number {
const x = e.clientX - e.target.getBoundingClientRect().x
const w = container.getClientRects()[0]?.width
return x / w < 0.5 ? cutoff - 10 : cutoff
}
</script>
<div
bind:this={container}
on:click={(e) => dispatch("click", { score: getScore(e) })}
on:mousemove={(e) => dispatch("hover", { score: getScore(e) })}
>
{#if score >= cutoff}
<ToSvelte construct={Svg.star_svg().SetClass(starSize)} />
{:else if score + 10 >= cutoff}
<ToSvelte construct={Svg.star_half_svg().SetClass(starSize)} />
{:else}
<ToSvelte construct={Svg.star_outline_svg().SetClass(starSize)} />
{/if}
</div>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { createEventDispatcher } from "svelte"
import StarElement from "./StarElement.svelte"
/**
* Number between 0 and 100. Every 10 points, another half star is added
*/
export let score: number
let dispatch = createEventDispatcher<{ hover: number; click: number }>()
let cutoffs = [20, 40, 60, 80, 100]
export let starSize = "w-h h-4"
</script>
{#if score !== undefined}
<div class="flex" on:mouseout>
{#each cutoffs as cutoff}
<StarElement {score} {cutoff} {starSize} on:hover on:click />
{/each}
</div>
{/if}

View file

@ -0,0 +1,10 @@
<script lang="ts">
import { Store } from "../../Logic/UIEventSource"
import StarsBar from "./StarsBar.svelte"
export let score: Store<number>
</script>
{#if $score !== undefined && $score !== null}
<StarsBar score={$score} />
{/if}

View file

@ -15,6 +15,8 @@ import FeatureSwitchState from "../Logic/State/FeatureSwitchState"
import { MenuState } from "../Models/MenuState"
import OsmObjectDownloader from "../Logic/Osm/OsmObjectDownloader"
import { RasterLayerPolygon } from "../Models/RasterLayers"
import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager"
import { OsmTags } from "../Models/OsmFeature"
/**
* The state needed to render a special Visualisation.
@ -25,7 +27,10 @@ export interface SpecialVisualizationState {
readonly featureSwitches: FeatureSwitchState
readonly layerState: LayerState
readonly featureProperties: { getStore(id: string): UIEventSource<Record<string, string>> }
readonly featureProperties: {
getStore(id: string): UIEventSource<Record<string, string>>
trackFeature?(feature: { properties: OsmTags })
}
readonly indexedFeatures: IndexedFeatureSource
@ -65,6 +70,7 @@ export interface SpecialVisualizationState {
readonly perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer>
readonly userRelatedState: {
readonly imageLicense: UIEventSource<string>
readonly showTags: UIEventSource<"no" | undefined | "always" | "yes" | "full">
readonly mangroveIdentity: MangroveIdentity
readonly showAllQuestionsAtOnce: UIEventSource<boolean>
@ -74,6 +80,8 @@ export interface SpecialVisualizationState {
readonly lastClickObject: WritableFeatureSource
readonly availableLayers: Store<RasterLayerPolygon[]>
readonly imageUploadManager: ImageUploadManager
}
export interface SpecialVisualization {

View file

@ -22,23 +22,16 @@ import { Store, Stores, UIEventSource } from "../Logic/UIEventSource"
import AllTagsPanel from "./Popup/AllTagsPanel.svelte"
import AllImageProviders from "../Logic/ImageProviders/AllImageProviders"
import { ImageCarousel } from "./Image/ImageCarousel"
import { ImageUploadFlow } from "./Image/ImageUploadFlow"
import { VariableUiElement } from "./Base/VariableUIElement"
import { Utils } from "../Utils"
import Wikidata, { WikidataResponse } from "../Logic/Web/Wikidata"
import { Translation } from "./i18n/Translation"
import Translations from "./i18n/Translations"
import ReviewForm from "./Reviews/ReviewForm"
import ReviewElement from "./Reviews/ReviewElement"
import OpeningHoursVisualization from "./OpeningHours/OpeningHoursVisualization"
import LiveQueryHandler from "../Logic/Web/LiveQueryHandler"
import { SubtleButton } from "./Base/SubtleButton"
import Svg from "../Svg"
import NoteCommentElement from "./Popup/NoteCommentElement"
import ImgurUploader from "../Logic/ImageProviders/ImgurUploader"
import FileSelectorButton from "./Input/FileSelectorButton"
import { LoginToggle } from "./Popup/LoginButton"
import Toggle from "./Input/Toggle"
import { SubstitutedTranslation } from "./SubstitutedTranslation"
import List from "./Base/List"
import StatisticsPanel from "./BigComponents/StatisticsPanel"
@ -74,6 +67,10 @@ import FediverseValidator from "./InputElement/Validators/FediverseValidator"
import SendEmail from "./Popup/SendEmail.svelte"
import NearbyImages from "./Popup/NearbyImages.svelte"
import NearbyImagesCollapsed from "./Popup/NearbyImagesCollapsed.svelte"
import UploadImage from "./Image/UploadImage.svelte"
import AllReviews from "./Reviews/AllReviews.svelte"
import StarsBarIcon from "./Reviews/StarsBarIcon.svelte"
import ReviewForm from "./Reviews/ReviewForm.svelte"
class NearbyImageVis implements SpecialVisualization {
// Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests
@ -538,7 +535,7 @@ export default class SpecialVisualizations {
const keys = args[0].split(";").map((k) => k.trim())
const wikiIds: Store<string[]> = tagsSource.map((tags) => {
const key = keys.find((k) => tags[k] !== undefined && tags[k] !== "")
return tags[key]?.split(";")?.map((id) => id.trim())
return tags[key]?.split(";")?.map((id) => id.trim()) ?? []
})
return new SvelteUIElement(WikipediaPanel, {
wikiIds,
@ -616,20 +613,91 @@ export default class SpecialVisualizations {
{
name: "image-key",
doc: "Image tag to add the URL to (or image-tag:0, image-tag:1 when multiple images are added)",
defaultValue: "image",
required: false,
},
{
name: "label",
doc: "The text to show on the button",
defaultValue: "Add image",
required: false,
},
],
constr: (state, tags, args) => {
return new ImageUploadFlow(tags, state, args[0], args[1])
return new SvelteUIElement(UploadImage, {
state,
tags,
labelText: args[1],
image: args[0],
})
},
},
{
funcName: "reviews",
funcName: "rating",
docs: "Shows stars which represent the avarage rating on mangrove.reviews",
args: [
{
name: "subjectKey",
defaultValue: "name",
doc: "The key to use to determine the subject. If specified, the subject will be <b>tags[subjectKey]</b>",
},
{
name: "fallback",
doc: "The identifier to use, if <i>tags[subjectKey]</i> as specified above is not available. This is effectively a fallback value",
},
],
constr: (state, tags, args, feature, layer) => {
const nameKey = args[0] ?? "name"
let fallbackName = args[1]
const reviews = FeatureReviews.construct(
feature,
tags,
state.userRelatedState.mangroveIdentity,
{
nameKey: nameKey,
fallbackName,
}
)
return new SvelteUIElement(StarsBarIcon, {
score: reviews.average,
reviews,
state,
tags,
feature,
layer,
})
},
},
{
funcName: "create_review",
docs: "Invites the contributor to leave a review. Somewhat small UI-element until interacted",
args: [
{
name: "subjectKey",
defaultValue: "name",
doc: "The key to use to determine the subject. If specified, the subject will be <b>tags[subjectKey]</b>",
},
{
name: "fallback",
doc: "The identifier to use, if <i>tags[subjectKey]</i> as specified above is not available. This is effectively a fallback value",
},
],
constr: (state, tags, args, feature, layer) => {
const nameKey = args[0] ?? "name"
let fallbackName = args[1]
const reviews = FeatureReviews.construct(
feature,
tags,
state.userRelatedState.mangroveIdentity,
{
nameKey: nameKey,
fallbackName,
}
)
return new SvelteUIElement(ReviewForm, { reviews, state, tags, feature, layer })
},
},
{
funcName: "list_reviews",
docs: "Adds an overview of the mangrove-reviews of this object. Mangrove.Reviews needs - in order to identify the reviewed object - a coordinate and a name. By default, the name of the object is given, but this can be overwritten",
example:
"`{reviews()}` for a vanilla review, `{reviews(name, play_forest)}` to review a play forest. If a name is known, the name will be used as identifier, otherwise 'play_forest' is used",
@ -644,10 +712,10 @@ export default class SpecialVisualizations {
doc: "The identifier to use, if <i>tags[subjectKey]</i> as specified above is not available. This is effectively a fallback value",
},
],
constr: (state, tags, args, feature) => {
constr: (state, tags, args, feature, layer) => {
const nameKey = args[0] ?? "name"
let fallbackName = args[1]
const mangrove = FeatureReviews.construct(
const reviews = FeatureReviews.construct(
feature,
tags,
state.userRelatedState.mangroveIdentity,
@ -656,9 +724,7 @@ export default class SpecialVisualizations {
fallbackName,
}
)
const form = new ReviewForm((r) => mangrove.createReview(r), state)
return new ReviewElement(mangrove, form)
return new SvelteUIElement(AllReviews, { reviews, state, tags, feature, layer })
},
},
{
@ -864,42 +930,10 @@ export default class SpecialVisualizations {
},
],
constr: (state, tags, args) => {
const isUploading = new UIEventSource(false)
const t = Translations.t.notes
const id = tags.data[args[0] ?? "id"]
const uploader = new ImgurUploader(async (url) => {
isUploading.setData(false)
await state.osmConnection.addCommentToNote(id, url)
NoteCommentElement.addCommentTo(url, tags, state)
})
const label = new Combine([
Svg.camera_plus_svg().SetClass("block w-12 h-12 p-1 text-4xl "),
Translations.t.image.addPicture,
]).SetClass(
"p-2 border-4 border-black rounded-full font-bold h-full align-center w-full flex justify-center"
)
const fileSelector = new FileSelectorButton(label)
fileSelector.GetValue().addCallback((filelist) => {
isUploading.setData(true)
uploader.uploadMany("Image for osm.org/note/" + id, "CC0", filelist)
})
const ti = Translations.t.image
const uploadPanel = new Combine([
fileSelector,
ti.respectPrivacy.SetClass("text-sm"),
]).SetClass("flex flex-col")
return new LoginToggle(
new Toggle(
Translations.t.image.uploadingPicture.SetClass("alert"),
uploadPanel,
isUploading
),
t.loginToAddPicture,
state
)
tags = state.featureProperties.getStore(id)
console.log("Id is", id)
return new SvelteUIElement(UploadImage, { state, tags })
},
},
{
@ -1155,19 +1189,24 @@ export default class SpecialVisualizations {
name: "class",
doc: "CSS-classes to add to the element",
},
{
name: "download",
doc: "If set, this link will act as a download-button. The contents of `href` will be offered for download; this parameter will act as the proposed filename",
},
],
constr(
state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>,
args: string[]
): BaseUIElement {
const [text, href, classnames] = args
const [text, href, classnames, download] = args
return new VariableUiElement(
tagSource.map((tags) =>
new Link(
Utils.SubstituteKeys(text, tags),
Utils.SubstituteKeys(href, tags),
true
download === undefined && !href.startsWith("#"),
Utils.SubstituteKeys(download, tags)
).SetClass(classnames)
)
)

View file

@ -1,7 +1,7 @@
<script lang="ts">
import Svg from "../Svg";
import Loading from "./Base/Loading.svelte";
import ToSvelte from "./Base/ToSvelte.svelte";
import Svg from "../Svg"
import Loading from "./Base/Loading.svelte"
import ToSvelte from "./Base/ToSvelte.svelte"
</script>
<div>
@ -29,6 +29,10 @@
areas, where some buttons might appear.
</p>
<div class="border-interactive interactive">
Highly interactive area (mostly: active question)
</div>
<div class="flex">
<button class="primary">
<ToSvelte construct={Svg.community_svg().SetClass("w-6 h-6")} />
@ -44,12 +48,8 @@
Small button
</button>
<button class="small primary">
Small button
</button>
<button class="small primary disabled">
Small, disabled button
</button>
<button class="small primary">Small button</button>
<button class="small primary disabled">Small, disabled button</button>
</div>
<div class="flex">
<button>

View file

@ -103,7 +103,7 @@
let currentViewLayer = layout.layers.find((l) => l.id === "current_view")
let rasterLayer: Store<RasterLayerPolygon> = state.mapProperties.rasterLayer
let rasterLayerName =
rasterLayer.data?.properties?.name ?? AvailableRasterLayers.maplibre.properties.name
rasterLayer.data?.properties?.name ?? AvailableRasterLayers.maptilerDefaultLayer.properties.name
onDestroy(
rasterLayer.addCallbackAndRunD((l) => {
rasterLayerName = l.properties.name

View file

@ -126,7 +126,7 @@ export default class WikidataPreviewBox extends VariableUiElement {
new Combine([
Translation.fromMap(wikidata.labels)?.SetClass("font-bold"),
link,
]).SetClass("flex justify-between"),
]).SetClass("flex justify-between flex-wrap-reverse"),
Translation.fromMap(wikidata.descriptions),
WikidataPreviewBox.QuickFacts(wikidata, options),
...(options?.extraItems ?? []),

View file

@ -131,7 +131,7 @@ Another example is to search for species and trees:
const searchResult: Store<{ success?: WikidataResponse[]; error?: any }> = searchField
.GetValue()
.bind((searchText) => {
if (searchText.length < 3) {
if (searchText.length < 3 && !searchText.match(/[qQ][0-9]+/)) {
return tooShort
}
const lang = Locale.language.data

View file

@ -11,40 +11,44 @@
import Translations from "../i18n/Translations"
/**
* Small helper
* Shows a wikipedia-article + wikidata preview for the given item
*/
export let wikipediaDetails: Store<FullWikipediaDetails>
</script>
<a class="flex" href={$wikipediaDetails.articleUrl} rel="noreferrer" target="_blank">
<img class="h-6 w-6" src="./assets/svg/wikipedia.svg" />
<Tr t={Translations.t.general.wikipedia.fromWikipedia} />
</a>
{#if $wikipediaDetails.articleUrl}
<a class="flex" href={$wikipediaDetails.articleUrl} rel="noreferrer" target="_blank">
<img class="h-6 w-6" src="./assets/svg/wikipedia.svg" />
<Tr t={Translations.t.general.wikipedia.fromWikipedia} />
</a>
{/if}
{#if $wikipediaDetails.wikidata}
<ToSvelte construct={WikidataPreviewBox.WikidataResponsePreview($wikipediaDetails.wikidata)} />
{/if}
{#if $wikipediaDetails.firstParagraph === "" || $wikipediaDetails.firstParagraph === undefined}
<Loading>
<Tr t={Translations.t.general.wikipedia.loading} />
</Loading>
{:else}
<span class="wikipedia-article">
<FromHtml src={$wikipediaDetails.firstParagraph} />
<Disclosure let:open>
<DisclosureButton>
<span class="flex">
<ChevronRightIcon
style={(open ? "transform: rotate(90deg); " : "") +
" transition: all .25s linear; width: 1.5rem; height: 1.5rem"}
/>
Read the rest of the article
</span>
</DisclosureButton>
<DisclosurePanel>
<FromHtml src={$wikipediaDetails.restOfArticle} />
</DisclosurePanel>
</Disclosure>
</span>
{#if $wikipediaDetails.articleUrl}
{#if $wikipediaDetails.firstParagraph === "" || $wikipediaDetails.firstParagraph === undefined}
<Loading>
<Tr t={Translations.t.general.wikipedia.loading} />
</Loading>
{:else}
<span class="wikipedia-article">
<FromHtml src={$wikipediaDetails.firstParagraph} />
<Disclosure let:open>
<DisclosureButton>
<span class="flex">
<ChevronRightIcon
style={(open ? "transform: rotate(90deg); " : "") +
" transition: all .25s linear; width: 1.5rem; height: 1.5rem"}
/>
<Tr t={Translations.t.general.wikipedia.readMore} />
</span>
</DisclosureButton>
<DisclosurePanel>
<FromHtml src={$wikipediaDetails.restOfArticle} />
</DisclosurePanel>
</Disclosure>
</span>
{/if}
{/if}

View file

@ -1,5 +0,0 @@
export interface WikipediaBoxOptions {
addHeader?: boolean
firstParagraphOnly?: true | boolean
allowToAdd?: boolean
}

View file

@ -16,7 +16,7 @@
*/
export let wikiIds: Store<string[]>
let wikipediaStores: Store<Store<FullWikipediaDetails>[]> = Locale.language.bind((language) =>
wikiIds.map((wikiIds) => wikiIds.map((id) => Wikipedia.fetchArticleAndWikidata(id, language)))
wikiIds?.map((wikiIds) => wikiIds?.map((id) => Wikipedia.fetchArticleAndWikidata(id, language)))
)
let _wikipediaStores
onDestroy(
@ -27,30 +27,36 @@
</script>
{#if _wikipediaStores !== undefined}
<TabGroup>
<TabList>
{#each _wikipediaStores as store (store.tag)}
<Tab class={({ selected }) => (selected ? "tab-selected" : "tab-unselected")}>
<WikipediaTitle wikipediaDetails={store} />
</Tab>
{/each}
</TabList>
<TabPanels>
{#each _wikipediaStores as store (store.tag)}
<TabPanel>
<WikipediaArticle wikipediaDetails={store} />
</TabPanel>
{/each}
</TabPanels>
</TabGroup>
{#if _wikipediaStores.length === 1}
<WikipediaArticle wikipediaDetails={_wikipediaStores[0]} />
{:else}
<TabGroup>
<TabList>
{#each _wikipediaStores as store (store.tag)}
<Tab class={({ selected }) => (selected ? "tab-selected" : "tab-unselected")}>
<WikipediaTitle wikipediaDetails={store} />
</Tab>
{/each}
</TabList>
<TabPanels>
{#each _wikipediaStores as store (store.tag)}
<TabPanel>
<WikipediaArticle wikipediaDetails={store} />
</TabPanel>
{/each}
</TabPanels>
</TabGroup>
{/if}
{/if}
<style>
/* Actually used, don't remove*/
.tab-selected {
background-color: rgb(59 130 246);
color: rgb(255 255 255);
}
/* Actually used, don't remove*/
.tab-unselected {
background-color: rgb(255 255 255);
color: rgb(0 0 0);

Some files were not shown because too many files have changed in this diff Show more