Merge branch 'develop'
This commit is contained in:
commit
7ace25d377
161 changed files with 3351 additions and 2461 deletions
67
src/Logic/Actors/PreferredRasterLayerSelector.ts
Normal file
67
src/Logic/Actors/PreferredRasterLayerSelector.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
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 }>;
|
||||
}
|
|
@ -1,60 +1,30 @@
|
|||
import ImageProvider, { ProvidedImage } from "./ImageProvider"
|
||||
import BaseUIElement from "../../UI/BaseUIElement"
|
||||
import { Utils } from "../../Utils"
|
||||
import Constants from "../../Models/Constants"
|
||||
import { LicenseInfo } from "./LicenseInfo"
|
||||
import ImageProvider, { ProvidedImage } from "./ImageProvider";
|
||||
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
|
||||
|
||||
|
@ -63,6 +33,7 @@ export class Imgur extends ImageProvider {
|
|||
formData.append("title", title)
|
||||
formData.append("description", description)
|
||||
|
||||
|
||||
const settings: RequestInit = {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
|
@ -74,17 +45,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]
|
||||
|
||||
|
|
54
src/Logic/Osm/Actions/LinkImageAction.ts
Normal file
54
src/Logic/Osm/Actions/LinkImageAction.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
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)
|
||||
}
|
||||
}
|
|
@ -19,6 +19,9 @@ export default abstract class OsmChangeAction {
|
|||
constructor(mainObjectId: string, trackStatistics: boolean = true) {
|
||||
this.trackStatistics = trackStatistics
|
||||
this.mainObjectId = mainObjectId
|
||||
if(mainObjectId === undefined || mainObjectId === null){
|
||||
throw "OsmObject received '"+mainObjectId+"' as mainObjectId"
|
||||
}
|
||||
}
|
||||
|
||||
public async Perform(changes: Changes) {
|
||||
|
|
|
@ -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,11 @@ 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
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import { UIEventSource } from "../UIEventSource"
|
||||
import { LocalStorageSource } from "../Web/LocalStorageSource"
|
||||
import { QueryParameters } from "../Web/QueryParameters"
|
||||
import { UIEventSource } from "../UIEventSource";
|
||||
import { LocalStorageSource } from "../Web/LocalStorageSource";
|
||||
import { QueryParameters } from "../Web/QueryParameters";
|
||||
|
||||
export type GeolocationPermissionState = "prompt" | "requested" | "granted" | "denied"
|
||||
|
||||
export interface GeoLocationPointProperties extends GeolocationCoordinates {
|
||||
id: "gps"
|
||||
"user:location": "yes"
|
||||
date: string
|
||||
id: "gps";
|
||||
"user:location": "yes";
|
||||
date: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -23,22 +23,22 @@ export class GeoLocationState {
|
|||
*/
|
||||
public readonly permission: UIEventSource<GeolocationPermissionState> = new UIEventSource(
|
||||
"prompt"
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Important to determine e.g. if we move automatically on fix or not
|
||||
*/
|
||||
public readonly requestMoment: UIEventSource<Date | undefined> = new UIEventSource(undefined)
|
||||
public readonly requestMoment: UIEventSource<Date | undefined> = new UIEventSource(undefined);
|
||||
/**
|
||||
* If true: the map will center (and re-center) to this location
|
||||
*/
|
||||
public readonly allowMoving: UIEventSource<boolean> = new UIEventSource<boolean>(true)
|
||||
public readonly allowMoving: UIEventSource<boolean> = new UIEventSource<boolean>(true);
|
||||
|
||||
/**
|
||||
* The latest GeoLocationCoordinates, as given by the WebAPI
|
||||
*/
|
||||
public readonly currentGPSLocation: UIEventSource<GeolocationCoordinates | undefined> =
|
||||
new UIEventSource<GeolocationCoordinates | undefined>(undefined)
|
||||
new UIEventSource<GeolocationCoordinates | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* A small flag on localstorage. If the user previously granted the geolocation, it will be set.
|
||||
|
@ -50,69 +50,49 @@ export class GeoLocationState {
|
|||
*/
|
||||
private readonly _previousLocationGrant: UIEventSource<"true" | "false"> = <any>(
|
||||
LocalStorageSource.Get("geolocation-permissions")
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Used to detect a permission retraction
|
||||
*/
|
||||
private readonly _grantedThisSession: UIEventSource<boolean> = new UIEventSource<boolean>(false)
|
||||
private readonly _grantedThisSession: UIEventSource<boolean> = new UIEventSource<boolean>(false);
|
||||
|
||||
constructor() {
|
||||
const self = this
|
||||
const self = this;
|
||||
|
||||
this.permission.addCallbackAndRunD(async (state) => {
|
||||
if (state === "granted") {
|
||||
self._previousLocationGrant.setData("true")
|
||||
self._grantedThisSession.setData(true)
|
||||
self._previousLocationGrant.setData("true");
|
||||
self._grantedThisSession.setData(true);
|
||||
}
|
||||
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)
|
||||
console.warn("Detected a downgrade in permissions!")
|
||||
self._previousLocationGrant.setData("false");
|
||||
self.permission.setData("denied");
|
||||
self.currentGPSLocation.setData(undefined);
|
||||
console.warn("Detected a downgrade in permissions!");
|
||||
}
|
||||
if (state === "denied") {
|
||||
self._previousLocationGrant.setData("false")
|
||||
self._previousLocationGrant.setData("false");
|
||||
}
|
||||
})
|
||||
console.log("Previous location grant:", this._previousLocationGrant.data)
|
||||
});
|
||||
console.log("Previous location grant:", this._previousLocationGrant.data);
|
||||
if (this._previousLocationGrant.data === "true") {
|
||||
// A previous visit successfully granted permission. Chance is high that we are allowed to use it again!
|
||||
|
||||
// We set the flag to false again. If the user only wanted to share their location once, we are not gonna keep bothering them
|
||||
this._previousLocationGrant.setData("false")
|
||||
console.log("Requesting access to GPS as this was previously granted")
|
||||
this._previousLocationGrant.setData("false");
|
||||
console.log("Requesting access to GPS as this was previously granted");
|
||||
const latLonGivenViaUrl =
|
||||
QueryParameters.wasInitialized("lat") || QueryParameters.wasInitialized("lon")
|
||||
QueryParameters.wasInitialized("lat") || QueryParameters.wasInitialized("lon");
|
||||
if (!latLonGivenViaUrl) {
|
||||
this.requestMoment.setData(new Date())
|
||||
this.requestMoment.setData(new Date());
|
||||
}
|
||||
this.requestPermission()
|
||||
this.requestPermission();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the listener for updates
|
||||
* @private
|
||||
*/
|
||||
private async startWatching() {
|
||||
const self = this
|
||||
navigator.geolocation.watchPosition(
|
||||
function (position) {
|
||||
self.currentGPSLocation.setData(position.coords)
|
||||
self._previousLocationGrant.setData("true")
|
||||
},
|
||||
function () {
|
||||
console.warn("Could not get location with navigator.geolocation")
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the user to allow access to their position.
|
||||
* When granted, will be written to the 'geolocationState'.
|
||||
|
@ -121,33 +101,57 @@ export class GeoLocationState {
|
|||
public requestPermission() {
|
||||
if (typeof navigator === "undefined") {
|
||||
// Not compatible with this browser
|
||||
this.permission.setData("denied")
|
||||
return
|
||||
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
|
||||
return;
|
||||
}
|
||||
|
||||
this.permission.setData("requested")
|
||||
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 () {
|
||||
const self = this;
|
||||
if(status.state === "granted" || status.state === "denied"){
|
||||
self.permission.setData(status.state)
|
||||
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()
|
||||
self.startWatching();
|
||||
})
|
||||
.catch((e) => console.error("Could not get geopermission", e))
|
||||
.catch((e) => console.error("Could not get geopermission", e));
|
||||
} catch (e) {
|
||||
console.error("Could not get permission:", e)
|
||||
console.error("Could not get permission:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the listener for updates
|
||||
* @private
|
||||
*/
|
||||
private async startWatching() {
|
||||
const self = this;
|
||||
navigator.geolocation.watchPosition(
|
||||
function(position) {
|
||||
self.currentGPSLocation.setData(position.coords);
|
||||
self._previousLocationGrant.setData("true");
|
||||
},
|
||||
function() {
|
||||
console.warn("Could not get location with navigator.geolocation");
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,21 +1,23 @@
|
|||
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
|
||||
import { OsmConnection } from "../Osm/OsmConnection"
|
||||
import { MangroveIdentity } from "../Web/MangroveReviews"
|
||||
import { Store, Stores, UIEventSource } from "../UIEventSource"
|
||||
import StaticFeatureSource from "../FeatureSource/Sources/StaticFeatureSource"
|
||||
import { FeatureSource } from "../FeatureSource/FeatureSource"
|
||||
import { Feature } from "geojson"
|
||||
import { Utils } from "../../Utils"
|
||||
import translators from "../../assets/translators.json"
|
||||
import codeContributors from "../../assets/contributors.json"
|
||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
|
||||
import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson"
|
||||
import usersettings from "../../../src/assets/generated/layers/usersettings.json"
|
||||
import Locale from "../../UI/i18n/Locale"
|
||||
import LinkToWeblate from "../../UI/Base/LinkToWeblate"
|
||||
import FeatureSwitchState from "./FeatureSwitchState"
|
||||
import Constants from "../../Models/Constants"
|
||||
import { QueryParameters } from "../Web/QueryParameters"
|
||||
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
|
||||
import { OsmConnection } from "../Osm/OsmConnection";
|
||||
import { MangroveIdentity } from "../Web/MangroveReviews";
|
||||
import { Store, Stores, UIEventSource } from "../UIEventSource";
|
||||
import StaticFeatureSource from "../FeatureSource/Sources/StaticFeatureSource";
|
||||
import { FeatureSource } from "../FeatureSource/FeatureSource";
|
||||
import { Feature } from "geojson";
|
||||
import { Utils } from "../../Utils";
|
||||
import translators from "../../assets/translators.json";
|
||||
import codeContributors from "../../assets/contributors.json";
|
||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
|
||||
import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson";
|
||||
import usersettings from "../../../src/assets/generated/layers/usersettings.json";
|
||||
import Locale from "../../UI/i18n/Locale";
|
||||
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,8 @@ 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 +55,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 +100,17 @@ 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 +262,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:
|
||||
|
@ -259,6 +277,7 @@ export default class UserRelatedState {
|
|||
amendedPrefs.data["__url_parameter_initialized:" + key] = "yes"
|
||||
}
|
||||
|
||||
|
||||
const osmConnection = this.osmConnection
|
||||
osmConnection.preferencesHandler.preferences.addCallback((newPrefs) => {
|
||||
for (const k in newPrefs) {
|
||||
|
@ -279,7 +298,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 +344,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 +405,13 @@ export default class UserRelatedState {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
this._mapProperties?.rasterLayer?.addCallbackAndRun(l => {
|
||||
amendedPrefs.data["__current_background"] = l?.properties?.id
|
||||
amendedPrefs.ping()
|
||||
})
|
||||
|
||||
|
||||
return amendedPrefs
|
||||
}
|
||||
}
|
||||
|
|
14
src/Logic/State/UserSettingsMetaTagging.ts
Normal file
14
src/Logic/State/UserSettingsMetaTagging.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
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'
|
||||
}
|
||||
}
|
|
@ -515,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())
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,43 +1,43 @@
|
|||
import { Feature, Polygon } from "geojson"
|
||||
import * as editorlayerindex from "../assets/editor-layer-index.json"
|
||||
import * as globallayers from "../assets/global-raster-layers.json"
|
||||
import { BBox } from "../Logic/BBox"
|
||||
import { Store, Stores } from "../Logic/UIEventSource"
|
||||
import { GeoOperations } from "../Logic/GeoOperations"
|
||||
import { RasterLayerProperties } from "./RasterLayerProperties"
|
||||
import { Feature, Polygon } from "geojson";
|
||||
import * as editorlayerindex from "../assets/editor-layer-index.json";
|
||||
import * as globallayers from "../assets/global-raster-layers.json";
|
||||
import { BBox } from "../Logic/BBox";
|
||||
import { Store, Stores } from "../Logic/UIEventSource";
|
||||
import { GeoOperations } from "../Logic/GeoOperations";
|
||||
import { RasterLayerProperties } from "./RasterLayerProperties";
|
||||
|
||||
export class AvailableRasterLayers {
|
||||
public static EditorLayerIndex: (Feature<Polygon, EditorLayerIndexProperties> &
|
||||
RasterLayerPolygon)[] = <any>editorlayerindex.features
|
||||
RasterLayerPolygon)[] = <any>editorlayerindex.features;
|
||||
public static globalLayers: RasterLayerPolygon[] = globallayers.layers.map(
|
||||
(properties) =>
|
||||
<RasterLayerPolygon>{
|
||||
type: "Feature",
|
||||
properties,
|
||||
geometry: BBox.global.asGeometry(),
|
||||
geometry: BBox.global.asGeometry()
|
||||
}
|
||||
)
|
||||
);
|
||||
public static readonly osmCartoProperties: RasterLayerProperties = {
|
||||
id: "osm",
|
||||
name: "OpenStreetMap",
|
||||
url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
attribution: {
|
||||
text: "OpenStreetMap",
|
||||
url: "https://openStreetMap.org/copyright",
|
||||
url: "https://openStreetMap.org/copyright"
|
||||
},
|
||||
best: true,
|
||||
max_zoom: 19,
|
||||
min_zoom: 0,
|
||||
category: "osmbasedmap",
|
||||
}
|
||||
category: "osmbasedmap"
|
||||
};
|
||||
|
||||
public static readonly osmCarto: RasterLayerPolygon = {
|
||||
type: "Feature",
|
||||
properties: AvailableRasterLayers.osmCartoProperties,
|
||||
geometry: BBox.global.asGeometry(),
|
||||
}
|
||||
geometry: BBox.global.asGeometry()
|
||||
};
|
||||
|
||||
public static readonly maplibre: RasterLayerPolygon = {
|
||||
public static readonly maptilerDefaultLayer: RasterLayerPolygon = {
|
||||
type: "Feature",
|
||||
properties: {
|
||||
name: "MapTiler",
|
||||
|
@ -47,12 +47,43 @@ export class AvailableRasterLayers {
|
|||
type: "vector",
|
||||
attribution: {
|
||||
text: "Maptiler",
|
||||
url: "https://www.maptiler.com/copyright/",
|
||||
},
|
||||
url: "https://www.maptiler.com/copyright/"
|
||||
}
|
||||
},
|
||||
geometry: BBox.global.asGeometry(),
|
||||
}
|
||||
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: {
|
||||
|
@ -63,41 +94,43 @@ export class AvailableRasterLayers {
|
|||
type: "vector",
|
||||
attribution: {
|
||||
text: "Americana",
|
||||
url: "https://github.com/ZeLonewolf/openstreetmap-americana/",
|
||||
},
|
||||
url: "https://github.com/ZeLonewolf/openstreetmap-americana/"
|
||||
}
|
||||
},
|
||||
geometry: BBox.global.asGeometry(),
|
||||
}
|
||||
geometry: BBox.global.asGeometry()
|
||||
};
|
||||
|
||||
public static layersAvailableAt(
|
||||
location: Store<{ lon: number; lat: number }>
|
||||
): Store<RasterLayerPolygon[]> {
|
||||
const availableLayersBboxes = Stores.ListStabilized(
|
||||
location.mapD((loc) => {
|
||||
const lonlat: [number, number] = [loc.lon, loc.lat]
|
||||
const lonlat: [number, number] = [loc.lon, loc.lat];
|
||||
return AvailableRasterLayers.EditorLayerIndex.filter((eliPolygon) =>
|
||||
BBox.get(eliPolygon).contains(lonlat)
|
||||
)
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
const available = Stores.ListStabilized(
|
||||
availableLayersBboxes.map((eliPolygons) => {
|
||||
const loc = location.data
|
||||
const lonlat: [number, number] = [loc.lon, loc.lat]
|
||||
const loc = location.data;
|
||||
const lonlat: [number, number] = [loc.lon, loc.lat];
|
||||
const matching: RasterLayerPolygon[] = eliPolygons.filter((eliPolygon) => {
|
||||
if (eliPolygon.geometry === null) {
|
||||
return true // global ELI-layer
|
||||
return true; // global ELI-layer
|
||||
}
|
||||
return GeoOperations.inside(lonlat, eliPolygon)
|
||||
})
|
||||
matching.unshift(AvailableRasterLayers.osmCarto)
|
||||
matching.unshift(AvailableRasterLayers.americana)
|
||||
matching.unshift(AvailableRasterLayers.maplibre)
|
||||
matching.push(...AvailableRasterLayers.globalLayers)
|
||||
return matching
|
||||
return GeoOperations.inside(lonlat, eliPolygon);
|
||||
});
|
||||
matching.push(...AvailableRasterLayers.globalLayers);
|
||||
matching.unshift(AvailableRasterLayers.maptilerDefaultLayer,
|
||||
AvailableRasterLayers.osmCarto,
|
||||
AvailableRasterLayers.maptilerCarto,
|
||||
AvailableRasterLayers.maptilerBackdrop,
|
||||
AvailableRasterLayers.americana);
|
||||
return matching;
|
||||
})
|
||||
)
|
||||
return available
|
||||
);
|
||||
return available;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -115,22 +148,22 @@ export class RasterLayerUtils {
|
|||
preferredCategory: string,
|
||||
ignoreLayer?: RasterLayerPolygon
|
||||
): RasterLayerPolygon {
|
||||
let secondBest: RasterLayerPolygon = undefined
|
||||
let secondBest: RasterLayerPolygon = undefined;
|
||||
for (const rasterLayer of available) {
|
||||
if (rasterLayer === ignoreLayer) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
const p = rasterLayer.properties
|
||||
const p = rasterLayer.properties;
|
||||
if (p.category === preferredCategory) {
|
||||
if (p.best) {
|
||||
return rasterLayer
|
||||
return rasterLayer;
|
||||
}
|
||||
if (!secondBest) {
|
||||
secondBest = rasterLayer
|
||||
secondBest = rasterLayer;
|
||||
}
|
||||
}
|
||||
}
|
||||
return secondBest
|
||||
return secondBest;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -146,11 +179,11 @@ export interface EditorLayerIndexProperties extends RasterLayerProperties {
|
|||
/**
|
||||
* The name of the imagery source
|
||||
*/
|
||||
readonly name: string
|
||||
readonly name: string;
|
||||
/**
|
||||
* Whether the imagery name should be translated
|
||||
*/
|
||||
readonly i18n?: boolean
|
||||
readonly i18n?: boolean;
|
||||
readonly type:
|
||||
| "tms"
|
||||
| "wms"
|
||||
|
@ -158,7 +191,7 @@ export interface EditorLayerIndexProperties extends RasterLayerProperties {
|
|||
| "scanex"
|
||||
| "wms_endpoint"
|
||||
| "wmts"
|
||||
| "vector" /* Vector is not actually part of the ELI-spec, we add it for vector layers */
|
||||
| "vector"; /* Vector is not actually part of the ELI-spec, we add it for vector layers */
|
||||
/**
|
||||
* A rough categorisation of different types of layers. See https://github.com/osmlab/editor-layer-index/blob/gh-pages/CONTRIBUTING.md#categories for a description of the individual categories.
|
||||
*/
|
||||
|
@ -170,53 +203,53 @@ export interface EditorLayerIndexProperties extends RasterLayerProperties {
|
|||
| "historicphoto"
|
||||
| "qa"
|
||||
| "elevation"
|
||||
| "other"
|
||||
| "other";
|
||||
/**
|
||||
* A URL template for imagery tiles
|
||||
*/
|
||||
readonly url: string
|
||||
readonly min_zoom?: number
|
||||
readonly max_zoom?: number
|
||||
readonly url: string;
|
||||
readonly min_zoom?: number;
|
||||
readonly max_zoom?: number;
|
||||
/**
|
||||
* explicit/implicit permission by the owner for use in OSM
|
||||
*/
|
||||
readonly permission_osm?: "explicit" | "implicit" | "no"
|
||||
readonly permission_osm?: "explicit" | "implicit" | "no";
|
||||
/**
|
||||
* A URL for the license or permissions for the imagery
|
||||
*/
|
||||
readonly license_url?: string
|
||||
readonly license_url?: string;
|
||||
/**
|
||||
* A URL for the privacy policy of the operator or false if there is no existing privacy policy for tis imagery.
|
||||
*/
|
||||
readonly privacy_policy_url?: string | boolean
|
||||
readonly privacy_policy_url?: string | boolean;
|
||||
/**
|
||||
* A unique identifier for the source; used in imagery_used changeset tag
|
||||
*/
|
||||
readonly id: string
|
||||
readonly id: string;
|
||||
/**
|
||||
* A short English-language description of the source
|
||||
*/
|
||||
readonly description?: string
|
||||
readonly description?: string;
|
||||
/**
|
||||
* The ISO 3166-1 alpha-2 two letter country code in upper case. Use ZZ for unknown or multiple.
|
||||
*/
|
||||
readonly country_code?: string
|
||||
readonly country_code?: string;
|
||||
/**
|
||||
* Whether this imagery should be shown in the default world-wide menu
|
||||
*/
|
||||
readonly default?: boolean
|
||||
readonly default?: boolean;
|
||||
/**
|
||||
* Whether this imagery is the best source for the region
|
||||
*/
|
||||
readonly best?: boolean
|
||||
readonly best?: boolean;
|
||||
/**
|
||||
* The age of the oldest imagery or data in the source, as an RFC3339 date or leading portion of one
|
||||
*/
|
||||
readonly start_date?: string
|
||||
readonly start_date?: string;
|
||||
/**
|
||||
* The age of the newest imagery or data in the source, as an RFC3339 date or leading portion of one
|
||||
*/
|
||||
readonly end_date?: string
|
||||
readonly end_date?: string;
|
||||
/**
|
||||
* HTTP header to check for information if the tile is invalid
|
||||
*/
|
||||
|
@ -226,61 +259,61 @@ export interface EditorLayerIndexProperties extends RasterLayerProperties {
|
|||
* via the `patternProperty` "^.*$".
|
||||
*/
|
||||
[k: string]: string[] | null
|
||||
}
|
||||
};
|
||||
/**
|
||||
* 'true' if tiles are transparent and can be overlaid on another source
|
||||
*/
|
||||
readonly overlay?: boolean & string
|
||||
readonly available_projections?: string[]
|
||||
readonly overlay?: boolean & string;
|
||||
readonly available_projections?: string[];
|
||||
readonly attribution?: {
|
||||
readonly url?: string
|
||||
readonly text?: string
|
||||
readonly html?: string
|
||||
readonly required?: boolean
|
||||
}
|
||||
};
|
||||
/**
|
||||
* A URL for an image, that can be displayed in the list of imagery layers next to the name
|
||||
*/
|
||||
readonly icon?: string
|
||||
readonly icon?: string;
|
||||
/**
|
||||
* A link to an EULA text that has to be accepted by the user, before the imagery source is added. Can contain {lang} to be replaced by a current user language wiki code (like FR:) or an empty string for the default English text.
|
||||
*/
|
||||
readonly eula?: string
|
||||
readonly eula?: string;
|
||||
/**
|
||||
* A URL for an image, that is displayed in the mapview for attribution
|
||||
*/
|
||||
readonly "logo-image"?: string
|
||||
readonly "logo-image"?: string;
|
||||
/**
|
||||
* Customized text for the terms of use link (default is "Background Terms of Use")
|
||||
*/
|
||||
readonly "terms-of-use-text"?: string
|
||||
readonly "terms-of-use-text"?: string;
|
||||
/**
|
||||
* Specify a checksum for tiles, which aren't real tiles. `type` is the digest type and can be MD5, SHA-1, SHA-256, SHA-384 and SHA-512, value is the hex encoded checksum in lower case. To create a checksum save the tile as file and upload it to e.g. https://defuse.ca/checksums.htm.
|
||||
*/
|
||||
readonly "no-tile-checksum"?: string
|
||||
readonly "no-tile-checksum"?: string;
|
||||
/**
|
||||
* header-name attribute specifies a header returned by tile server, that will be shown as `metadata-key` attribute in Show Tile Info dialog
|
||||
*/
|
||||
readonly "metadata-header"?: string
|
||||
readonly "metadata-header"?: string;
|
||||
/**
|
||||
* Set to `true` if imagery source is properly aligned and does not need imagery offset adjustments. This is used for OSM based sources too.
|
||||
*/
|
||||
readonly "valid-georeference"?: boolean
|
||||
readonly "valid-georeference"?: boolean;
|
||||
/**
|
||||
* Size of individual tiles delivered by a TMS service
|
||||
*/
|
||||
readonly "tile-size"?: number
|
||||
readonly "tile-size"?: number;
|
||||
/**
|
||||
* Whether tiles status can be accessed by appending /status to the tile URL and can be submitted for re-rendering by appending /dirty.
|
||||
*/
|
||||
readonly "mod-tile-features"?: string
|
||||
readonly "mod-tile-features"?: string;
|
||||
/**
|
||||
* HTTP headers to be sent to server. It has two attributes header-name and header-value. May be specified multiple times.
|
||||
*/
|
||||
readonly "custom-http-headers"?: {
|
||||
readonly "header-name"?: string
|
||||
readonly "header-value"?: string
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Default layer to open (when using WMS_ENDPOINT type). Contains list of layer tag with two attributes - name and style, e.g. `"default-layers": ["layer": { name="Basisdata_NP_Basiskart_JanMayen_WMTS_25829" "style":"default" } ]` (not allowed in `mirror` attribute)
|
||||
*/
|
||||
|
@ -291,17 +324,17 @@ export interface EditorLayerIndexProperties extends RasterLayerProperties {
|
|||
[k: string]: unknown
|
||||
}
|
||||
[k: string]: unknown
|
||||
}[]
|
||||
}[];
|
||||
/**
|
||||
* format to use when connecting tile server (when using WMS_ENDPOINT type)
|
||||
*/
|
||||
readonly format?: string
|
||||
readonly format?: string;
|
||||
/**
|
||||
* If `true` transparent tiles will be requested from WMS server
|
||||
*/
|
||||
readonly transparent?: boolean & string
|
||||
readonly transparent?: boolean & string;
|
||||
/**
|
||||
* minimum expiry time for tiles in seconds. The larger the value, the longer entry in cache will be considered valid
|
||||
*/
|
||||
readonly "minimum-tile-expire"?: number
|
||||
readonly "minimum-tile-expire"?: number;
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import LineRenderingConfigJson from "../Json/LineRenderingConfigJson"
|
|||
import { LayerConfigJson } from "../Json/LayerConfigJson"
|
||||
import { DesugaringStep, Each, Fuse, On } from "./Conversion"
|
||||
import PointRenderingConfigJson from "../Json/PointRenderingConfigJson"
|
||||
import { del } from "idb-keyval";
|
||||
|
||||
export class UpdateLegacyLayer extends DesugaringStep<
|
||||
LayerConfigJson | string | { builtin; override }
|
||||
|
@ -41,7 +42,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) {
|
||||
|
@ -146,7 +146,6 @@ export class UpdateLegacyLayer extends DesugaringStep<
|
|||
}
|
||||
const pr = <PointRenderingConfigJson>rendering
|
||||
let iconSize = pr.iconSize
|
||||
console.log("Iconsize is", iconSize)
|
||||
|
||||
if (Object.keys(pr.iconSize).length === 1 && pr.iconSize["render"] !== undefined) {
|
||||
iconSize = pr.iconSize["render"]
|
||||
|
@ -198,6 +197,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"]
|
||||
|
|
|
@ -18,6 +18,8 @@ import { QuestionableTagRenderingConfigJson } from "../Json/QuestionableTagRende
|
|||
import Validators from "../../../UI/InputElement/Validators"
|
||||
import TagRenderingConfig from "../TagRenderingConfig"
|
||||
import { parse as parse_html } from "node-html-parser"
|
||||
import PresetConfig from "../PresetConfig"
|
||||
import { TagsFilter } from "../../../Logic/Tags/TagsFilter"
|
||||
|
||||
class ValidateLanguageCompleteness extends DesugaringStep<any> {
|
||||
private readonly _languages: string[]
|
||||
|
@ -167,9 +169,9 @@ class ValidateTheme extends DesugaringStep<LayoutConfigJson> {
|
|||
json: LayoutConfigJson,
|
||||
context: string
|
||||
): { result: LayoutConfigJson; errors: string[]; warnings: string[]; information: string[] } {
|
||||
const errors = []
|
||||
const warnings = []
|
||||
const information = []
|
||||
const errors: string[] = []
|
||||
const warnings: string[] = []
|
||||
const information: string[] = []
|
||||
|
||||
const theme = new LayoutConfig(json, this._isBuiltin)
|
||||
|
||||
|
@ -245,7 +247,7 @@ class ValidateTheme extends DesugaringStep<LayoutConfigJson> {
|
|||
information
|
||||
)
|
||||
}
|
||||
const dups = Utils.Dupiclates(json.layers.map((layer) => layer["id"]))
|
||||
const dups = Utils.Duplicates(json.layers.map((layer) => layer["id"]))
|
||||
if (dups.length > 0) {
|
||||
errors.push(
|
||||
`The theme ${json.id} defines multiple layers with id ${dups.join(", ")}`
|
||||
|
@ -275,6 +277,10 @@ class ValidateTheme extends DesugaringStep<LayoutConfigJson> {
|
|||
errors.push(e)
|
||||
}
|
||||
|
||||
if (theme.id !== "personal") {
|
||||
new DetectDuplicatePresets().convertJoin(theme, context, errors, warnings, information)
|
||||
}
|
||||
|
||||
return {
|
||||
result: json,
|
||||
errors,
|
||||
|
@ -838,6 +844,15 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
|
|||
}
|
||||
}
|
||||
|
||||
const layerConfig = new LayerConfig(json, "validation", true)
|
||||
for (const [attribute, code, isStrict] of layerConfig.calculatedTags ?? []) {
|
||||
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}`
|
||||
}
|
||||
}
|
||||
|
||||
if (json.source === "special") {
|
||||
if (!Constants.priviliged_layers.find((x) => x == json.id)) {
|
||||
errors.push(
|
||||
|
@ -880,7 +895,7 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
|
|||
{
|
||||
// duplicate ids in tagrenderings check
|
||||
const duplicates = Utils.Dedup(
|
||||
Utils.Dupiclates(Utils.NoNull((json.tagRenderings ?? []).map((tr) => tr["id"])))
|
||||
Utils.Duplicates(Utils.NoNull((json.tagRenderings ?? []).map((tr) => tr["id"])))
|
||||
)
|
||||
if (duplicates.length > 0) {
|
||||
console.log(json.tagRenderings)
|
||||
|
@ -976,7 +991,7 @@ export class ValidateLayer extends DesugaringStep<LayerConfigJson> {
|
|||
)
|
||||
}
|
||||
|
||||
const duplicateIds = Utils.Dupiclates(
|
||||
const duplicateIds = Utils.Duplicates(
|
||||
(json.tagRenderings ?? [])
|
||||
?.map((f) => f["id"])
|
||||
.filter((id) => id !== "questions")
|
||||
|
@ -1234,3 +1249,68 @@ export class DetectDuplicateFilters extends DesugaringStep<{
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class DetectDuplicatePresets extends DesugaringStep<LayoutConfig> {
|
||||
constructor() {
|
||||
super(
|
||||
"Detects mappings which have identical (english) names or identical mappings.",
|
||||
["presets"],
|
||||
"DetectDuplicatePresets"
|
||||
)
|
||||
}
|
||||
convert(
|
||||
json: LayoutConfig,
|
||||
context: string
|
||||
): {
|
||||
result: LayoutConfig
|
||||
errors?: string[]
|
||||
warnings?: string[]
|
||||
information?: string[]
|
||||
} {
|
||||
const presets: PresetConfig[] = [].concat(...json.layers.map((l) => l.presets))
|
||||
|
||||
const errors = []
|
||||
const enNames = presets.map((p) => p.title.textFor("en"))
|
||||
if (new Set(enNames).size != enNames.length) {
|
||||
const dups = Utils.Duplicates(enNames)
|
||||
const layersWithDup = json.layers.filter((l) =>
|
||||
l.presets.some((p) => dups.indexOf(p.title.textFor("en")) >= 0)
|
||||
)
|
||||
const layerIds = layersWithDup.map((l) => l.id)
|
||||
errors.push(
|
||||
`At ${context}: this themes has multiple presets which are named:${dups}, namely layers ${layerIds.join(
|
||||
", "
|
||||
)} this is confusing for contributors and is probably the result of reusing the same layer multiple times. Use \`{"override": {"=presets": []}}\` to remove some presets`
|
||||
)
|
||||
}
|
||||
|
||||
const optimizedTags = <TagsFilter[]>presets.map((p) => new And(p.tags).optimize())
|
||||
for (let i = 0; i < presets.length; i++) {
|
||||
const presetATags = optimizedTags[i]
|
||||
const presetA = presets[i]
|
||||
for (let j = i + 1; j < presets.length; j++) {
|
||||
const presetBTags = optimizedTags[j]
|
||||
const presetB = presets[j]
|
||||
if (
|
||||
Utils.SameObject(presetATags, presetBTags) &&
|
||||
Utils.sameList(
|
||||
presetA.preciseInput.snapToLayers,
|
||||
presetB.preciseInput.snapToLayers
|
||||
)
|
||||
) {
|
||||
errors.push(
|
||||
`At ${context}: this themes has multiple presets with the same tags: ${presetATags.asHumanString(
|
||||
false,
|
||||
false,
|
||||
{}
|
||||
)}, namely the preset '${presets[i].title.textFor("en")}' and '${presets[
|
||||
j
|
||||
].title.textFor("en")}'`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { errors, result: json }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -204,12 +204,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])
|
||||
}
|
||||
}
|
||||
|
@ -365,7 +359,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)`
|
||||
}
|
||||
|
|
|
@ -1,59 +1,58 @@
|
|||
import LayoutConfig from "./ThemeConfig/LayoutConfig"
|
||||
import { SpecialVisualizationState } from "../UI/SpecialVisualization"
|
||||
import { Changes } from "../Logic/Osm/Changes"
|
||||
import { ImmutableStore, Store, UIEventSource } from "../Logic/UIEventSource"
|
||||
import LayoutConfig from "./ThemeConfig/LayoutConfig";
|
||||
import { SpecialVisualizationState } from "../UI/SpecialVisualization";
|
||||
import { Changes } from "../Logic/Osm/Changes";
|
||||
import { 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 {
|
||||
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"
|
||||
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";
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -64,68 +63,71 @@ import FilteredLayer from "./FilteredLayer"
|
|||
* It ties up all the needed elements and starts some actors.
|
||||
*/
|
||||
export default class ThemeViewState implements SpecialVisualizationState {
|
||||
readonly layout: LayoutConfig
|
||||
readonly map: UIEventSource<MlMap>
|
||||
readonly changes: Changes
|
||||
readonly featureSwitches: FeatureSwitchState
|
||||
readonly featureSwitchIsTesting: Store<boolean>
|
||||
readonly featureSwitchUserbadge: Store<boolean>
|
||||
readonly layout: LayoutConfig;
|
||||
readonly map: UIEventSource<MlMap>;
|
||||
readonly changes: Changes;
|
||||
readonly featureSwitches: FeatureSwitchState;
|
||||
readonly featureSwitchIsTesting: Store<boolean>;
|
||||
readonly featureSwitchUserbadge: Store<boolean>;
|
||||
|
||||
readonly featureProperties: FeaturePropertiesStore
|
||||
readonly featureProperties: FeaturePropertiesStore;
|
||||
|
||||
readonly osmConnection: OsmConnection
|
||||
readonly selectedElement: UIEventSource<Feature>
|
||||
readonly selectedElementAndLayer: Store<{ feature: Feature; layer: LayerConfig }>
|
||||
readonly mapProperties: MapProperties & ExportableMap
|
||||
readonly osmObjectDownloader: OsmObjectDownloader
|
||||
readonly osmConnection: OsmConnection;
|
||||
readonly selectedElement: UIEventSource<Feature>;
|
||||
readonly selectedElementAndLayer: Store<{ feature: Feature; layer: LayerConfig }>;
|
||||
readonly mapProperties: MapProperties & ExportableMap;
|
||||
readonly osmObjectDownloader: OsmObjectDownloader;
|
||||
|
||||
readonly dataIsLoading: Store<boolean>
|
||||
readonly dataIsLoading: Store<boolean>;
|
||||
/**
|
||||
* Indicates if there is _some_ data in view, even if it is not shown due to the filters
|
||||
*/
|
||||
readonly hasDataInView: Store<FeatureViewState>
|
||||
readonly hasDataInView: Store<FeatureViewState>;
|
||||
|
||||
readonly guistate: MenuState
|
||||
readonly fullNodeDatabase?: FullNodeDatabaseSource
|
||||
readonly guistate: MenuState;
|
||||
readonly fullNodeDatabase?: FullNodeDatabaseSource;
|
||||
|
||||
readonly historicalUserLocations: WritableFeatureSource<Feature<Point>>
|
||||
readonly indexedFeatures: IndexedFeatureSource & LayoutSource
|
||||
readonly currentView: FeatureSource<Feature<Polygon>>
|
||||
readonly featuresInView: FeatureSource
|
||||
readonly newFeatures: WritableFeatureSource
|
||||
readonly layerState: LayerState
|
||||
readonly perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer>
|
||||
readonly perLayerFiltered: ReadonlyMap<string, FilteringFeatureSource>
|
||||
readonly historicalUserLocations: WritableFeatureSource<Feature<Point>>;
|
||||
readonly indexedFeatures: IndexedFeatureSource & LayoutSource;
|
||||
readonly currentView: FeatureSource<Feature<Polygon>>;
|
||||
readonly featuresInView: FeatureSource;
|
||||
readonly newFeatures: WritableFeatureSource;
|
||||
readonly layerState: LayerState;
|
||||
readonly perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer>;
|
||||
readonly perLayerFiltered: ReadonlyMap<string, FilteringFeatureSource>;
|
||||
|
||||
readonly availableLayers: Store<RasterLayerPolygon[]>
|
||||
readonly selectedLayer: UIEventSource<LayerConfig>
|
||||
readonly userRelatedState: UserRelatedState
|
||||
readonly geolocation: GeoLocationHandler
|
||||
readonly availableLayers: Store<RasterLayerPolygon[]>;
|
||||
readonly selectedLayer: UIEventSource<LayerConfig>;
|
||||
readonly userRelatedState: UserRelatedState;
|
||||
readonly geolocation: GeoLocationHandler;
|
||||
|
||||
readonly lastClickObject: WritableFeatureSource
|
||||
readonly imageUploadManager: ImageUploadManager
|
||||
|
||||
readonly lastClickObject: WritableFeatureSource;
|
||||
readonly overlayLayerStates: ReadonlyMap<
|
||||
string,
|
||||
{ readonly isDisplayed: UIEventSource<boolean> }
|
||||
>
|
||||
>;
|
||||
/**
|
||||
* All 'level'-tags that are available with the current features
|
||||
*/
|
||||
readonly floors: Store<string[]>
|
||||
readonly floors: Store<string[]>;
|
||||
|
||||
constructor(layout: LayoutConfig) {
|
||||
this.layout = layout
|
||||
this.featureSwitches = new FeatureSwitchState(layout)
|
||||
Utils.initDomPurify();
|
||||
this.layout = layout;
|
||||
this.featureSwitches = new FeatureSwitchState(layout);
|
||||
this.guistate = new MenuState(
|
||||
this.featureSwitches.featureSwitchWelcomeMessage.data,
|
||||
layout.id
|
||||
)
|
||||
this.map = new UIEventSource<MlMap>(undefined)
|
||||
const initial = new InitialMapPositioning(layout)
|
||||
this.mapProperties = new MapLibreAdaptor(this.map, initial)
|
||||
const geolocationState = new GeoLocationState()
|
||||
);
|
||||
this.map = new UIEventSource<MlMap>(undefined);
|
||||
const initial = new InitialMapPositioning(layout);
|
||||
this.mapProperties = new MapLibreAdaptor(this.map, initial);
|
||||
const geolocationState = new GeoLocationState();
|
||||
|
||||
this.featureSwitchIsTesting = this.featureSwitches.featureSwitchIsTesting
|
||||
this.featureSwitchUserbadge = this.featureSwitches.featureSwitchEnableLogin
|
||||
this.featureSwitchIsTesting = this.featureSwitches.featureSwitchIsTesting;
|
||||
this.featureSwitchUserbadge = this.featureSwitches.featureSwitchEnableLogin;
|
||||
|
||||
this.osmConnection = new OsmConnection({
|
||||
dryRun: this.featureSwitches.featureSwitchIsTesting,
|
||||
|
@ -135,65 +137,68 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
undefined,
|
||||
"Used to complete the login"
|
||||
),
|
||||
osmConfiguration: <"osm" | "osm-test">this.featureSwitches.featureSwitchApiURL.data,
|
||||
})
|
||||
osmConfiguration: <"osm" | "osm-test">this.featureSwitches.featureSwitchApiURL.data
|
||||
});
|
||||
this.userRelatedState = new UserRelatedState(
|
||||
this.osmConnection,
|
||||
layout?.language,
|
||||
layout,
|
||||
this.featureSwitches
|
||||
)
|
||||
this.featureSwitches,
|
||||
this.mapProperties
|
||||
);
|
||||
this.userRelatedState.fixateNorth.addCallbackAndRunD((fixated) => {
|
||||
this.mapProperties.allowRotating.setData(fixated !== "yes")
|
||||
})
|
||||
this.selectedElement = new UIEventSource<Feature | undefined>(undefined, "Selected element")
|
||||
this.selectedLayer = new UIEventSource<LayerConfig>(undefined, "Selected layer")
|
||||
this.mapProperties.allowRotating.setData(fixated !== "yes");
|
||||
});
|
||||
this.selectedElement = new UIEventSource<Feature | undefined>(undefined, "Selected element");
|
||||
this.selectedLayer = new UIEventSource<LayerConfig>(undefined, "Selected layer");
|
||||
|
||||
this.selectedElementAndLayer = this.selectedElement.mapD(
|
||||
(feature) => {
|
||||
const layer = this.selectedLayer.data
|
||||
const layer = this.selectedLayer.data;
|
||||
if (!layer) {
|
||||
return undefined
|
||||
return undefined;
|
||||
}
|
||||
return { layer, feature }
|
||||
return { layer, feature };
|
||||
},
|
||||
[this.selectedLayer]
|
||||
)
|
||||
);
|
||||
|
||||
this.geolocation = new GeoLocationHandler(
|
||||
geolocationState,
|
||||
this.selectedElement,
|
||||
this.mapProperties,
|
||||
this.userRelatedState.gpsLocationHistoryRetentionTime
|
||||
)
|
||||
);
|
||||
|
||||
this.availableLayers = AvailableRasterLayers.layersAvailableAt(this.mapProperties.location)
|
||||
this.availableLayers = AvailableRasterLayers.layersAvailableAt(this.mapProperties.location);
|
||||
|
||||
const self = this
|
||||
this.layerState = new LayerState(this.osmConnection, layout.layers, layout.id)
|
||||
|
||||
const self = this;
|
||||
this.layerState = new LayerState(this.osmConnection, layout.layers, layout.id);
|
||||
|
||||
{
|
||||
const overlayLayerStates = new Map<string, { isDisplayed: UIEventSource<boolean> }>()
|
||||
const overlayLayerStates = new Map<string, { isDisplayed: UIEventSource<boolean> }>();
|
||||
for (const rasterInfo of this.layout.tileLayerSources) {
|
||||
const isDisplayed = QueryParameters.GetBooleanQueryParameter(
|
||||
"overlay-" + rasterInfo.id,
|
||||
rasterInfo.defaultState ?? true,
|
||||
"Wether or not overlayer layer " + rasterInfo.id + " is shown"
|
||||
)
|
||||
const state = { isDisplayed }
|
||||
overlayLayerStates.set(rasterInfo.id, state)
|
||||
new ShowOverlayRasterLayer(rasterInfo, this.map, this.mapProperties, state)
|
||||
);
|
||||
const state = { isDisplayed };
|
||||
overlayLayerStates.set(rasterInfo.id, state);
|
||||
new ShowOverlayRasterLayer(rasterInfo, this.map, this.mapProperties, state);
|
||||
}
|
||||
this.overlayLayerStates = overlayLayerStates
|
||||
this.overlayLayerStates = overlayLayerStates;
|
||||
}
|
||||
|
||||
|
||||
{
|
||||
/* Setup the layout source
|
||||
* A bit tricky, as this is heavily intertwined with the 'changes'-element, which generate a stream of new and changed features too
|
||||
*/
|
||||
|
||||
if (this.layout.layers.some((l) => l._needsFullNodeDatabase)) {
|
||||
this.fullNodeDatabase = new FullNodeDatabaseSource()
|
||||
this.fullNodeDatabase = new FullNodeDatabaseSource();
|
||||
}
|
||||
|
||||
const layoutSource = new LayoutSource(
|
||||
|
@ -203,49 +208,49 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
this.osmConnection.Backend(),
|
||||
(id) => self.layerState.filteredLayers.get(id).isDisplayed,
|
||||
this.fullNodeDatabase
|
||||
)
|
||||
);
|
||||
|
||||
this.indexedFeatures = layoutSource
|
||||
this.indexedFeatures = layoutSource;
|
||||
|
||||
const empty = []
|
||||
let currentViewIndex = 0
|
||||
const empty = [];
|
||||
let currentViewIndex = 0;
|
||||
this.currentView = new StaticFeatureSource(
|
||||
this.mapProperties.bounds.map((bbox) => {
|
||||
if (!bbox) {
|
||||
return empty
|
||||
return empty;
|
||||
}
|
||||
currentViewIndex++
|
||||
currentViewIndex++;
|
||||
return <Feature[]>[
|
||||
bbox.asGeoJson({
|
||||
zoom: this.mapProperties.zoom.data,
|
||||
...this.mapProperties.location.data,
|
||||
id: "current_view",
|
||||
}),
|
||||
]
|
||||
id: "current_view"
|
||||
})
|
||||
];
|
||||
})
|
||||
)
|
||||
this.featuresInView = new BBoxFeatureSource(layoutSource, this.mapProperties.bounds)
|
||||
this.dataIsLoading = layoutSource.isLoading
|
||||
);
|
||||
this.featuresInView = new BBoxFeatureSource(layoutSource, this.mapProperties.bounds);
|
||||
this.dataIsLoading = layoutSource.isLoading;
|
||||
|
||||
const indexedElements = this.indexedFeatures
|
||||
this.featureProperties = new FeaturePropertiesStore(indexedElements)
|
||||
const indexedElements = this.indexedFeatures;
|
||||
this.featureProperties = new FeaturePropertiesStore(indexedElements);
|
||||
this.changes = new Changes(
|
||||
{
|
||||
dryRun: this.featureSwitches.featureSwitchIsTesting,
|
||||
allElements: indexedElements,
|
||||
featurePropertiesStore: this.featureProperties,
|
||||
osmConnection: this.osmConnection,
|
||||
historicalUserLocations: this.geolocation.historicalUserLocations,
|
||||
historicalUserLocations: this.geolocation.historicalUserLocations
|
||||
},
|
||||
layout?.isLeftRightSensitive() ?? false
|
||||
)
|
||||
this.historicalUserLocations = this.geolocation.historicalUserLocations
|
||||
);
|
||||
this.historicalUserLocations = this.geolocation.historicalUserLocations;
|
||||
this.newFeatures = new NewGeometryFromChangesFeatureSource(
|
||||
this.changes,
|
||||
indexedElements,
|
||||
this.osmConnection.Backend()
|
||||
)
|
||||
layoutSource.addSource(this.newFeatures)
|
||||
this.featureProperties
|
||||
);
|
||||
layoutSource.addSource(this.newFeatures);
|
||||
|
||||
const perLayer = new PerLayerFeatureSourceSplitter(
|
||||
Array.from(this.layerState.filteredLayers.values()).filter(
|
||||
|
@ -261,11 +266,11 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
features.length,
|
||||
"leftover features, such as",
|
||||
features[0].properties
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
)
|
||||
this.perLayer = perLayer.perLayer
|
||||
);
|
||||
this.perLayer = perLayer.perLayer;
|
||||
}
|
||||
this.perLayer.forEach((fs) => {
|
||||
new SaveFeatureSourceToLocalStorage(
|
||||
|
@ -275,73 +280,74 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
fs,
|
||||
this.featureProperties,
|
||||
fs.layer.layerDef.maxAgeOfCache
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
this.floors = this.featuresInView.features.stabilized(500).map((features) => {
|
||||
if (!features) {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
const floors = new Set<string>()
|
||||
const floors = new Set<string>();
|
||||
for (const feature of features) {
|
||||
const level = feature.properties["level"]
|
||||
const level = feature.properties["level"];
|
||||
if (level) {
|
||||
const levels = level.split(";")
|
||||
const levels = level.split(";");
|
||||
for (const l of levels) {
|
||||
floors.add(l)
|
||||
floors.add(l);
|
||||
}
|
||||
} else {
|
||||
floors.add("0") // '0' is the default and is thus _always_ present
|
||||
floors.add("0"); // '0' is the default and is thus _always_ present
|
||||
}
|
||||
}
|
||||
const sorted = Array.from(floors)
|
||||
const sorted = Array.from(floors);
|
||||
// Sort alphabetically first, to deal with floor "A", "B" and "C"
|
||||
sorted.sort()
|
||||
sorted.sort();
|
||||
sorted.sort((a, b) => {
|
||||
// We use the laxer 'parseInt' to deal with floor '1A'
|
||||
const na = parseInt(a)
|
||||
const nb = parseInt(b)
|
||||
const na = parseInt(a);
|
||||
const nb = parseInt(b);
|
||||
if (isNaN(na) || isNaN(nb)) {
|
||||
return 0
|
||||
return 0;
|
||||
}
|
||||
return na - nb
|
||||
})
|
||||
sorted.reverse(/* new list, no side-effects */)
|
||||
return sorted
|
||||
})
|
||||
return na - nb;
|
||||
});
|
||||
sorted.reverse(/* new list, no side-effects */);
|
||||
return sorted;
|
||||
});
|
||||
|
||||
const lastClick = (this.lastClickObject = new LastClickFeatureSource(
|
||||
this.mapProperties.lastClickLocation,
|
||||
this.layout
|
||||
))
|
||||
));
|
||||
|
||||
this.osmObjectDownloader = new OsmObjectDownloader(
|
||||
this.osmConnection.Backend(),
|
||||
this.changes
|
||||
)
|
||||
);
|
||||
|
||||
this.perLayerFiltered = this.showNormalDataOn(this.map)
|
||||
this.perLayerFiltered = this.showNormalDataOn(this.map);
|
||||
|
||||
this.hasDataInView = new NoElementsInViewDetector(this).hasFeatureInView
|
||||
this.hasDataInView = new NoElementsInViewDetector(this).hasFeatureInView;
|
||||
this.imageUploadManager = new ImageUploadManager(layout, Imgur.singleton, this.featureProperties, this.osmConnection, this.changes)
|
||||
|
||||
this.initActors()
|
||||
this.addLastClick(lastClick)
|
||||
this.drawSpecialLayers()
|
||||
this.initHotkeys()
|
||||
this.miscSetup()
|
||||
this.initActors();
|
||||
this.addLastClick(lastClick);
|
||||
this.drawSpecialLayers();
|
||||
this.initHotkeys();
|
||||
this.miscSetup();
|
||||
if (!Utils.runningFromConsole) {
|
||||
console.log("State setup completed", this)
|
||||
console.log("State setup completed", this);
|
||||
}
|
||||
}
|
||||
|
||||
public showNormalDataOn(map: Store<MlMap>): ReadonlyMap<string, FilteringFeatureSource> {
|
||||
const filteringFeatureSource = new Map<string, FilteringFeatureSource>()
|
||||
const filteringFeatureSource = new Map<string, FilteringFeatureSource>();
|
||||
this.perLayer.forEach((fs, layerName) => {
|
||||
const doShowLayer = this.mapProperties.zoom.map(
|
||||
(z) =>
|
||||
(fs.layer.isDisplayed?.data ?? true) && z >= (fs.layer.layerDef?.minzoom ?? 0),
|
||||
[fs.layer.isDisplayed]
|
||||
)
|
||||
);
|
||||
|
||||
if (!doShowLayer.data && this.featureSwitches.featureSwitchFilter.data === false) {
|
||||
/* This layer is hidden and there is no way to enable it (filterview is disabled or this layer doesn't show up in the filter view as the name is not defined)
|
||||
|
@ -351,15 +357,15 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
* Note: it is tempting to also permanently disable the layer if it is not visible _and_ the layer name is hidden.
|
||||
* However, this is _not_ correct: the layer might be hidden because zoom is not enough. Zooming in more _will_ reveal the layer!
|
||||
* */
|
||||
return
|
||||
return;
|
||||
}
|
||||
const filtered = new FilteringFeatureSource(
|
||||
fs.layer,
|
||||
fs,
|
||||
(id) => this.featureProperties.getStore(id),
|
||||
this.layerState.globalFilters
|
||||
)
|
||||
filteringFeatureSource.set(layerName, filtered)
|
||||
);
|
||||
filteringFeatureSource.set(layerName, filtered);
|
||||
|
||||
new ShowDataLayer(map, {
|
||||
layer: fs.layer.layerDef,
|
||||
|
@ -367,30 +373,30 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
doShowLayer,
|
||||
selectedElement: this.selectedElement,
|
||||
selectedLayer: this.selectedLayer,
|
||||
fetchStore: (id) => this.featureProperties.getStore(id),
|
||||
})
|
||||
})
|
||||
return filteringFeatureSource
|
||||
fetchStore: (id) => this.featureProperties.getStore(id)
|
||||
});
|
||||
});
|
||||
return filteringFeatureSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Various small methods that need to be called
|
||||
*/
|
||||
private miscSetup() {
|
||||
this.userRelatedState.markLayoutAsVisited(this.layout)
|
||||
this.userRelatedState.markLayoutAsVisited(this.layout);
|
||||
|
||||
this.selectedElement.addCallbackAndRunD((feature) => {
|
||||
// As soon as we have a selected element, we clear the selected element
|
||||
// This is to work around maplibre, which'll _first_ register the click on the map and only _then_ on the feature
|
||||
// The only exception is if the last element is the 'add_new'-button, as we don't want it to disappear
|
||||
if (feature.properties.id === "last_click") {
|
||||
return
|
||||
return;
|
||||
}
|
||||
this.lastClickObject.features.setData([])
|
||||
})
|
||||
this.lastClickObject.features.setData([]);
|
||||
});
|
||||
|
||||
if (this.layout.customCss !== undefined && window.location.pathname.indexOf("theme") >= 0) {
|
||||
Utils.LoadCustomCss(this.layout.customCss)
|
||||
Utils.LoadCustomCss(this.layout.customCss);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -399,74 +405,74 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
{ nomod: "Escape", onUp: true },
|
||||
Translations.t.hotkeyDocumentation.closeSidebar,
|
||||
() => {
|
||||
this.selectedElement.setData(undefined)
|
||||
this.guistate.closeAll()
|
||||
this.selectedElement.setData(undefined);
|
||||
this.guistate.closeAll();
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
Hotkeys.RegisterHotkey(
|
||||
{
|
||||
nomod: "b",
|
||||
nomod: "b"
|
||||
},
|
||||
Translations.t.hotkeyDocumentation.openLayersPanel,
|
||||
() => {
|
||||
if (this.featureSwitches.featureSwitchFilter.data) {
|
||||
this.guistate.openFilterView()
|
||||
this.guistate.openFilterView();
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
Hotkeys.RegisterHotkey(
|
||||
{ shift: "O" },
|
||||
Translations.t.hotkeyDocumentation.selectMapnik,
|
||||
() => {
|
||||
this.mapProperties.rasterLayer.setData(AvailableRasterLayers.osmCarto)
|
||||
this.mapProperties.rasterLayer.setData(AvailableRasterLayers.osmCarto);
|
||||
}
|
||||
)
|
||||
);
|
||||
const setLayerCategory = (category: EliCategory) => {
|
||||
const available = this.availableLayers.data
|
||||
const current = this.mapProperties.rasterLayer
|
||||
const available = this.availableLayers.data;
|
||||
const current = this.mapProperties.rasterLayer;
|
||||
const best = RasterLayerUtils.SelectBestLayerAccordingTo(
|
||||
available,
|
||||
category,
|
||||
current.data
|
||||
)
|
||||
console.log("Best layer for category", category, "is", best.properties.id)
|
||||
current.setData(best)
|
||||
}
|
||||
);
|
||||
console.log("Best layer for category", category, "is", best.properties.id);
|
||||
current.setData(best);
|
||||
};
|
||||
|
||||
Hotkeys.RegisterHotkey(
|
||||
{ nomod: "O" },
|
||||
Translations.t.hotkeyDocumentation.selectOsmbasedmap,
|
||||
() => setLayerCategory("osmbasedmap")
|
||||
)
|
||||
);
|
||||
|
||||
Hotkeys.RegisterHotkey({ nomod: "M" }, Translations.t.hotkeyDocumentation.selectMap, () =>
|
||||
setLayerCategory("map")
|
||||
)
|
||||
);
|
||||
|
||||
Hotkeys.RegisterHotkey(
|
||||
{ nomod: "P" },
|
||||
Translations.t.hotkeyDocumentation.selectAerial,
|
||||
() => setLayerCategory("photo")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private addLastClick(last_click: LastClickFeatureSource) {
|
||||
// The last_click gets a _very_ special treatment as it interacts with various parts
|
||||
|
||||
const last_click_layer = this.layerState.filteredLayers.get("last_click")
|
||||
this.featureProperties.trackFeatureSource(last_click)
|
||||
this.indexedFeatures.addSource(last_click)
|
||||
const last_click_layer = this.layerState.filteredLayers.get("last_click");
|
||||
this.featureProperties.trackFeatureSource(last_click);
|
||||
this.indexedFeatures.addSource(last_click);
|
||||
|
||||
last_click.features.addCallbackAndRunD((features) => {
|
||||
if (this.selectedLayer.data?.id === "last_click") {
|
||||
// The last-click location moved, but we have selected the last click of the previous location
|
||||
// So, we update _after_ clearing the selection to make sure no stray data is sticking around
|
||||
this.selectedElement.setData(undefined)
|
||||
this.selectedElement.setData(features[0])
|
||||
this.selectedElement.setData(undefined);
|
||||
this.selectedElement.setData(features[0]);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
new ShowDataLayer(this.map, {
|
||||
features: new FilteringFeatureSource(last_click_layer, last_click),
|
||||
|
@ -478,18 +484,18 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
if (this.mapProperties.zoom.data < Constants.minZoomLevelToAddNewPoint) {
|
||||
this.map.data.flyTo({
|
||||
zoom: Constants.minZoomLevelToAddNewPoint,
|
||||
center: this.mapProperties.lastClickLocation.data,
|
||||
})
|
||||
return
|
||||
center: this.mapProperties.lastClickLocation.data
|
||||
});
|
||||
return;
|
||||
}
|
||||
// We first clear the selection to make sure no weird state is around
|
||||
this.selectedLayer.setData(undefined)
|
||||
this.selectedElement.setData(undefined)
|
||||
this.selectedLayer.setData(undefined);
|
||||
this.selectedElement.setData(undefined);
|
||||
|
||||
this.selectedElement.setData(feature)
|
||||
this.selectedLayer.setData(last_click_layer.layerDef)
|
||||
},
|
||||
})
|
||||
this.selectedElement.setData(feature);
|
||||
this.selectedLayer.setData(last_click_layer.layerDef);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -497,7 +503,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
*/
|
||||
private drawSpecialLayers() {
|
||||
type AddedByDefaultTypes = (typeof Constants.added_by_default)[number]
|
||||
const empty = []
|
||||
const empty = [];
|
||||
/**
|
||||
* A listing which maps the layerId onto the featureSource
|
||||
*/
|
||||
|
@ -517,21 +523,21 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
bbox === undefined ? empty : <Feature[]>[bbox.asGeoJson({ id: "range" })]
|
||||
)
|
||||
),
|
||||
current_view: this.currentView,
|
||||
}
|
||||
current_view: this.currentView
|
||||
};
|
||||
if (this.layout?.lockLocation) {
|
||||
const bbox = new BBox(this.layout.lockLocation)
|
||||
this.mapProperties.maxbounds.setData(bbox)
|
||||
const bbox = new BBox(this.layout.lockLocation);
|
||||
this.mapProperties.maxbounds.setData(bbox);
|
||||
ShowDataLayer.showRange(
|
||||
this.map,
|
||||
new StaticFeatureSource([bbox.asGeoJson({})]),
|
||||
this.featureSwitches.featureSwitchIsTesting
|
||||
)
|
||||
);
|
||||
}
|
||||
const currentViewLayer = this.layout.layers.find((l) => l.id === "current_view")
|
||||
const currentViewLayer = this.layout.layers.find((l) => l.id === "current_view");
|
||||
if (currentViewLayer?.tagRenderings?.length > 0) {
|
||||
const params = MetaTagging.createExtraFuncParams(this)
|
||||
this.featureProperties.trackFeatureSource(specialLayers.current_view)
|
||||
const params = MetaTagging.createExtraFuncParams(this);
|
||||
this.featureProperties.trackFeatureSource(specialLayers.current_view);
|
||||
specialLayers.current_view.features.addCallbackAndRunD((features) => {
|
||||
MetaTagging.addMetatags(
|
||||
features,
|
||||
|
@ -540,37 +546,37 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
this.layout,
|
||||
this.osmObjectDownloader,
|
||||
this.featureProperties
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const rangeFLayer: FilteredLayer = this.layerState.filteredLayers.get("range")
|
||||
const rangeFLayer: FilteredLayer = this.layerState.filteredLayers.get("range");
|
||||
|
||||
const rangeIsDisplayed = rangeFLayer?.isDisplayed
|
||||
const rangeIsDisplayed = rangeFLayer?.isDisplayed;
|
||||
|
||||
if (
|
||||
!QueryParameters.wasInitialized(FilteredLayer.queryParameterKey(rangeFLayer.layerDef))
|
||||
) {
|
||||
rangeIsDisplayed?.syncWith(this.featureSwitches.featureSwitchIsTesting, true)
|
||||
rangeIsDisplayed?.syncWith(this.featureSwitches.featureSwitchIsTesting, true);
|
||||
}
|
||||
|
||||
this.layerState.filteredLayers.forEach((flayer) => {
|
||||
const id = flayer.layerDef.id
|
||||
const features: FeatureSource = specialLayers[id]
|
||||
const id = flayer.layerDef.id;
|
||||
const features: FeatureSource = specialLayers[id];
|
||||
if (features === undefined) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
this.featureProperties.trackFeatureSource(features)
|
||||
this.featureProperties.trackFeatureSource(features);
|
||||
// this.indexedFeatures.addSource(features)
|
||||
new ShowDataLayer(this.map, {
|
||||
features,
|
||||
doShowLayer: flayer.isDisplayed,
|
||||
layer: flayer.layerDef,
|
||||
selectedElement: this.selectedElement,
|
||||
selectedLayer: this.selectedLayer,
|
||||
})
|
||||
})
|
||||
selectedLayer: this.selectedLayer
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -579,29 +585,30 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
private initActors() {
|
||||
// Unselect the selected element if it is panned out of view
|
||||
this.mapProperties.bounds.stabilized(250).addCallbackD((bounds) => {
|
||||
const selected = this.selectedElement.data
|
||||
const selected = this.selectedElement.data;
|
||||
if (selected === undefined) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const bbox = BBox.get(selected)
|
||||
const bbox = BBox.get(selected);
|
||||
if (!bbox.overlapsWith(bounds)) {
|
||||
this.selectedElement.setData(undefined)
|
||||
this.selectedElement.setData(undefined);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
this.selectedElement.addCallback((selected) => {
|
||||
if (selected === undefined) {
|
||||
// We did _unselect_ an item - we always remove the lastclick-object
|
||||
this.lastClickObject.features.setData([])
|
||||
this.selectedLayer.setData(undefined)
|
||||
this.lastClickObject.features.setData([]);
|
||||
this.selectedLayer.setData(undefined);
|
||||
}
|
||||
})
|
||||
new ThemeViewStateHashActor(this)
|
||||
new MetaTagging(this)
|
||||
new TitleHandler(this.selectedElement, this.selectedLayer, this.featureProperties, this)
|
||||
new ChangeToElementsActor(this.changes, this.featureProperties)
|
||||
new PendingChangesUploader(this.changes, this.selectedElement)
|
||||
new SelectedElementTagsUpdater(this)
|
||||
new BackgroundLayerResetter(this.mapProperties.rasterLayer, this.availableLayers)
|
||||
});
|
||||
new ThemeViewStateHashActor(this);
|
||||
new MetaTagging(this);
|
||||
new TitleHandler(this.selectedElement, this.selectedLayer, this.featureProperties, this);
|
||||
new ChangeToElementsActor(this.changes, this.featureProperties);
|
||||
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
|
||||
}
|
||||
}
|
40
src/UI/Base/FileSelector.svelte
Normal file
40
src/UI/Base/FileSelector.svelte
Normal file
|
@ -0,0 +1,40 @@
|
|||
<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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -53,17 +53,17 @@ 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.download = this._download
|
||||
el.setAttribute("download", this._download)
|
||||
}
|
||||
el.appendChild(embeddedShow)
|
||||
return el
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
<script>
|
||||
import ToSvelte from "./ToSvelte.svelte"
|
||||
import Svg from "../../Svg"
|
||||
<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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,40 +1,44 @@
|
|||
<script lang="ts">
|
||||
import Translations from "../i18n/Translations"
|
||||
import Svg from "../../Svg"
|
||||
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 { SearchIcon } from "@rgossiaux/svelte-heroicons/solid"
|
||||
import { twJoin } from "tailwind-merge"
|
||||
import { Utils } from "../../Utils"
|
||||
import Translations from "../i18n/Translations";
|
||||
import Svg from "../../Svg";
|
||||
import Tr from "../Base/Tr.svelte";
|
||||
import NextButton from "../Base/NextButton.svelte";
|
||||
import Geosearch from "./Geosearch.svelte";
|
||||
import ToSvelte from "../Base/ToSvelte.svelte";
|
||||
import ThemeViewState from "../../Models/ThemeViewState";
|
||||
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
|
||||
*/
|
||||
export let state: ThemeViewState
|
||||
let layout = state.layout
|
||||
let selectedElement = state.selectedElement
|
||||
let selectedLayer = state.selectedLayer
|
||||
export let state: ThemeViewState;
|
||||
let layout = state.layout;
|
||||
let selectedElement = state.selectedElement;
|
||||
let selectedLayer = state.selectedLayer;
|
||||
|
||||
let triggerSearch: UIEventSource<any> = new UIEventSource<any>(undefined)
|
||||
let searchEnabled = false
|
||||
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
|
||||
const glstate = state.geolocation.geolocationState;
|
||||
if (glstate.currentGPSLocation.data !== undefined) {
|
||||
const c: GeolocationCoordinates = glstate.currentGPSLocation.data
|
||||
state.guistate.themeIsOpened.setData(false)
|
||||
const coor = { lon: c.longitude, lat: c.latitude }
|
||||
state.mapProperties.location.setData(coor)
|
||||
const c: GeolocationCoordinates = glstate.currentGPSLocation.data;
|
||||
state.guistate.themeIsOpened.setData(false);
|
||||
const coor = { lon: c.longitude, lat: c.latitude };
|
||||
state.mapProperties.location.setData(coor);
|
||||
}
|
||||
if (glstate.permission.data !== "granted") {
|
||||
glstate.requestPermission()
|
||||
return
|
||||
glstate.requestPermission();
|
||||
return;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -58,12 +62,24 @@
|
|||
</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="flex w-full items-center gap-x-2 disabled" 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").SetClass("animate-spin")} />
|
||||
<Tr t={Translations.t.general.waitingForGeopermission} />
|
||||
</button>
|
||||
{:else if $geopermission !== "denied"}
|
||||
<button class="flex w-full items-center gap-x-2 disabled">
|
||||
<ToSvelte construct={Svg.crosshair_svg().SetClass("w-8 h-8").SetClass("motion-safe:animate-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,199 +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
|
||||
)
|
||||
}
|
||||
}
|
77
src/UI/Image/UploadImage.svelte
Normal file
77
src/UI/Image/UploadImage.svelte
Normal file
|
@ -0,0 +1,77 @@
|
|||
<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="flex flex-col alert">
|
||||
<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="flex flex-col alert">
|
||||
{#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 })
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
123
src/UI/PlantNet/PlantNet.svelte
Normal file
123
src/UI/PlantNet/PlantNet.svelte
Normal file
|
@ -0,0 +1,123 @@
|
|||
<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 p-2 mb-1 relative">
|
||||
<XCircleIcon class="absolute top-0 right-0 w-8 h-8 m-4 cursor-pointer"
|
||||
on:click={() => {collapsedMode = true}}></XCircleIcon>
|
||||
<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="w-8 h-8 m-4 cursor-pointer"
|
||||
on:click={() => {collapsedMode = true}}></XCircleIcon>
|
||||
|
||||
</PlantNetSpeciesList>
|
||||
{:else if !done}
|
||||
<div class="flex flex-col border-interactive">
|
||||
<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="flex p-2 low-interaction rounded-xl self-end">
|
||||
<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>
|
37
src/UI/PlantNet/PlantNetSpeciesList.svelte
Normal file
37
src/UI/PlantNet/PlantNetSpeciesList.svelte
Normal file
|
@ -0,0 +1,37 @@
|
|||
<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 flex p-2 flex-col relative">
|
||||
<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}
|
54
src/UI/PlantNet/SpeciesButton.svelte
Normal file
54
src/UI/PlantNet/SpeciesButton.svelte
Normal file
|
@ -0,0 +1,54 @@
|
|||
<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")}></ToSvelte>
|
||||
{/if}
|
||||
</NextButton>
|
|
@ -3,109 +3,109 @@
|
|||
* This component ties together all the steps that are needed to create a new point.
|
||||
* There are many subcomponents which help with that
|
||||
*/
|
||||
import type { SpecialVisualizationState } from "../../SpecialVisualization"
|
||||
import PresetList from "./PresetList.svelte"
|
||||
import type PresetConfig from "../../../Models/ThemeConfig/PresetConfig"
|
||||
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
|
||||
import Tr from "../../Base/Tr.svelte"
|
||||
import SubtleButton from "../../Base/SubtleButton.svelte"
|
||||
import FromHtml from "../../Base/FromHtml.svelte"
|
||||
import Translations from "../../i18n/Translations.js"
|
||||
import TagHint from "../TagHint.svelte"
|
||||
import { And } from "../../../Logic/Tags/And.js"
|
||||
import LoginToggle from "../../Base/LoginToggle.svelte"
|
||||
import Constants from "../../../Models/Constants.js"
|
||||
import FilteredLayer from "../../../Models/FilteredLayer"
|
||||
import { Store, UIEventSource } from "../../../Logic/UIEventSource"
|
||||
import { EyeIcon, EyeOffIcon } from "@rgossiaux/svelte-heroicons/solid"
|
||||
import LoginButton from "../../Base/LoginButton.svelte"
|
||||
import NewPointLocationInput from "../../BigComponents/NewPointLocationInput.svelte"
|
||||
import CreateNewNodeAction from "../../../Logic/Osm/Actions/CreateNewNodeAction"
|
||||
import { OsmWay } from "../../../Logic/Osm/OsmObject"
|
||||
import { Tag } from "../../../Logic/Tags/Tag"
|
||||
import type { WayId } from "../../../Models/OsmFeature"
|
||||
import Loading from "../../Base/Loading.svelte"
|
||||
import type { GlobalFilter } from "../../../Models/GlobalFilter"
|
||||
import { onDestroy } from "svelte"
|
||||
import NextButton from "../../Base/NextButton.svelte"
|
||||
import BackButton from "../../Base/BackButton.svelte"
|
||||
import ToSvelte from "../../Base/ToSvelte.svelte"
|
||||
import Svg from "../../../Svg"
|
||||
import OpenBackgroundSelectorButton from "../../BigComponents/OpenBackgroundSelectorButton.svelte"
|
||||
import { twJoin } from "tailwind-merge"
|
||||
import type { SpecialVisualizationState } from "../../SpecialVisualization";
|
||||
import PresetList from "./PresetList.svelte";
|
||||
import type PresetConfig from "../../../Models/ThemeConfig/PresetConfig";
|
||||
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig";
|
||||
import Tr from "../../Base/Tr.svelte";
|
||||
import SubtleButton from "../../Base/SubtleButton.svelte";
|
||||
import FromHtml from "../../Base/FromHtml.svelte";
|
||||
import Translations from "../../i18n/Translations.js";
|
||||
import TagHint from "../TagHint.svelte";
|
||||
import { And } from "../../../Logic/Tags/And.js";
|
||||
import LoginToggle from "../../Base/LoginToggle.svelte";
|
||||
import Constants from "../../../Models/Constants.js";
|
||||
import FilteredLayer from "../../../Models/FilteredLayer";
|
||||
import { Store, UIEventSource } from "../../../Logic/UIEventSource";
|
||||
import { EyeIcon, EyeOffIcon } from "@rgossiaux/svelte-heroicons/solid";
|
||||
import LoginButton from "../../Base/LoginButton.svelte";
|
||||
import NewPointLocationInput from "../../BigComponents/NewPointLocationInput.svelte";
|
||||
import CreateNewNodeAction from "../../../Logic/Osm/Actions/CreateNewNodeAction";
|
||||
import { OsmWay } from "../../../Logic/Osm/OsmObject";
|
||||
import { Tag } from "../../../Logic/Tags/Tag";
|
||||
import type { WayId } from "../../../Models/OsmFeature";
|
||||
import Loading from "../../Base/Loading.svelte";
|
||||
import type { GlobalFilter } from "../../../Models/GlobalFilter";
|
||||
import { onDestroy } from "svelte";
|
||||
import NextButton from "../../Base/NextButton.svelte";
|
||||
import BackButton from "../../Base/BackButton.svelte";
|
||||
import ToSvelte from "../../Base/ToSvelte.svelte";
|
||||
import Svg from "../../../Svg";
|
||||
import OpenBackgroundSelectorButton from "../../BigComponents/OpenBackgroundSelectorButton.svelte";
|
||||
import { twJoin } from "tailwind-merge";
|
||||
|
||||
export let coordinate: { lon: number; lat: number }
|
||||
export let state: SpecialVisualizationState
|
||||
export let coordinate: { lon: number; lat: number };
|
||||
export let state: SpecialVisualizationState;
|
||||
|
||||
let selectedPreset: {
|
||||
preset: PresetConfig
|
||||
layer: LayerConfig
|
||||
icon: string
|
||||
tags: Record<string, string>
|
||||
} = undefined
|
||||
let checkedOfGlobalFilters: number = 0
|
||||
let confirmedCategory = false
|
||||
} = undefined;
|
||||
let checkedOfGlobalFilters: number = 0;
|
||||
let confirmedCategory = false;
|
||||
$: if (selectedPreset === undefined) {
|
||||
confirmedCategory = false
|
||||
creating = false
|
||||
checkedOfGlobalFilters = 0
|
||||
confirmedCategory = false;
|
||||
creating = false;
|
||||
checkedOfGlobalFilters = 0;
|
||||
}
|
||||
|
||||
let flayer: FilteredLayer = undefined
|
||||
let layerIsDisplayed: UIEventSource<boolean> | undefined = undefined
|
||||
let layerHasFilters: Store<boolean> | undefined = undefined
|
||||
let globalFilter: UIEventSource<GlobalFilter[]> = state.layerState.globalFilters
|
||||
let _globalFilter: GlobalFilter[] = []
|
||||
let flayer: FilteredLayer = undefined;
|
||||
let layerIsDisplayed: UIEventSource<boolean> | undefined = undefined;
|
||||
let layerHasFilters: Store<boolean> | undefined = undefined;
|
||||
let globalFilter: UIEventSource<GlobalFilter[]> = state.layerState.globalFilters;
|
||||
let _globalFilter: GlobalFilter[] = [];
|
||||
onDestroy(
|
||||
globalFilter.addCallbackAndRun((globalFilter) => {
|
||||
console.log("Global filters are", globalFilter)
|
||||
_globalFilter = globalFilter ?? []
|
||||
console.log("Global filters are", globalFilter);
|
||||
_globalFilter = globalFilter ?? [];
|
||||
})
|
||||
)
|
||||
);
|
||||
$: {
|
||||
flayer = state.layerState.filteredLayers.get(selectedPreset?.layer?.id)
|
||||
layerIsDisplayed = flayer?.isDisplayed
|
||||
layerHasFilters = flayer?.hasFilter
|
||||
flayer = state.layerState.filteredLayers.get(selectedPreset?.layer?.id);
|
||||
layerIsDisplayed = flayer?.isDisplayed;
|
||||
layerHasFilters = flayer?.hasFilter;
|
||||
}
|
||||
const t = Translations.t.general.add
|
||||
const t = Translations.t.general.add;
|
||||
|
||||
const zoom = state.mapProperties.zoom
|
||||
const zoom = state.mapProperties.zoom;
|
||||
|
||||
const isLoading = state.dataIsLoading
|
||||
let preciseCoordinate: UIEventSource<{ lon: number; lat: number }> = new UIEventSource(undefined)
|
||||
let snappedToObject: UIEventSource<string> = new UIEventSource<string>(undefined)
|
||||
const isLoading = state.dataIsLoading;
|
||||
let preciseCoordinate: UIEventSource<{ lon: number; lat: number }> = new UIEventSource(undefined);
|
||||
let snappedToObject: UIEventSource<string> = new UIEventSource<string>(undefined);
|
||||
|
||||
// Small helper variable: if the map is tapped, we should let the 'Next'-button grab some attention as users have to click _that_ to continue, not the map
|
||||
let preciseInputIsTapped = false
|
||||
let preciseInputIsTapped = false;
|
||||
|
||||
let creating = false
|
||||
let creating = false;
|
||||
|
||||
/**
|
||||
* Call when the user should restart the flow by clicking on the map, e.g. because they disabled filters.
|
||||
* Will delete the lastclick-location
|
||||
*/
|
||||
function abort() {
|
||||
state.selectedElement.setData(undefined)
|
||||
state.selectedElement.setData(undefined);
|
||||
// When aborted, we force the contributors to place the pin _again_
|
||||
// This is because there might be a nearby object that was disabled; this forces them to re-evaluate the map
|
||||
state.lastClickObject.features.setData([])
|
||||
preciseInputIsTapped = false
|
||||
state.lastClickObject.features.setData([]);
|
||||
preciseInputIsTapped = false;
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
creating = true
|
||||
const location: { lon: number; lat: number } = preciseCoordinate.data
|
||||
const snapTo: WayId | undefined = <WayId>snappedToObject.data
|
||||
creating = true;
|
||||
const location: { lon: number; lat: number } = preciseCoordinate.data;
|
||||
const snapTo: WayId | undefined = <WayId>snappedToObject.data;
|
||||
const tags: Tag[] = selectedPreset.preset.tags.concat(
|
||||
..._globalFilter.map((f) => f?.onNewPoint?.tags ?? [])
|
||||
)
|
||||
console.log("Creating new point at", location, "snapped to", snapTo, "with tags", tags)
|
||||
);
|
||||
console.log("Creating new point at", location, "snapped to", snapTo, "with tags", tags);
|
||||
|
||||
let snapToWay: undefined | OsmWay = undefined
|
||||
let snapToWay: undefined | OsmWay = undefined;
|
||||
if (snapTo !== undefined) {
|
||||
const downloaded = await state.osmObjectDownloader.DownloadObjectAsync(snapTo, 0)
|
||||
const downloaded = await state.osmObjectDownloader.DownloadObjectAsync(snapTo, 0);
|
||||
if (downloaded !== "deleted") {
|
||||
snapToWay = downloaded
|
||||
snapToWay = downloaded;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -113,33 +113,42 @@
|
|||
theme: state.layout?.id ?? "unkown",
|
||||
changeType: "create",
|
||||
snapOnto: snapToWay,
|
||||
})
|
||||
await state.changes.applyAction(newElementAction)
|
||||
state.newFeatures.features.ping()
|
||||
reusePointWithinMeters: 1
|
||||
});
|
||||
await state.changes.applyAction(newElementAction);
|
||||
state.newFeatures.features.ping();
|
||||
// The 'changes' should have created a new point, which added this into the 'featureProperties'
|
||||
const newId = newElementAction.newElementId
|
||||
console.log("Applied pending changes, fetching store for", newId)
|
||||
const tagsStore = state.featureProperties.getStore(newId)
|
||||
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
|
||||
const properties = tagsStore.data;
|
||||
if (snapTo) {
|
||||
// metatags (starting with underscore) are not uploaded, so we can safely mark this
|
||||
delete properties["_referencing_ways"]
|
||||
properties["_referencing_ways"] = `["${snapTo}"]`
|
||||
delete properties["_referencing_ways"];
|
||||
properties["_referencing_ways"] = `["${snapTo}"]`;
|
||||
}
|
||||
properties["_backend"] = state.osmConnection.Backend()
|
||||
properties["_last_edit:timestamp"] = new Date().toISOString()
|
||||
const userdetails = state.osmConnection.userDetails.data
|
||||
properties["_last_edit:contributor"] = userdetails.name
|
||||
properties["_last_edit:uid"] = "" + userdetails.uid
|
||||
tagsStore.ping()
|
||||
properties["_backend"] = state.osmConnection.Backend();
|
||||
properties["_last_edit:timestamp"] = new Date().toISOString();
|
||||
const userdetails = state.osmConnection.userDetails.data;
|
||||
properties["_last_edit:contributor"] = userdetails.name;
|
||||
properties["_last_edit:uid"] = "" + userdetails.uid;
|
||||
tagsStore.ping();
|
||||
}
|
||||
const feature = state.indexedFeatures.featuresById.data.get(newId)
|
||||
abort()
|
||||
state.selectedLayer.setData(selectedPreset.layer)
|
||||
state.selectedElement.setData(feature)
|
||||
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>
|
||||
|
||||
|
@ -328,7 +337,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(
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
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 LinkImageAction from "../../Logic/Osm/Actions/LinkImageAction"
|
||||
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"
|
||||
import { Tag } from "../../Logic/Tags/Tag"
|
||||
import { GeoOperations } from "../../Logic/GeoOperations"
|
||||
|
@ -23,25 +23,24 @@
|
|||
export let feature: Feature
|
||||
export let layer: LayerConfig
|
||||
|
||||
export let linkable = true
|
||||
let isLinked = false
|
||||
export let linkable = true;
|
||||
let isLinked = Object.values(tags.data).some(v => image.pictureUrl === v);
|
||||
|
||||
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]
|
||||
if (isLinked) {
|
||||
const action = new LinkPicture(currentTags.id, key, url, currentTags, {
|
||||
const action = new LinkImageAction(currentTags.id, key, url, currentTags, {
|
||||
theme: state.layout.id,
|
||||
changeType: "link-image",
|
||||
})
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -88,7 +88,6 @@
|
|||
checkedMappings.push(unseenFreeformValues.length > 0)
|
||||
}
|
||||
}
|
||||
console.log("Inited 'checkMappings' to", checkedMappings)
|
||||
if (confg.freeform?.key) {
|
||||
if (!confg.multiAnswer) {
|
||||
// Somehow, setting multi-answer freeform values is broken if this is not set
|
||||
|
|
|
@ -1,113 +1,118 @@
|
|||
import { Store, UIEventSource } from "../Logic/UIEventSource"
|
||||
import BaseUIElement from "./BaseUIElement"
|
||||
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"
|
||||
import { IndexedFeatureSource, WritableFeatureSource } from "../Logic/FeatureSource/FeatureSource"
|
||||
import { OsmConnection } from "../Logic/Osm/OsmConnection"
|
||||
import { Changes } from "../Logic/Osm/Changes"
|
||||
import { ExportableMap, MapProperties } from "../Models/MapProperties"
|
||||
import LayerState from "../Logic/State/LayerState"
|
||||
import { Feature, Geometry, Point } from "geojson"
|
||||
import FullNodeDatabaseSource from "../Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource"
|
||||
import { MangroveIdentity } from "../Logic/Web/MangroveReviews"
|
||||
import { GeoIndexedStoreForLayer } from "../Logic/FeatureSource/Actors/GeoIndexedStore"
|
||||
import LayerConfig from "../Models/ThemeConfig/LayerConfig"
|
||||
import FeatureSwitchState from "../Logic/State/FeatureSwitchState"
|
||||
import { MenuState } from "../Models/MenuState"
|
||||
import OsmObjectDownloader from "../Logic/Osm/OsmObjectDownloader"
|
||||
import { RasterLayerPolygon } from "../Models/RasterLayers"
|
||||
import { Store, UIEventSource } from "../Logic/UIEventSource";
|
||||
import BaseUIElement from "./BaseUIElement";
|
||||
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig";
|
||||
import { IndexedFeatureSource, WritableFeatureSource } from "../Logic/FeatureSource/FeatureSource";
|
||||
import { OsmConnection } from "../Logic/Osm/OsmConnection";
|
||||
import { Changes } from "../Logic/Osm/Changes";
|
||||
import { ExportableMap, MapProperties } from "../Models/MapProperties";
|
||||
import LayerState from "../Logic/State/LayerState";
|
||||
import { Feature, Geometry, Point } from "geojson";
|
||||
import FullNodeDatabaseSource from "../Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource";
|
||||
import { MangroveIdentity } from "../Logic/Web/MangroveReviews";
|
||||
import { GeoIndexedStoreForLayer } from "../Logic/FeatureSource/Actors/GeoIndexedStore";
|
||||
import LayerConfig from "../Models/ThemeConfig/LayerConfig";
|
||||
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.
|
||||
*/
|
||||
export interface SpecialVisualizationState {
|
||||
readonly guistate: MenuState
|
||||
readonly layout: LayoutConfig
|
||||
readonly featureSwitches: FeatureSwitchState
|
||||
readonly guistate: MenuState;
|
||||
readonly layout: LayoutConfig;
|
||||
readonly featureSwitches: FeatureSwitchState;
|
||||
|
||||
readonly layerState: LayerState
|
||||
readonly featureProperties: { getStore(id: string): UIEventSource<Record<string, string>> }
|
||||
readonly layerState: LayerState;
|
||||
readonly featureProperties: { getStore(id: string): UIEventSource<Record<string, string>>, trackFeature?(feature: { properties: OsmTags }) };
|
||||
|
||||
readonly indexedFeatures: IndexedFeatureSource
|
||||
readonly indexedFeatures: IndexedFeatureSource;
|
||||
|
||||
/**
|
||||
* Some features will create a new element that should be displayed.
|
||||
* These can be injected by appending them to this featuresource (and pinging it)
|
||||
*/
|
||||
readonly newFeatures: WritableFeatureSource
|
||||
/**
|
||||
* Some features will create a new element that should be displayed.
|
||||
* These can be injected by appending them to this featuresource (and pinging it)
|
||||
*/
|
||||
readonly newFeatures: WritableFeatureSource;
|
||||
|
||||
readonly historicalUserLocations: WritableFeatureSource<Feature<Point>>
|
||||
readonly historicalUserLocations: WritableFeatureSource<Feature<Point>>;
|
||||
|
||||
readonly osmConnection: OsmConnection
|
||||
readonly featureSwitchUserbadge: Store<boolean>
|
||||
readonly featureSwitchIsTesting: Store<boolean>
|
||||
readonly changes: Changes
|
||||
readonly osmObjectDownloader: OsmObjectDownloader
|
||||
/**
|
||||
* State of the main map
|
||||
*/
|
||||
readonly mapProperties: MapProperties & ExportableMap
|
||||
readonly osmConnection: OsmConnection;
|
||||
readonly featureSwitchUserbadge: Store<boolean>;
|
||||
readonly featureSwitchIsTesting: Store<boolean>;
|
||||
readonly changes: Changes;
|
||||
readonly osmObjectDownloader: OsmObjectDownloader;
|
||||
/**
|
||||
* State of the main map
|
||||
*/
|
||||
readonly mapProperties: MapProperties & ExportableMap;
|
||||
|
||||
readonly selectedElement: UIEventSource<Feature>
|
||||
/**
|
||||
* Works together with 'selectedElement' to indicate what properties should be displayed
|
||||
*/
|
||||
readonly selectedLayer: UIEventSource<LayerConfig>
|
||||
readonly selectedElementAndLayer: Store<{ feature: Feature; layer: LayerConfig }>
|
||||
readonly selectedElement: UIEventSource<Feature>;
|
||||
/**
|
||||
* Works together with 'selectedElement' to indicate what properties should be displayed
|
||||
*/
|
||||
readonly selectedLayer: UIEventSource<LayerConfig>;
|
||||
readonly selectedElementAndLayer: Store<{ feature: Feature; layer: LayerConfig }>;
|
||||
|
||||
/**
|
||||
* If data is currently being fetched from external sources
|
||||
*/
|
||||
readonly dataIsLoading: Store<boolean>
|
||||
/**
|
||||
* Only needed for 'ReplaceGeometryAction'
|
||||
*/
|
||||
readonly fullNodeDatabase?: FullNodeDatabaseSource
|
||||
/**
|
||||
* If data is currently being fetched from external sources
|
||||
*/
|
||||
readonly dataIsLoading: Store<boolean>;
|
||||
/**
|
||||
* Only needed for 'ReplaceGeometryAction'
|
||||
*/
|
||||
readonly fullNodeDatabase?: FullNodeDatabaseSource;
|
||||
|
||||
readonly perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer>
|
||||
readonly userRelatedState: {
|
||||
readonly showTags: UIEventSource<"no" | undefined | "always" | "yes" | "full">
|
||||
readonly mangroveIdentity: MangroveIdentity
|
||||
readonly showAllQuestionsAtOnce: UIEventSource<boolean>
|
||||
readonly preferencesAsTags: Store<Record<string, string>>
|
||||
readonly language: UIEventSource<string>
|
||||
}
|
||||
readonly lastClickObject: WritableFeatureSource
|
||||
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>
|
||||
readonly preferencesAsTags: Store<Record<string, string>>
|
||||
readonly language: UIEventSource<string>
|
||||
};
|
||||
readonly lastClickObject: WritableFeatureSource;
|
||||
|
||||
readonly availableLayers: Store<RasterLayerPolygon[]>
|
||||
readonly availableLayers: Store<RasterLayerPolygon[]>;
|
||||
|
||||
readonly imageUploadManager: ImageUploadManager;
|
||||
}
|
||||
|
||||
export interface SpecialVisualization {
|
||||
readonly funcName: string
|
||||
readonly docs: string | BaseUIElement
|
||||
readonly example?: string
|
||||
readonly funcName: string;
|
||||
readonly docs: string | BaseUIElement;
|
||||
readonly example?: string;
|
||||
|
||||
/**
|
||||
* Indicates that this special visualisation will make requests to the 'alLNodesDatabase' and that it thus should be included
|
||||
*/
|
||||
readonly needsNodeDatabase?: boolean
|
||||
readonly args: {
|
||||
name: string
|
||||
defaultValue?: string
|
||||
doc: string
|
||||
required?: false | boolean
|
||||
}[]
|
||||
readonly getLayerDependencies?: (argument: string[]) => string[]
|
||||
/**
|
||||
* Indicates that this special visualisation will make requests to the 'alLNodesDatabase' and that it thus should be included
|
||||
*/
|
||||
readonly needsNodeDatabase?: boolean;
|
||||
readonly args: {
|
||||
name: string
|
||||
defaultValue?: string
|
||||
doc: string
|
||||
required?: false | boolean
|
||||
}[];
|
||||
readonly getLayerDependencies?: (argument: string[]) => string[];
|
||||
|
||||
structuredExamples?(): { feature: Feature<Geometry, Record<string, string>>; args: string[] }[]
|
||||
structuredExamples?(): { feature: Feature<Geometry, Record<string, string>>; args: string[] }[];
|
||||
|
||||
constr(
|
||||
state: SpecialVisualizationState,
|
||||
tagSource: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement
|
||||
constr(
|
||||
state: SpecialVisualizationState,
|
||||
tagSource: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement;
|
||||
}
|
||||
|
||||
export type RenderingSpecification =
|
||||
| string
|
||||
| {
|
||||
func: SpecialVisualization
|
||||
args: string[]
|
||||
style: string
|
||||
}
|
||||
| string
|
||||
| {
|
||||
func: SpecialVisualization
|
||||
args: string[]
|
||||
style: string
|
||||
}
|
||||
|
|
|
@ -1,79 +1,71 @@
|
|||
import Combine from "./Base/Combine"
|
||||
import { FixedUiElement } from "./Base/FixedUiElement"
|
||||
import BaseUIElement from "./BaseUIElement"
|
||||
import Title from "./Base/Title"
|
||||
import Table from "./Base/Table"
|
||||
import {
|
||||
RenderingSpecification,
|
||||
SpecialVisualization,
|
||||
SpecialVisualizationState,
|
||||
} from "./SpecialVisualization"
|
||||
import { HistogramViz } from "./Popup/HistogramViz"
|
||||
import { MinimapViz } from "./Popup/MinimapViz"
|
||||
import { ShareLinkViz } from "./Popup/ShareLinkViz"
|
||||
import { UploadToOsmViz } from "./Popup/UploadToOsmViz"
|
||||
import { MultiApplyViz } from "./Popup/MultiApplyViz"
|
||||
import { AddNoteCommentViz } from "./Popup/AddNoteCommentViz"
|
||||
import { PlantNetDetectionViz } from "./Popup/PlantNetDetectionViz"
|
||||
import TagApplyButton from "./Popup/TagApplyButton"
|
||||
import { CloseNoteButton } from "./Popup/CloseNoteButton"
|
||||
import { MapillaryLinkVis } from "./Popup/MapillaryLinkVis"
|
||||
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"
|
||||
import AutoApplyButton from "./Popup/AutoApplyButton"
|
||||
import { LanguageElement } from "./Popup/LanguageElement"
|
||||
import FeatureReviews from "../Logic/Web/MangroveReviews"
|
||||
import Maproulette from "../Logic/Maproulette"
|
||||
import SvelteUIElement from "./Base/SvelteUIElement"
|
||||
import { BBoxFeatureSourceForLayer } from "../Logic/FeatureSource/Sources/TouchesBboxFeatureSource"
|
||||
import QuestionViz from "./Popup/QuestionViz"
|
||||
import { Feature, Point } from "geojson"
|
||||
import { GeoOperations } from "../Logic/GeoOperations"
|
||||
import CreateNewNote from "./Popup/CreateNewNote.svelte"
|
||||
import AddNewPoint from "./Popup/AddNewPoint/AddNewPoint.svelte"
|
||||
import UserProfile from "./BigComponents/UserProfile.svelte"
|
||||
import LanguagePicker from "./LanguagePicker"
|
||||
import Link from "./Base/Link"
|
||||
import LayerConfig from "../Models/ThemeConfig/LayerConfig"
|
||||
import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig"
|
||||
import { OsmTags, WayId } from "../Models/OsmFeature"
|
||||
import MoveWizard from "./Popup/MoveWizard"
|
||||
import SplitRoadWizard from "./Popup/SplitRoadWizard"
|
||||
import { ExportAsGpxViz } from "./Popup/ExportAsGpxViz"
|
||||
import WikipediaPanel from "./Wikipedia/WikipediaPanel.svelte"
|
||||
import TagRenderingEditable from "./Popup/TagRendering/TagRenderingEditable.svelte"
|
||||
import { PointImportButtonViz } from "./Popup/ImportButtons/PointImportButtonViz"
|
||||
import WayImportButtonViz from "./Popup/ImportButtons/WayImportButtonViz"
|
||||
import ConflateImportButtonViz from "./Popup/ImportButtons/ConflateImportButtonViz"
|
||||
import DeleteWizard from "./Popup/DeleteFlow/DeleteWizard.svelte"
|
||||
import { OpenJosm } from "./BigComponents/OpenJosm"
|
||||
import OpenIdEditor from "./BigComponents/OpenIdEditor.svelte"
|
||||
import FediverseValidator from "./InputElement/Validators/FediverseValidator"
|
||||
import SendEmail from "./Popup/SendEmail.svelte"
|
||||
import NearbyImages from "./Popup/NearbyImages.svelte"
|
||||
import NearbyImagesCollapsed from "./Popup/NearbyImagesCollapsed.svelte"
|
||||
import Combine from "./Base/Combine";
|
||||
import { FixedUiElement } from "./Base/FixedUiElement";
|
||||
import BaseUIElement from "./BaseUIElement";
|
||||
import Title from "./Base/Title";
|
||||
import Table from "./Base/Table";
|
||||
import { RenderingSpecification, SpecialVisualization, SpecialVisualizationState } from "./SpecialVisualization";
|
||||
import { HistogramViz } from "./Popup/HistogramViz";
|
||||
import { MinimapViz } from "./Popup/MinimapViz";
|
||||
import { ShareLinkViz } from "./Popup/ShareLinkViz";
|
||||
import { UploadToOsmViz } from "./Popup/UploadToOsmViz";
|
||||
import { MultiApplyViz } from "./Popup/MultiApplyViz";
|
||||
import { AddNoteCommentViz } from "./Popup/AddNoteCommentViz";
|
||||
import { PlantNetDetectionViz } from "./Popup/PlantNetDetectionViz";
|
||||
import TagApplyButton from "./Popup/TagApplyButton";
|
||||
import { CloseNoteButton } from "./Popup/CloseNoteButton";
|
||||
import { MapillaryLinkVis } from "./Popup/MapillaryLinkVis";
|
||||
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 { 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 { SubstitutedTranslation } from "./SubstitutedTranslation";
|
||||
import List from "./Base/List";
|
||||
import StatisticsPanel from "./BigComponents/StatisticsPanel";
|
||||
import AutoApplyButton from "./Popup/AutoApplyButton";
|
||||
import { LanguageElement } from "./Popup/LanguageElement";
|
||||
import FeatureReviews from "../Logic/Web/MangroveReviews";
|
||||
import Maproulette from "../Logic/Maproulette";
|
||||
import SvelteUIElement from "./Base/SvelteUIElement";
|
||||
import { BBoxFeatureSourceForLayer } from "../Logic/FeatureSource/Sources/TouchesBboxFeatureSource";
|
||||
import QuestionViz from "./Popup/QuestionViz";
|
||||
import { Feature, Point } from "geojson";
|
||||
import { GeoOperations } from "../Logic/GeoOperations";
|
||||
import CreateNewNote from "./Popup/CreateNewNote.svelte";
|
||||
import AddNewPoint from "./Popup/AddNewPoint/AddNewPoint.svelte";
|
||||
import UserProfile from "./BigComponents/UserProfile.svelte";
|
||||
import LanguagePicker from "./LanguagePicker";
|
||||
import Link from "./Base/Link";
|
||||
import LayerConfig from "../Models/ThemeConfig/LayerConfig";
|
||||
import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig";
|
||||
import { OsmTags, WayId } from "../Models/OsmFeature";
|
||||
import MoveWizard from "./Popup/MoveWizard";
|
||||
import SplitRoadWizard from "./Popup/SplitRoadWizard";
|
||||
import { ExportAsGpxViz } from "./Popup/ExportAsGpxViz";
|
||||
import WikipediaPanel from "./Wikipedia/WikipediaPanel.svelte";
|
||||
import TagRenderingEditable from "./Popup/TagRendering/TagRenderingEditable.svelte";
|
||||
import { PointImportButtonViz } from "./Popup/ImportButtons/PointImportButtonViz";
|
||||
import WayImportButtonViz from "./Popup/ImportButtons/WayImportButtonViz";
|
||||
import ConflateImportButtonViz from "./Popup/ImportButtons/ConflateImportButtonViz";
|
||||
import DeleteWizard from "./Popup/DeleteFlow/DeleteWizard.svelte";
|
||||
import { OpenJosm } from "./BigComponents/OpenJosm";
|
||||
import OpenIdEditor from "./BigComponents/OpenIdEditor.svelte";
|
||||
import FediverseValidator from "./InputElement/Validators/FediverseValidator";
|
||||
import SendEmail from "./Popup/SendEmail.svelte";
|
||||
import NearbyImages from "./Popup/NearbyImages.svelte";
|
||||
import NearbyImagesCollapsed from "./Popup/NearbyImagesCollapsed.svelte";
|
||||
import UploadImage from "./Image/UploadImage.svelte";
|
||||
|
||||
class NearbyImageVis implements SpecialVisualization {
|
||||
// Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests
|
||||
|
@ -272,6 +264,7 @@ export default class SpecialVisualizations {
|
|||
SpecialVisualizations.specialVisualizations
|
||||
.map((sp) => sp.funcName + "()")
|
||||
.join(", ")
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -538,7 +531,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,16 +609,18 @@ 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]
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -864,43 +859,11 @@ 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})
|
||||
}
|
||||
},
|
||||
{
|
||||
funcName: "title",
|
||||
|
@ -1171,7 +1134,7 @@ export default class SpecialVisualizations {
|
|||
new Link(
|
||||
Utils.SubstituteKeys(text, tags),
|
||||
Utils.SubstituteKeys(href, tags),
|
||||
download === undefined,
|
||||
download === undefined && !href.startsWith("#"),
|
||||
Utils.SubstituteKeys(download, tags)
|
||||
).SetClass(classnames)
|
||||
)
|
||||
|
|
|
@ -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")} />
|
||||
|
|
|
@ -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,7 +11,7 @@
|
|||
import Translations from "../i18n/Translations"
|
||||
|
||||
/**
|
||||
* Small helper
|
||||
* Shows a wikipedia-article + wikidata preview for the given item
|
||||
*/
|
||||
export let wikipediaDetails: Store<FullWikipediaDetails>
|
||||
</script>
|
||||
|
@ -22,31 +22,33 @@
|
|||
<Tr t={Translations.t.general.wikipedia.fromWikipedia} />
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if $wikipediaDetails.wikidata}
|
||||
<ToSvelte construct={WikidataPreviewBox.WikidataResponsePreview($wikipediaDetails.wikidata)} />
|
||||
{/if}
|
||||
|
||||
{#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"}
|
||||
/>
|
||||
Read the rest of the article
|
||||
</span>
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel>
|
||||
<FromHtml src={$wikipediaDetails.restOfArticle} />
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</span>
|
||||
<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}
|
||||
|
|
|
@ -16,8 +16,8 @@
|
|||
*/
|
||||
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(
|
||||
wikipediaStores.addCallbackAndRunD((wikipediaStores) => {
|
||||
|
|
29
src/Utils.ts
29
src/Utils.ts
|
@ -1,4 +1,5 @@
|
|||
import colors from "./assets/colors.json"
|
||||
import DOMPurify from "dompurify"
|
||||
|
||||
export class Utils {
|
||||
/**
|
||||
|
@ -24,7 +25,6 @@ Remark that the syntax is slightly different then expected; it uses '$' to note
|
|||
Note that these values can be prepare with javascript in the theme by using a [calculatedTag](calculatedTags.md#calculating-tags-with-javascript)
|
||||
`
|
||||
public static readonly imageExtensions = new Set(["jpg", "png", "svg", "jpeg", ".gif"])
|
||||
|
||||
public static readonly special_visualizations_importRequirementDocs = `#### Importing a dataset into OpenStreetMap: requirements
|
||||
|
||||
If you want to import a dataset, make sure that:
|
||||
|
@ -146,6 +146,27 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
|||
}
|
||||
>()
|
||||
|
||||
public static initDomPurify() {
|
||||
if (Utils.runningFromConsole) {
|
||||
return
|
||||
}
|
||||
DOMPurify.addHook("afterSanitizeAttributes", function (node) {
|
||||
// set all elements owning target to target=_blank + add noopener noreferrer
|
||||
const target = node.getAttribute("target")
|
||||
if (target) {
|
||||
node.setAttribute("target", "_blank")
|
||||
node.setAttribute("rel", "noopener noreferrer")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public static purify(src: string): string {
|
||||
return DOMPurify.sanitize(src, {
|
||||
USE_PROFILES: { html: true },
|
||||
ADD_ATTR: ["target"], // Don't remove target='_blank'. Note that Utils.initDomPurify does add a hook which automatically adds 'rel=noopener'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the arguments for special visualisations
|
||||
*/
|
||||
|
@ -287,10 +308,12 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
|||
}
|
||||
|
||||
/**
|
||||
* Adds a property to the given object, but the value will _only_ be calculated when it is actually requested
|
||||
* Adds a property to the given object, but the value will _only_ be calculated when it is actually requested.
|
||||
* This calculation will run once
|
||||
* @param object
|
||||
* @param name
|
||||
* @param init
|
||||
* @param whenDone: called when the value is updated. Note that this will be called at most once
|
||||
* @constructor
|
||||
*/
|
||||
public static AddLazyProperty(
|
||||
|
@ -364,7 +387,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
|||
return newArr
|
||||
}
|
||||
|
||||
public static Dupiclates(arr: string[]): string[] {
|
||||
public static Duplicates(arr: string[]): string[] {
|
||||
if (arr === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
|
|
@ -46,7 +46,7 @@ export class PngMapCreator {
|
|||
const pixelRatio = 4
|
||||
const mapElem = new MlMap({
|
||||
container: div.id,
|
||||
style: AvailableRasterLayers.maplibre.properties.url,
|
||||
style: AvailableRasterLayers.maptilerDefaultLayer.properties.url,
|
||||
center: [l.lon, l.lat],
|
||||
zoom: settings.zoom.data,
|
||||
pixelRatio,
|
||||
|
|
|
@ -679,13 +679,6 @@ class SvgToPdfPage {
|
|||
".json",
|
||||
24 * 60 * 60 * 1000
|
||||
)
|
||||
const shared_questions = await Utils.downloadJsonCached(
|
||||
"https://raw.githubusercontent.com/pietervdvn/MapComplete/develop/langs/shared-questions/" +
|
||||
language +
|
||||
".json",
|
||||
24 * 60 * 60 * 1000
|
||||
)
|
||||
this.layerTranslations[language]["shared-questions"] = shared_questions["shared_questions"]
|
||||
}
|
||||
|
||||
public async Prepare() {
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -154,6 +154,11 @@ input[type=text] {
|
|||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.border-region {
|
||||
border: 2px dashed var(--interactive-background);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
/******************* Styling of input elements **********************/
|
||||
|
||||
|
|
|
@ -2,7 +2,10 @@ import ThemeViewState from "./src/Models/ThemeViewState"
|
|||
import SvelteUIElement from "./src/UI/Base/SvelteUIElement"
|
||||
import ThemeViewGUI from "./src/UI/ThemeViewGUI.svelte"
|
||||
import LayoutConfig from "./src/Models/ThemeConfig/LayoutConfig";
|
||||
import MetaTagging from "./src/Logic/MetaTagging";
|
||||
|
||||
|
||||
MetaTagging.setThemeMetatagging(new ThemeMetaTagging())
|
||||
const state = new ThemeViewState(new LayoutConfig(<any> layout))
|
||||
const main = new SvelteUIElement(ThemeViewGUI, { state })
|
||||
main.AttachTo("maindiv")
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue