forked from MapComplete/MapComplete
Merge develop
This commit is contained in:
commit
55c6442cac
388 changed files with 16178 additions and 17860 deletions
73
src/Logic/Actors/PreferredRasterLayerSelector.ts
Normal file
73
src/Logic/Actors/PreferredRasterLayerSelector.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -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([
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
159
src/Logic/ImageProviders/ImageUploadManager.ts
Normal file
159
src/Logic/ImageProviders/ImageUploadManager.ts
Normal 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)
|
||||
}
|
||||
}
|
15
src/Logic/ImageProviders/ImageUploader.ts
Normal file
15
src/Logic/ImageProviders/ImageUploader.ts
Normal 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 }>
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
57
src/Logic/Osm/Actions/LinkImageAction.ts
Normal file
57
src/Logic/Osm/Actions/LinkImageAction.ts
Normal 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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
42
src/Logic/State/UserSettingsMetaTagging.ts
Normal file
42
src/Logic/State/UserSettingsMetaTagging.ts
Normal 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(/</g, "<")?.replace(/>/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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
})
|
||||
)
|
||||
|
|
|
@ -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
|
@ -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)`
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
48
src/UI/Base/FileSelector.svelte
Normal file
48
src/UI/Base/FileSelector.svelte
Normal 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>
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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")
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
81
src/UI/Image/UploadImage.svelte
Normal file
81
src/UI/Image/UploadImage.svelte
Normal 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>
|
67
src/UI/Image/UploadingImageCounter.svelte
Normal file
67
src/UI/Image/UploadingImageCounter.svelte
Normal 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}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 })
|
||||
|
|
|
@ -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 ||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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)
|
||||
|
|
150
src/UI/PlantNet/PlantNet.svelte
Normal file
150
src/UI/PlantNet/PlantNet.svelte
Normal 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>
|
36
src/UI/PlantNet/PlantNetSpeciesList.svelte
Normal file
36
src/UI/PlantNet/PlantNetSpeciesList.svelte
Normal 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}
|
61
src/UI/PlantNet/SpeciesButton.svelte
Normal file
61
src/UI/PlantNet/SpeciesButton.svelte
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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("");
|
||||
|
|
46
src/UI/Reviews/AllReviews.svelte
Normal file
46
src/UI/Reviews/AllReviews.svelte
Normal 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>
|
|
@ -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]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
106
src/UI/Reviews/ReviewForm.svelte
Normal file
106
src/UI/Reviews/ReviewForm.svelte
Normal 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}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
38
src/UI/Reviews/SingleReview.svelte
Normal file
38
src/UI/Reviews/SingleReview.svelte
Normal 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>
|
|
@ -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")
|
||||
}
|
||||
}
|
32
src/UI/Reviews/StarElement.svelte
Normal file
32
src/UI/Reviews/StarElement.svelte
Normal 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>
|
21
src/UI/Reviews/StarsBar.svelte
Normal file
21
src/UI/Reviews/StarsBar.svelte
Normal 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}
|
10
src/UI/Reviews/StarsBarIcon.svelte
Normal file
10
src/UI/Reviews/StarsBarIcon.svelte
Normal 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}
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ?? []),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
export interface WikipediaBoxOptions {
|
||||
addHeader?: boolean
|
||||
firstParagraphOnly?: true | boolean
|
||||
allowToAdd?: boolean
|
||||
}
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue