Merge develop

This commit is contained in:
Pieter Vander Vennet 2023-09-29 00:28:14 +02:00
commit 477ef56e00
202 changed files with 5785 additions and 2386 deletions

View file

@ -1,5 +1,5 @@
import { Store, UIEventSource } from "../UIEventSource";
import { RasterLayerPolygon } from "../../Models/RasterLayers";
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.
@ -7,40 +7,47 @@ import { RasterLayerPolygon } from "../../Models/RasterLayers";
* 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>;
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;
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 => {
this._rasterLayerSetting.addCallbackD((layer) => {
if (layer.properties.id !== this._queryParameter.data) {
this._queryParameter.setData(undefined);
return true;
this._queryParameter.setData(undefined)
return true
}
});
})
this._queryParameter.addCallbackAndRunD(_ => {
const isApplied = self.updateLayer();
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
self._queryParameter.setData(undefined)
return true // Unregister
}
});
})
this._preferredBackgroundLayer.addCallbackD(_ => self.updateLayer());
this._availableLayers.addCallbackD(_ => self.updateLayer());
this._preferredBackgroundLayer.addCallbackD((_) => self.updateLayer())
this._availableLayers.addCallbackD((_) => self.updateLayer())
}
/**
@ -48,20 +55,19 @@ export class PreferredRasterLayerSelector {
* @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);
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;
this._rasterLayerSetting.setData(foundLayer)
return true
}
// The current layer is not in view
}
}

View file

@ -1,159 +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";
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 _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
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
);
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
}
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
/**
* 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),
}
}
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 {
/**
* 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
}
({ 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);
}
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)
}
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);
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
}
if (!collection.has(key)) {
collection.set(key, new UIEventSource<number>(0));
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)
}
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);
}
private increaseCountFor(collection: Map<string, UIEventSource<number>>, key: string | "*") {
const counter = this.getCounterFor(collection, key)
counter.setData(counter.data + 1)
const global = this.getCounterFor(collection, "*")
global.setData(counter.data + 1)
}
}

View file

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

View file

@ -1,10 +1,9 @@
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";
import Img from "../../UI/Base/Img";
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 implements ImageUploader {
public static readonly defaultValuePrefix = ["https://i.imgur.com"]

View file

@ -1,15 +1,15 @@
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";
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" };
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
@ -31,10 +31,10 @@ export default class LinkImageAction extends OsmChangeAction {
}
) {
super(elementId, true)
this._proposedKey = proposedKey;
this._url = url;
this._currentTags = currentTags;
this._meta = meta;
this._proposedKey = proposedKey
this._url = url
this._currentTags = currentTags
this._meta = meta
}
protected CreateChangeDescriptions(): Promise<ChangeDescription[]> {
@ -46,9 +46,12 @@ export default class LinkImageAction extends OsmChangeAction {
key = this._proposedKey + ":" + i
i++
}
const tagChangeAction = new ChangeTagAction ( this.mainObjectId, new Tag(key, url), currentTags, this._meta)
const tagChangeAction = new ChangeTagAction(
this.mainObjectId,
new Tag(key, url),
currentTags,
this._meta
)
return tagChangeAction.CreateChangeDescriptions()
}
}

View file

@ -19,9 +19,6 @@ 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) {

View file

@ -5,7 +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";
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
export interface ChangesetTag {
key: string
@ -30,11 +30,14 @@ export class ChangesetHandler {
constructor(
dryRun: Store<boolean>,
osmConnection: OsmConnection,
allElements: FeaturePropertiesStore | { addAlias: (id0: string, id1: string) => void } | undefined,
allElements:
| FeaturePropertiesStore
| { addAlias: (id0: string, id1: string) => void }
| undefined,
changes: Changes
) {
this.osmConnection = osmConnection
this.allElements = <FeaturePropertiesStore> allElements
this.allElements = <FeaturePropertiesStore>allElements
this.changes = changes
this._dryRun = dryRun
this.userDetails = osmConnection.userDetails

View file

@ -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,46 +50,46 @@ 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._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()
}
}
@ -101,37 +101,36 @@ 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) => {
const self = this;
if(status.state === "granted" || status.state === "denied"){
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);
});
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)
}
}
@ -140,18 +139,18 @@ export class GeoLocationState {
* @private
*/
private async startWatching() {
const self = this;
const self = this
navigator.geolocation.watchPosition(
function(position) {
self.currentGPSLocation.setData(position.coords);
self._previousLocationGrant.setData("true");
function (position) {
self.currentGPSLocation.setData(position.coords)
self._previousLocationGrant.setData("true")
},
function() {
console.warn("Could not get location with navigator.geolocation");
function () {
console.warn("Could not get location with navigator.geolocation")
},
{
enableHighAccuracy: true
enableHighAccuracy: true,
}
);
)
}
}

View file

@ -1,23 +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 { ThemeMetaTagging } from "./UserSettingsMetaTagging";
import { MapProperties } from "../../Models/MapProperties";
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,
@ -42,8 +42,10 @@ export default class UserRelatedState {
public readonly fixateNorth: UIEventSource<undefined | "yes">
public readonly homeLocation: FeatureSource
public readonly language: UIEventSource<string>
public readonly preferredBackgroundLayer: UIEventSource<string | "photo" | "map" | "osmbasedmap" | undefined>
public readonly imageLicense : UIEventSource<string>
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
@ -61,7 +63,7 @@ export default class UserRelatedState {
* Note: these are linked via OsmConnection.preferences which exports all preferences as UIEventSource
*/
public readonly preferencesAsTags: UIEventSource<Record<string, string>>
private readonly _mapProperties: MapProperties;
private readonly _mapProperties: MapProperties
constructor(
osmConnection: OsmConnection,
@ -71,7 +73,7 @@ export default class UserRelatedState {
mapProperties?: MapProperties
) {
this.osmConnection = osmConnection
this._mapProperties = mapProperties;
this._mapProperties = mapProperties
{
const translationMode: UIEventSource<undefined | "true" | "false" | "mobile" | string> =
this.osmConnection.GetPreference("translation-mode", "false")
@ -104,12 +106,17 @@ export default class UserRelatedState {
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.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.imageLicense = this.osmConnection.GetPreference("pictures-license", "CC0", {
documentation: "The license under which new images are uploaded",
})
this.installedUserThemes = this.InitInstalledUserThemes()
@ -277,7 +284,6 @@ 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) {
@ -405,13 +411,11 @@ export default class UserRelatedState {
}
}
this._mapProperties?.rasterLayer?.addCallbackAndRun(l => {
this._mapProperties?.rasterLayer?.addCallbackAndRun((l) => {
amendedPrefs.data["__current_background"] = l?.properties?.id
amendedPrefs.ping()
})
return amendedPrefs
}
}

View file

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

View file

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

View file

@ -1,41 +1,41 @@
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 maptilerDefaultLayer: RasterLayerPolygon = {
type: "Feature",
@ -47,11 +47,11 @@ 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",
@ -63,11 +63,11 @@ 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 maptilerBackdrop: RasterLayerPolygon = {
type: "Feature",
@ -79,11 +79,11 @@ 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 americana: RasterLayerPolygon = {
type: "Feature",
properties: {
@ -94,43 +94,45 @@ 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.push(...AvailableRasterLayers.globalLayers);
matching.unshift(AvailableRasterLayers.maptilerDefaultLayer,
return GeoOperations.inside(lonlat, eliPolygon)
})
matching.push(...AvailableRasterLayers.globalLayers)
matching.unshift(
AvailableRasterLayers.maptilerDefaultLayer,
AvailableRasterLayers.osmCarto,
AvailableRasterLayers.maptilerCarto,
AvailableRasterLayers.maptilerBackdrop,
AvailableRasterLayers.americana);
return matching;
AvailableRasterLayers.americana
)
return matching
})
);
return available;
)
return available
}
}
@ -148,22 +150,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
}
}
@ -179,11 +181,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"
@ -191,7 +193,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.
*/
@ -203,53 +205,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
*/
@ -259,61 +261,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)
*/
@ -324,17 +326,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
}

View file

@ -4,7 +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";
import { del } from "idb-keyval"
export class UpdateLegacyLayer extends DesugaringStep<
LayerConfigJson | string | { builtin; override }
@ -197,7 +197,7 @@ class UpdateLegacyTheme extends DesugaringStep<LayoutConfigJson> {
delete oldThemeConfig.socialImage
}
if(oldThemeConfig.defaultBackgroundId === "osm"){
if (oldThemeConfig.defaultBackgroundId === "osm") {
console.log("Removing old background in", json.id)
}

View file

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

View file

@ -1,40 +1,48 @@
<script lang="ts">
import { createEventDispatcher } from "svelte"
import { twMerge } from "tailwind-merge"
import { createEventDispatcher } from "svelte";
import { twMerge } from "tailwind-merge";
export let accept: string
export let multiple: boolean = true
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 + "";
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}>
<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"
>
<input
{accept}
bind:this={inputElement}
class="hidden"
id={"fileinput" + id}
{multiple}
name="file-input"
on:change|preventDefault={() => {
drawAttention = false
dispatcher("submit", inputElement.files)
}}
on:dragend={() => {
drawAttention = false
}}
on:dragover|preventDefault|stopPropagation={(e) => {
console.log("Dragging over!")
drawAttention = true
e.dataTransfer.drop = "copy"
}}
on:dragstart={() => {
drawAttention = false
}}
on:drop|preventDefault|stopPropagation={(e) => {
console.log("Got a 'drop'")
drawAttention = false
dispatcher("submit", e.dataTransfer.files)
}}
type="file"
/>
</form>

View file

@ -2,7 +2,7 @@
/**
* Given an HTML string, properly shows this
*/
import { Utils } from "../../Utils";
import { Utils } from "../../Utils"
export let src: string

View file

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

View file

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

View file

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

View file

@ -1,44 +1,45 @@
<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 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";
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;
let geopermission: Store<GeolocationPermissionState> =
state.geolocation.geolocationState.permission
let currentGPSLocation = state.geolocation.geolocationState.currentGPSLocation
geopermission.addCallback(perm => console.log(">>>> Permission", perm));
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>
@ -69,22 +70,29 @@
</button>
<!-- 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}>
<button class="disabled flex w-full items-center gap-x-2" on:click={jumpToCurrentLocation}>
<!-- Even though disabled, when clicking we request the location again in case the contributor dismissed the location popup -->
<ToSvelte construct={Svg.crosshair_svg().SetClass("w-8 h-8").SetClass("animate-spin")} />
<ToSvelte
construct={Svg.crosshair_svg()
.SetClass("w-8 h-8")
.SetStyle("animation: 3s linear 0s infinite normal none running spin;")}
/>
<Tr t={Translations.t.general.waitingForGeopermission} />
</button>
{:else if $geopermission === "denied"}
<button class="flex w-full items-center gap-x-2 disabled">
<button class="disabled flex w-full items-center gap-x-2">
<ToSvelte construct={Svg.location_refused_svg().SetClass("w-8 h-8")} />
<Tr t={Translations.t.general.geopermissionDenied} />
</button>
{:else }
<button class="flex w-full items-center gap-x-2 disabled">
<ToSvelte construct={Svg.crosshair_svg().SetClass("w-8 h-8").SetClass("animate-spin")} />
{:else}
<button class="disabled flex w-full items-center gap-x-2">
<ToSvelte
construct={Svg.crosshair_svg()
.SetClass("w-8 h-8")
.SetStyle("animation: 3s linear 0s infinite normal none running spin;")}
/>
<Tr t={Translations.t.general.waitingForLocation} />
</button>
{/if}
<div class=".button low-interaction m-1 flex w-full items-center gap-x-2 rounded border p-2">

View file

@ -1,63 +1,63 @@
<script lang="ts">/**
* Shows an 'upload'-button which will start the upload for this feature
*/
<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";
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 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;
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;
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);
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)}>
<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 ")} />
<ToSvelte construct={Svg.camera_plus_svg().SetClass("block w-12 h-12 p-1 text-4xl ")} />
{/if}
{#if labelText}
{labelText}
@ -68,10 +68,14 @@ function handleFiles(files: FileList) {
</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
class="cursor-pointer"
on:click={() => {
state.guistate.openUsersettings("picture-license")
}}
>
<Tr t={t.currentLicense.Subs({ license: $licenseStore })} />
</a>
</div>
</div>
</LoginToggle>

View file

@ -1,32 +1,28 @@
<script lang="ts">/**
* Shows information about how much images are uploaded for the given feature
*/
<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;
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 }
{#if $uploadFinished == 1}
<Tr cls="thanks" t={t.upload.one.done} />
{:else if $failed == 1}
<div class="flex flex-col alert">
<div class="alert flex flex-col">
<Tr cls="self-center" t={t.upload.one.failed} />
<Tr t={t.upload.failReasons} />
<Tr t={t.upload.failReasonsAdvanced} />
@ -35,30 +31,34 @@ const t = Translations.t.image;
<Loading cls="alert">
<Tr t={t.upload.one.retrying} />
</Loading>
{:else }
{: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})} />
{#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})} />
<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})} />
<Tr
t={t.upload.multiple.partiallyDone.Subs({
count: $uploadStarted - $uploadFinished,
done: $uploadFinished,
})}
/>
</Loading>
{/if}
{#if $failed > 0}
<div class="flex flex-col alert">
<div class="alert flex flex-col">
{#if failed === 1}
<Tr cls="self-center" t={t.upload.one.failed} />
{:else}
<Tr cls="self-center" t={t.upload.multiple.someFailed.Subs({count: $failed})} />
<Tr cls="self-center" t={t.upload.multiple.someFailed.Subs({ count: $failed })} />
{/if}
<Tr t={t.upload.failReasons} />
<Tr t={t.upload.failReasonsAdvanced} />

View file

@ -432,7 +432,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
)
}
await this.awaitStyleIsLoaded()
if(this._currentRasterLayer !== background?.id){
if (this._currentRasterLayer !== background?.id) {
this.removeCurrentLayer(map)
}
this._currentRasterLayer = background?.id
@ -459,10 +459,10 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
map.rotateTo(0, { duration: 0 })
map.setPitch(0)
map.dragRotate.disable()
map.touchZoomRotate.disableRotation();
map.touchZoomRotate.disableRotation()
} else {
map.dragRotate.enable()
map.touchZoomRotate.enableRotation();
map.touchZoomRotate.enableRotation()
}
}

View file

@ -1,76 +1,79 @@
<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";
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;
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);
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;
let error: string = undefined
/**
* The Wikidata-id of the species to apply
*/
let selectedOption: string;
let selectedOption: string
let done = false;
let done = false
function speciesSelected(species: PlantNetSpeciesMatch) {
console.log("Selected:", species);
selectedOption = species;
console.log("Selected:", species)
selectedOption = species
}
async function detectSpecies() {
collapsedMode = false;
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));
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;
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})} />
<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>
<div class=" border-region relative mb-1 p-2">
<XCircleIcon
class="absolute top-0 right-0 m-4 h-8 w-8 cursor-pointer"
on:click={() => {
collapsedMode = true
}}
/>
<Tr t={t.takeImages} />
<Tr t={ t.howTo.intro} />
<Tr t={t.howTo.intro} />
<ul>
<li>
<Tr t={t.howTo.li0} />
@ -87,23 +90,39 @@
</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
{options}
numberOfImages={$imageUrls.length}
on:selected={(species) => speciesSelected(species.detail)}
>
<XCircleIcon
slot="upper-right"
class="m-4 h-8 w-8 cursor-pointer"
on:click={() => {
collapsedMode = true
}}
/>
</PlantNetSpeciesList>
{:else if !done}
<div class="flex flex-col border-interactive">
<div class="border-interactive flex flex-col">
<div class="m-2">
<WikipediaPanel wikiIds={new ImmutableStore([selectedOption])} />
</div>
<div class="flex flex-col items-stretch">
<BackButton on:click={() => {selectedOption = undefined}}>
<BackButton
on:click={() => {
selectedOption = undefined
}}
>
<Tr t={t.back} />
</BackButton>
<NextButton clss="primary" on:click={() => { done = true; onConfirm(selectedOption); }} >
<NextButton
clss="primary"
on:click={() => {
done = true
onConfirm(selectedOption)
}}
>
<Tr t={t.confirm} />
</NextButton>
</div>
@ -111,13 +130,21 @@
{: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}}>
<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")} />
<div class="low-interaction flex self-end rounded-xl p-2">
<ToSvelte
construct={Svg.plantnet_logo_svg().SetClass("w-8 h-8 p-1 mr-1 bg-white rounded-full")}
/>
<Tr t={t.poweredByPlantnet} />
</div>
</div>

View file

@ -1,29 +1,28 @@
<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";
<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;
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})} />
<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 class="low-interaction border-interactive relative flex flex-col p-2">
<div class="absolute top-0 right-0">
<slot name="upper-right" />
</div>
<h3>
<Tr t={t.overviewTitle} />
@ -31,7 +30,7 @@ export let numberOfImages: number;
<Tr t={t.overviewIntro} />
<Tr cls="font-bold" t={t.overviewVerify} />
{#each $options as species}
<SpeciesButton {species} on:selected/>
{/each}
<SpeciesButton {species} on:selected />
{/each}
</div>
{/if}

View file

@ -1,54 +1,61 @@
<script lang="ts">/**
* A button to select a single species
*/
import { createEventDispatcher } from "svelte";
import type { PlantNetSpeciesMatch } from "../../Logic/Web/PlantNet";
import { UIEventSource } from "../../Logic/UIEventSource";
import Wikidata from "../../Logic/Web/Wikidata";
import NextButton from "../Base/NextButton.svelte";
import Loading from "../Base/Loading.svelte";
import WikidataPreviewBox from "../Wikipedia/WikidataPreviewBox";
import Tr from "../Base/Tr.svelte";
import Translations from "../i18n/Translations";
import ToSvelte from "../Base/ToSvelte.svelte";
<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 + "\""]
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;
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);
/**
* 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,
})} />
<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>
<ToSvelte
construct={() =>
new WikidataPreviewBox(wikidataId, {
imageStyle: "max-width: 8rem; width: unset; height: 8rem",
extraItems: [
t.matchPercentage
.Subs({ match: Math.round(species.score * 100) })
.SetClass("thanks w-fit self-center"),
],
}).SetClass("w-full")}
/>
{/if}
</NextButton>

View file

@ -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;
if (snapTo !== undefined) {
const downloaded = await state.osmObjectDownloader.DownloadObjectAsync(snapTo, 0);
let snapToWay: undefined | OsmWay = undefined
if (snapTo !== undefined && snapTo !== null) {
const downloaded = await state.osmObjectDownloader.DownloadObjectAsync(snapTo, 0)
if (downloaded !== "deleted") {
snapToWay = downloaded;
snapToWay = downloaded
}
}
@ -113,42 +113,44 @@
theme: state.layout?.id ?? "unkown",
changeType: "create",
snapOnto: snapToWay,
reusePointWithinMeters: 1
});
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);
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);
console.log("Selecting feature", feature, "and opening their popup");
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));
confirm()
.then((_) => console.debug("New point successfully handled"))
.catch((e) => console.error("Handling the new point went wrong due to", e))
}
</script>

View file

@ -62,7 +62,7 @@
state.newFeatures.features.data.push(feature)
state.newFeatures.features.ping()
state.selectedElement?.setData(feature)
if(state.featureProperties.trackFeature){
if (state.featureProperties.trackFeature) {
state.featureProperties.trackFeature(feature)
}
comment.setData("")

View file

@ -23,18 +23,20 @@
export let feature: Feature
export let layer: LayerConfig
export let linkable = true;
let isLinked = Object.values(tags.data).some(v => image.pictureUrl === v);
export let linkable = true
let isLinked = 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]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -27,8 +27,6 @@ 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 { SubtleButton } from "./Base/SubtleButton"
import Svg from "../Svg"
@ -74,6 +72,9 @@ import Constants from "../Models/Constants"
import { MangroveReviews } from "mangrove-reviews-typescript"
import Wikipedia from "../Logic/Web/Wikipedia"
import NearbyImagesSearch from "../Logic/Web/NearbyImagesSearch"
import AllReviews from "./Reviews/AllReviews.svelte"
import StarsBarIcon from "./Reviews/StarsBarIcon.svelte"
import ReviewForm from "./Reviews/ReviewForm.svelte"
class NearbyImageVis implements SpecialVisualization {
// Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests
@ -648,7 +649,75 @@ export default class SpecialVisualizations {
},
},
{
funcName: "reviews",
funcName: "rating",
docs: "Shows stars which represent the avarage rating on mangrove.reviews",
needsUrls: [MangroveReviews.ORIGINAL_API],
args: [
{
name: "subjectKey",
defaultValue: "name",
doc: "The key to use to determine the subject. If specified, the subject will be <b>tags[subjectKey]</b>",
},
{
name: "fallback",
doc: "The identifier to use, if <i>tags[subjectKey]</i> as specified above is not available. This is effectively a fallback value",
},
],
constr: (state, tags, args, feature, layer) => {
const nameKey = args[0] ?? "name"
let fallbackName = args[1]
const reviews = FeatureReviews.construct(
feature,
tags,
state.userRelatedState.mangroveIdentity,
{
nameKey: nameKey,
fallbackName,
}
)
return new SvelteUIElement(StarsBarIcon, {
score: reviews.average,
reviews,
state,
tags,
feature,
layer,
})
},
},
{
funcName: "create_review",
docs: "Invites the contributor to leave a review. Somewhat small UI-element until interacted",
needsUrls: [MangroveReviews.ORIGINAL_API],
args: [
{
name: "subjectKey",
defaultValue: "name",
doc: "The key to use to determine the subject. If specified, the subject will be <b>tags[subjectKey]</b>",
},
{
name: "fallback",
doc: "The identifier to use, if <i>tags[subjectKey]</i> as specified above is not available. This is effectively a fallback value",
},
],
constr: (state, tags, args, feature, layer) => {
const nameKey = args[0] ?? "name"
let fallbackName = args[1]
const reviews = FeatureReviews.construct(
feature,
tags,
state.userRelatedState.mangroveIdentity,
{
nameKey: nameKey,
fallbackName,
}
)
return new SvelteUIElement(ReviewForm, { reviews, state, tags, feature, layer })
},
},
{
funcName: "list_reviews",
docs: "Adds an overview of the mangrove-reviews of this object. Mangrove.Reviews needs - in order to identify the reviewed object - a coordinate and a name. By default, the name of the object is given, but this can be overwritten",
needsUrls: [MangroveReviews.ORIGINAL_API],
example:
@ -664,10 +733,10 @@ export default class SpecialVisualizations {
doc: "The identifier to use, if <i>tags[subjectKey]</i> as specified above is not available. This is effectively a fallback value",
},
],
constr: (state, tags, args, feature) => {
constr: (state, tags, args, feature, layer) => {
const nameKey = args[0] ?? "name"
let fallbackName = args[1]
const mangrove = FeatureReviews.construct(
const reviews = FeatureReviews.construct(
feature,
tags,
state.userRelatedState.mangroveIdentity,
@ -676,9 +745,7 @@ export default class SpecialVisualizations {
fallbackName,
}
)
const form = new ReviewForm((r) => mangrove.createReview(r), state)
return new ReviewElement(mangrove, form)
return new SvelteUIElement(AllReviews, { reviews, state, tags, feature, layer })
},
},
{

View file

@ -32,7 +32,7 @@
<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")} />

View file

@ -33,22 +33,22 @@
<Tr t={Translations.t.general.wikipedia.loading} />
</Loading>
{:else}
<span class="wikipedia-article">
<FromHtml src={$wikipediaDetails.firstParagraph} />
<Disclosure let:open>
<DisclosureButton>
<span class="flex">
<ChevronRightIcon
style={(open ? "transform: rotate(90deg); " : "") +
" transition: all .25s linear; width: 1.5rem; height: 1.5rem"}
/>
<Tr t={Translations.t.general.wikipedia.readMore}/>
</span>
</DisclosureButton>
<DisclosurePanel>
<FromHtml src={$wikipediaDetails.restOfArticle} />
</DisclosurePanel>
</Disclosure>
</span>
<span class="wikipedia-article">
<FromHtml src={$wikipediaDetails.firstParagraph} />
<Disclosure let:open>
<DisclosureButton>
<span class="flex">
<ChevronRightIcon
style={(open ? "transform: rotate(90deg); " : "") +
" transition: all .25s linear; width: 1.5rem; height: 1.5rem"}
/>
<Tr t={Translations.t.general.wikipedia.readMore} />
</span>
</DisclosureButton>
<DisclosurePanel>
<FromHtml src={$wikipediaDetails.restOfArticle} />
</DisclosurePanel>
</Disclosure>
</span>
{/if}
{/if}

View file

@ -17,7 +17,7 @@
export let wikiIds: Store<string[]>
let wikipediaStores: Store<Store<FullWikipediaDetails>[]> = Locale.language.bind((language) =>
wikiIds?.map((wikiIds) => wikiIds?.map((id) => Wikipedia.fetchArticleAndWikidata(id, language)))
);
)
let _wikipediaStores
onDestroy(
wikipediaStores.addCallbackAndRunD((wikipediaStores) => {

View file

@ -244,7 +244,7 @@ export class Translation extends BaseUIElement {
continue
}
let txt = this.translations[lng]
txt = txt.replace(/(\.|<br\/>|<br>).*/, "")
txt = txt.replace(/(\.|<br\/>|<br>|。).*/, "")
txt = Utils.EllipsesAfter(txt, 255)
tr[lng] = txt.trim()
}

View file

@ -1,7 +1,7 @@
{
"contributors": [
{
"commits": 5961,
"commits": 6039,
"contributor": "Pieter Vander Vennet"
},
{
@ -33,7 +33,7 @@
"contributor": "paunofu"
},
{
"commits": 28,
"commits": 29,
"contributor": "Hosted Weblate"
},
{

View file

@ -1,15 +1,15 @@
{
"contributors": [
{
"commits": 300,
"commits": 306,
"contributor": "kjon"
},
{
"commits": 285,
"commits": 287,
"contributor": "Pieter Vander Vennet"
},
{
"commits": 160,
"commits": 171,
"contributor": "paunofu"
},
{
@ -34,11 +34,11 @@
},
{
"commits": 32,
"contributor": "Babos Gábor"
"contributor": "Lucas"
},
{
"commits": 31,
"contributor": "Lucas"
"commits": 32,
"contributor": "Babos Gábor"
},
{
"commits": 31,