Merge branch 'develop' of github.com:pietervdvn/MapComplete into develop

This commit is contained in:
pietervdvn 2021-07-03 22:26:04 +02:00
commit 6b27c1b240
73 changed files with 1443 additions and 564 deletions

View file

@ -8,14 +8,14 @@ export default class AllKnownLayers {
// Must be below the list...
public static sharedLayers: Map<string, LayerConfig> = AllKnownLayers.getSharedLayers();
public static sharedLayersJson: Map<string, any> = AllKnownLayers.getSharedLayersJson();
public static sharedUnits: any[] = []
private static getSharedLayers(): Map<string, LayerConfig> {
const sharedLayers = new Map<string, LayerConfig>();
for (const layer of known_layers.layers) {
try {
const parsed = new LayerConfig(layer, AllKnownLayers.sharedUnits,"shared_layers")
const parsed = new LayerConfig(layer, AllKnownLayers.sharedUnits, "shared_layers")
sharedLayers.set(layer.id, parsed);
sharedLayers[layer.id] = parsed;
} catch (e) {
@ -35,7 +35,7 @@ export default class AllKnownLayers {
continue;
}
try {
const parsed = new LayerConfig(layer, AllKnownLayers.sharedUnits ,"shared_layer_in_theme")
const parsed = new LayerConfig(layer, AllKnownLayers.sharedUnits, "shared_layer_in_theme")
sharedLayers.set(layer.id, parsed);
sharedLayers[layer.id] = parsed;
} catch (e) {

View file

@ -1,8 +1,7 @@
import LayoutConfig from "./JSON/LayoutConfig";
import AllKnownLayers from "./AllKnownLayers";
import * as known_themes from "../assets/generated/known_layers_and_themes.json"
import {LayoutConfigJson} from "./JSON/LayoutConfigJson";
import * as all_layouts from "../assets/generated/known_layers_and_themes.json"
export class AllKnownLayouts {

View file

@ -0,0 +1,47 @@
import {DeleteConfigJson} from "./DeleteConfigJson";
import {Translation} from "../../UI/i18n/Translation";
import {TagsFilter} from "../../Logic/Tags/TagsFilter";
import Translations from "../../UI/i18n/Translations";
import {FromJSON} from "./FromJSON";
export default class DeleteConfig {
public readonly extraDeleteReasons?: {
explanation: Translation,
changesetMessage: string
}[]
public readonly nonDeleteMappings?: { if: TagsFilter, then: Translation }[]
public readonly softDeletionTags?: TagsFilter
public readonly neededChangesets?: number
constructor(json: DeleteConfigJson, context: string) {
this.extraDeleteReasons = json.extraDeleteReasons?.map((reason, i) => {
const ctx = `${context}.extraDeleteReasons[${i}]`
if ((reason.changesetMessage ?? "").length <= 5) {
throw `${ctx}.explanation is too short, needs at least 4 characters`
}
return {
explanation: Translations.T(reason.explanation, ctx + ".explanation"),
changesetMessage: reason.changesetMessage
}
})
this.nonDeleteMappings = json.nonDeleteMappings?.map((nonDelete, i) => {
const ctx = `${context}.extraDeleteReasons[${i}]`
return {
if: FromJSON.Tag(nonDelete.if, ctx + ".if"),
then: Translations.T(nonDelete.then, ctx + ".then")
}
})
this.softDeletionTags = null;
if(json.softDeletionTags !== undefined){
this.softDeletionTags = FromJSON.Tag(json.softDeletionTags,`${context}.softDeletionTags`)
}
this.neededChangesets = json.neededChangesets
}
}

View file

@ -0,0 +1,66 @@
import {AndOrTagConfigJson} from "./TagConfigJson";
export interface DeleteConfigJson {
/***
* By default, three reasons to delete a point are shown:
*
* - The point does not exist anymore
* - The point was a testing point
* - THe point could not be found
*
* However, for some layers, there might be different or more specific reasons for deletion which can be user friendly to set, e.g.:
*
* - the shop has closed
* - the climbing route has been closed of for nature conservation reasons
* - ...
*
* These reasons can be stated here and will be shown in the list of options the user can choose from
*/
extraDeleteReasons?: {
/**
* The text that will be shown to the user - translatable
*/
explanation: string | any,
/**
* The text that will be uploaded into the changeset or will be used in the fixme in case of a soft deletion
* Should be a few words, in english
*/
changesetMessage: string
}[]
/**
* In some cases, a (starting) contributor might wish to delete a feature even though deletion is not appropriate.
* (The most relevant case are small paths running over private property. These should be marked as 'private' instead of deleted, as the community might trace the path again from aerial imagery, gettting us back to the original situation).
*
* By adding a 'nonDeleteMapping', an option can be added into the list which will retag the feature.
* It is important that the feature will be retagged in such a way that it won't be picked up by the layer anymore!
*/
nonDeleteMappings?: { if: AndOrTagConfigJson, then: string | any }[],
/**
* In some cases, the contributor is not allowed to delete the current feature (e.g. because it isn't a point, the point is referenced by a relation or the user isn't experienced enough).
* To still offer the user a 'delete'-option, the feature is retagged with these tags. This is a soft deletion, as the point isn't actually removed from OSM but rather marked as 'disused'
* It is important that the feature will be retagged in such a way that it won't be picked up by the layer anymore!
*
* Example (note that "amenity=" erases the 'amenity'-key alltogether):
* ```
* {
* "and": ["disussed:amenity=public_bookcase", "amenity="]
* }
* ```
*
* or (notice the use of the ':='-tag to copy the old value of 'shop=*' into 'disused:shop='):
* ```
* {
* "and": ["disused:shop:={shop}", "shop="]
* }
* ```
*/
softDeletionTags?: AndOrTagConfigJson | string,
/***
* By default, the contributor needs 20 previous changesets to delete points edited by others.
* For some small features (e.g. bicycle racks) this is too much and this requirement can be lowered or dropped, which can be done here.
*/
neededChangesets?: number
}

View file

@ -151,7 +151,7 @@ export class Denomination {
if (stripped === null) {
return null;
}
return stripped + this.canonical
return stripped + " " + this.canonical.trim()
}
/**

View file

@ -9,8 +9,11 @@ import SubstitutingTag from "../../Logic/Tags/SubstitutingTag";
export class FromJSON {
public static SimpleTag(json: string): Tag {
public static SimpleTag(json: string, context?: string): Tag {
const tag = Utils.SplitFirst(json, "=");
if(tag.length !== 2){
throw `Invalid tag: no (or too much) '=' found (in ${context ?? "unkown context"})`
}
return new Tag(tag[0], tag[1]);
}

View file

@ -17,6 +17,7 @@ import {TagsFilter} from "../../Logic/Tags/TagsFilter";
import {Tag} from "../../Logic/Tags/Tag";
import BaseUIElement from "../../UI/BaseUIElement";
import {Unit} from "./Denomination";
import DeleteConfig from "./DeleteConfig";
export default class LayerConfig {
@ -47,6 +48,7 @@ export default class LayerConfig {
dashArray: TagRenderingConfig;
wayHandling: number;
public readonly units: Unit[];
public readonly deletion: DeleteConfig | null
presets: {
title: Translation,
@ -240,6 +242,15 @@ export default class LayerConfig {
this.width = tr("width", "7");
this.rotation = tr("rotation", "0");
this.dashArray = tr("dashArray", "");
this.deletion = null;
if(json.deletion === true){
json.deletion = {
}
}
if(json.deletion !== undefined && json.deletion !== false){
this.deletion = new DeleteConfig(json.deletion, `${context}.deletion`)
}
if (json["showIf"] !== undefined) {

View file

@ -1,5 +1,6 @@
import {TagRenderingConfigJson} from "./TagRenderingConfigJson";
import {AndOrTagConfigJson} from "./TagConfigJson";
import {DeleteConfigJson} from "./DeleteConfigJson";
/**
* Configuration for a single layer
@ -14,7 +15,7 @@ export interface LayerConfigJson {
/**
* The name of this layer
* Used in the layer control panel and the 'Personal theme'.
*
*
* If not given, will be hidden (and thus not toggable) in the layer control
*/
name?: string | any
@ -31,28 +32,28 @@ export interface LayerConfigJson {
* There are some options:
*
* # Query OSM directly
* source: {osmTags: "key=value"}
* source: {osmTags: "key=value"}
* will fetch all objects with given tags from OSM.
* Currently, this will create a query to overpass and fetch the data - in the future this might fetch from the OSM API
*
*
* # Query OSM Via the overpass API with a custom script
* source: {overpassScript: "<custom overpass tags>"} when you want to do special things. _This should be really rare_.
* This means that the data will be pulled from overpass with this script, and will ignore the osmTags for the query
* However, for the rest of the pipeline, the OsmTags will _still_ be used. This is important to enable layers etc...
*
*
* # A single geojson-file
* source: {geoJson: "https://my.source.net/some-geo-data.geojson"}
* # A single geojson-file
* source: {geoJson: "https://my.source.net/some-geo-data.geojson"}
* fetches a geojson from a third party source
*
*
* # A tiled geojson source
* source: {geoJson: "https://my.source.net/some-tile-geojson-{layer}-{z}-{x}-{y}.geojson", geoJsonZoomLevel: 14}
* source: {geoJson: "https://my.source.net/some-tile-geojson-{layer}-{z}-{x}-{y}.geojson", geoJsonZoomLevel: 14}
* to use a tiled geojson source. The web server must offer multiple geojsons. {z}, {x} and {y} are substituted by the location; {layer} is substituted with the id of the loaded layer
*
*
* Note that both geojson-options might set a flag 'isOsmCache' indicating that the data originally comes from OSM too
*
*
*
* Note that both geojson-options might set a flag 'isOsmCache' indicating that the data originally comes from OSM too
*
*
* NOTE: the previous format was 'overpassTags: AndOrTagCOnfigJson | string', which is interpreted as a shorthand for source: {osmTags: "key=value"}
* While still supported, this is considered deprecated
*/
@ -123,7 +124,7 @@ export interface LayerConfigJson {
* As a result, on could use a generic pin, then overlay it with a specific icon.
* To make things even more practical, one can use all svgs from the folder "assets/svg" and _substitute the color_ in it.
* E.g. to draw a red pin, use "pin:#f00", to have a green circle with your icon on top, use `circle:#0f0;<path to my icon.svg>`
*
*
*/
icon?: string | TagRenderingConfigJson;
@ -148,14 +149,14 @@ export interface LayerConfigJson {
*/
rotation?: string | TagRenderingConfigJson;
/**
* A HTML-fragment that is shown below the icon, for example:
* A HTML-fragment that is shown below the icon, for example:
* <div style="background: white; display: block">{name}</div>
*
*
* If the icon is undefined, then the label is shown in the center of the feature.
* Note that, if the wayhandling hides the icon then no label is shown as well.
*/
label?: string | TagRenderingConfigJson ;
label?: string | TagRenderingConfigJson;
/**
* The color for way-elements and SVG-elements.
* If the value starts with "--", the style of the body element will be queried for the corresponding variable instead
@ -230,7 +231,54 @@ export interface LayerConfigJson {
* A special value is 'questions', which indicates the location of the questions box. If not specified, it'll be appended to the bottom of the featureInfobox.
*
*/
tagRenderings?: (string | TagRenderingConfigJson) []
tagRenderings?: (string | TagRenderingConfigJson) [],
/**
* This block defines under what circumstances the delete dialog is shown for objects of this layer.
* If set, a dialog is shown to the user to (soft) delete the point.
* The dialog is built to be user friendly and to prevent mistakes.
* If deletion is not possible, the dialog will hide itself and show the reason of non-deletability instead.
*
* To configure, the following values are possible:
*
* - false: never ever show the delete button
* - true: show the default delete button
* - undefined: use the mapcomplete default to show deletion or not. Currently, this is the same as 'false' but this will change in the future
* - or: a hash with options (see below)
*
* The delete dialog
* =================
*
*
*
#### Hard deletion if enough experience
A feature can only be deleted from OpenStreetMap by mapcomplete if:
- It is a node
- No ways or relations use the node
- The logged-in user has enough experience OR the user is the only one to have edited the point previously
- The logged-in user has no unread messages (or has a ton of experience)
- The user did not select one of the 'non-delete-options' (see below)
In all other cases, a 'soft deletion' is used.
#### Soft deletion
A 'soft deletion' is when the point isn't deleted from OSM but retagged so that it'll won't how up in the mapcomplete theme anymore.
This makes it look like it was deleted, without doing damage. A fixme will be added to the point.
Note that a soft deletion is _only_ possible if these tags are provided by the theme creator, as they'll be different for every theme
#### No-delete options
In some cases, the contributor might want to delete something for the wrong reason (e.g. someone who wants to have a path removed "because the path is on their private property").
However, the path exists in reality and should thus be on OSM - otherwise the next contributor will pass by and notice "hey, there is a path missing here! Let me redraw it in OSM!)
The correct approach is to retag the feature in such a way that it is semantically correct *and* that it doesn't show up on the theme anymore.
A no-delete option is offered as 'reason to delete it', but secretly retags.
*/
deletion?: boolean | DeleteConfigJson
}

View file

@ -1,7 +1,6 @@
import TagRenderingConfig from "./JSON/TagRenderingConfig";
import * as questions from "../assets/tagRenderings/questions.json";
import * as icons from "../assets/tagRenderings/icons.json";
import {Utils} from "../Utils";
export default class SharedTagRenderings {
@ -12,9 +11,6 @@ export default class SharedTagRenderings {
const dict = new Map<string, TagRenderingConfig>();
function add(key, store) {
if(Utils.runningFromConsole){
return;
}
try {
dict.set(key, new TagRenderingConfig(store[key], undefined, `SharedTagRenderings.${key}`))
} catch (e) {

View file

@ -42,7 +42,7 @@ image-key | image | Image tag to add the URL to (or image-tag:0, image-tag:1 whe
name | default | description
------ | --------- | -------------
zoomlevel | 18 | The zoomlevel: the higher, the more zoomed in with 1 being the entire world and 19 being really close
zoomlevel | 18 | The (maximum) zoomlevel: the target zoomlevel after fitting the entire feature. The minimap will fit the entire feature, then zoom out to this zoom level. The higher, the more zoomed in with 1 being the entire world and 19 being really close
idKey | id | (Matches all resting arguments) This argument should be the key of a property of the feature. The corresponding value is interpreted as either the id or the a list of ID's. The features with these ID's will be shown on this minimap.
#### Example usage

View file

@ -154,10 +154,7 @@ export class InitUiElements {
}
State.state.osmConnection.userDetails.map((userDetails: UserDetails) => userDetails?.home)
.addCallbackAndRun(home => {
if (home === undefined) {
return;
}
.addCallbackAndRunD(home => {
const color = getComputedStyle(document.body).getPropertyValue("--subtle-detail-color")
const icon = L.icon({
iconUrl: Img.AsData(Svg.home_white_bg.replace(/#ffffff/g, color)),
@ -286,10 +283,8 @@ export class InitUiElements {
isOpened.setData(false);
})
State.state.selectedElement.addCallbackAndRun(selected => {
if (selected !== undefined) {
State.state.selectedElement.addCallbackAndRunD(_ => {
isOpened.setData(false);
}
})
isOpened.setData(Hash.hash.data === undefined || Hash.hash.data === "" || Hash.hash.data == "welcome")
}
@ -337,11 +332,9 @@ export class InitUiElements {
copyrightButton.isEnabled.setData(false);
});
State.state.selectedElement.addCallbackAndRun(feature => {
if (feature !== undefined) {
State.state.selectedElement.addCallbackAndRunD(_ => {
layerControlButton.isEnabled.setData(false);
copyrightButton.isEnabled.setData(false);
}
})
}

View file

@ -66,7 +66,7 @@ export class ImageSearcher extends UIEventSource<{ key: string, url: string }[]>
});
if (loadSpecial) {
tags.addCallbackAndRun(tags => {
tags.addCallbackAndRunD(tags => {
const wdItem = tags.wikidata;
if (wdItem !== undefined) {

View file

@ -61,7 +61,7 @@ export default class SelectedFeatureHandler {
return; // No valid feature selected
}
// We should have a valid osm-ID and zoom to it
OsmObject.DownloadObject(hash, (element: OsmObject, meta: OsmObjectMeta) => {
OsmObject.DownloadObject(hash).addCallbackAndRunD(element => {
const centerpoint = element.centerpoint();
console.log("Zooming to location for select point: ", centerpoint)
location.data.lat = centerpoint[0]

View file

@ -61,7 +61,7 @@ export default class TitleHandler {
constructor(layoutToUse: UIEventSource<LayoutConfig>,
selectedFeature: UIEventSource<any>,
allElementsStorage: ElementStorage) {
new TitleElement(layoutToUse, selectedFeature, allElementsStorage).addCallbackAndRun(title => {
new TitleElement(layoutToUse, selectedFeature, allElementsStorage).addCallbackAndRunD(title => {
document.title = title
})
}

View file

@ -1,6 +1,5 @@
import FeatureSource from "./FeatureSource";
import {UIEventSource} from "../UIEventSource";
import * as $ from "jquery";
import Loc from "../../Models/Loc";
import State from "../../State";
import {Utils} from "../../Utils";
@ -152,12 +151,8 @@ export default class GeoJsonSource implements FeatureSource {
private LoadJSONFrom(url: string) {
const eventSource = this.features;
const self = this;
$.getJSON(url, function (json, status) {
if (status !== "success") {
self.onFail(status, url);
return;
}
Utils.downloadJson(url)
.then(json => {
if (json.elements === [] && json.remarks.indexOf("runtime error") > 0) {
self.onFail("Runtime error (timeout)", url)
return;
@ -193,7 +188,7 @@ export default class GeoJsonSource implements FeatureSource {
eventSource.setData(eventSource.data.concat(newFeatures))
}).fail(msg => self.onFail(msg, url))
}).catch(msg => self.onFail(msg, url))
}
}

View file

@ -16,11 +16,7 @@ export default class LocalStorageSaver implements FeatureSource {
constructor(source: FeatureSource, layout: UIEventSource<LayoutConfig>) {
this.features = source.features;
this.features.addCallbackAndRun(features => {
if (features === undefined) {
return;
}
this.features.addCallbackAndRunD(features => {
const now = new Date().getTime()
features = features.filter(f => layout.data.cacheTimeout > Math.abs(now - f.freshness.getTime())/1000)

View file

@ -11,8 +11,6 @@ export default class LocalStorageSource implements FeatureSource {
this.features = new UIEventSource<{ feature: any; freshness: Date }[]>([])
const key = LocalStorageSaver.storageKey + layout.data.id
layout.addCallbackAndRun(_ => {
try {
const fromStorage = localStorage.getItem(key);
if (fromStorage == null) {

View file

@ -3,7 +3,6 @@ import {UIEventSource} from "../UIEventSource";
import {OsmObject} from "../Osm/OsmObject";
import State from "../../State";
import {Utils} from "../../Utils";
import Loc from "../../Models/Loc";
export default class OsmApiFeatureSource implements FeatureSource {
@ -21,10 +20,10 @@ export default class OsmApiFeatureSource implements FeatureSource {
return;
}
console.debug("Downloading", id, "from the OSM-API")
OsmObject.DownloadObject(id, (element, meta) => {
OsmObject.DownloadObject(id).addCallbackAndRunD(element => {
const geojson = element.asGeoJson();
geojson.id = geojson.properties.id;
this.features.setData([{feature: geojson, freshness: meta["_last_edit:timestamp"]}])
this.features.setData([{feature: geojson, freshness: element.timestamp}])
})
}

View file

@ -9,8 +9,8 @@ export default class RegisteringFeatureSource implements FeatureSource {
constructor(source: FeatureSource) {
this.features = source.features;
this.name = "RegisteringSource of " + source.name;
this.features.addCallbackAndRun(features => {
for (const feature of features ?? []) {
this.features.addCallbackAndRunD(features => {
for (const feature of features) {
State.state.allElements.addOrGetElement(feature.feature)
}
})

View file

@ -4,6 +4,7 @@ import ImageAttributionSource from "./ImageAttributionSource";
import BaseUIElement from "../../UI/BaseUIElement";
import {UIEventSource} from "../UIEventSource";
import Svg from "../../Svg";
import {Utils} from "../../Utils";
export class Mapillary extends ImageAttributionSource {
@ -43,7 +44,7 @@ export class Mapillary extends ImageAttributionSource {
const key = Mapillary.ExtractKeyFromURL(url)
const metadataURL = `https://a.mapillary.com/v3/images/${key}?client_id=TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2`
const source = new UIEventSource<LicenseInfo>(undefined)
$.getJSON(metadataURL, function (data) {
Utils.downloadJson(metadataURL).then(data => {
const license = new LicenseInfo();
license.artist = data.properties?.username;
license.licenseShortName = "CC BY-SA 4.0";

View file

@ -1,4 +1,3 @@
import * as $ from "jquery"
import ImageAttributionSource from "./ImageAttributionSource";
import BaseUIElement from "../../UI/BaseUIElement";
import Svg from "../../Svg";
@ -43,7 +42,7 @@ export class Wikimedia extends ImageAttributionSource {
}
const self = this;
console.log("Loading a wikimedia category: ", url)
$.getJSON(url, (response) => {
Utils.downloadJson(url).then((response) => {
let imageOverview = new ImagesInCategory();
let members = response.query?.categorymembers;
if (members === undefined) {
@ -78,7 +77,7 @@ export class Wikimedia extends ImageAttributionSource {
static GetWikiData(id: number, handleWikidata: ((Wikidata) => void)) {
const url = "https://www.wikidata.org/wiki/Special:EntityData/Q" + id + ".json";
$.getJSON(url, (response) => {
Utils.downloadJson(url).then (response => {
const entity = response.entities["Q" + id];
const commons = entity.sitelinks.commonswiki;
const wd = new Wikidata();

View file

@ -51,7 +51,7 @@ export default class MetaTagging {
layerFuncs.set(layer.id, this.createRetaggingFunc(layer));
}
allKnownFeatures.addCallbackAndRun(newFeatures => {
allKnownFeatures.addCallbackAndRunD(newFeatures => {
const featuresPerLayer = new Map<string, any[]>();
const allFeatures = Array.from(new Set(features.concat(newFeatures)))

View file

@ -64,6 +64,7 @@ export class Changes implements FeatureSource{
if (elementTags[change.k] !== change.v) {
elementTags[change.k] = change.v;
console.log("Applied ", change.k, "=", change.v)
// We use 'elementTags.id' here, as we might have retrieved with the id 'node/-1' as new point, but should use the rewritten id
this.pending.data.push({elementId: elementTags.id, key: change.k, value: change.v});
}
}
@ -228,9 +229,9 @@ export class Changes implements FeatureSource{
}
neededIds = Utils.Dedup(neededIds);
OsmObject.DownloadAll(neededIds, {}, (knownElements) => {
OsmObject.DownloadAll(neededIds).addCallbackAndRunD(knownElements => {
self.uploadChangesWithLatestVersions(knownElements, newElements, pending)
});
})
}
}

View file

@ -7,6 +7,7 @@ import State from "../../State";
import Locale from "../../UI/i18n/Locale";
import LayoutConfig from "../../Customizations/JSON/LayoutConfig";
import Constants from "../../Models/Constants";
import {OsmObject} from "./OsmObject";
export class ChangesetHandler {
@ -31,6 +32,14 @@ export class ChangesetHandler {
// @ts-ignore
for (const node of nodes) {
const oldId = parseInt(node.attributes.old_id.value);
if (node.attributes.new_id === undefined) {
// We just removed this point!
const element = allElements.getEventSourceById("node/" + oldId);
element.data._deleted = "yes"
element.ping();
continue;
}
const newId = parseInt(node.attributes.new_id.value);
if (oldId !== undefined && newId !== undefined &&
!isNaN(oldId) && !isNaN(newId)) {
@ -47,11 +56,20 @@ export class ChangesetHandler {
}
}
/**
* The full logic to upload a change to one or more elements.
*
* This method will attempt to reuse an existing, open changeset for this theme (or open one if none available).
* Then, it will upload a changes-xml within this changeset (and leave the changeset open)
* When upload is successfull, eventual id-rewriting will be handled (aka: don't worry about that)
*
* If 'dryrun' is specified, the changeset XML will be printed to console instead of being uploaded
*
*/
public UploadChangeset(
layout: LayoutConfig,
allElements: ElementStorage,
generateChangeXML: (csid: string) => string,
continuation: () => void) {
generateChangeXML: (csid: string) => string) {
if (this.userDetails.data.csCount == 0) {
// The user became a contributor!
@ -62,7 +80,6 @@ export class ChangesetHandler {
if (this._dryRun) {
const changesetXML = generateChangeXML("123456");
console.log(changesetXML);
continuation();
return;
}
@ -97,7 +114,7 @@ export class ChangesetHandler {
// Mark the CS as closed...
this.currentChangeset.setData("");
// ... and try again. As the cs is closed, no recursive loop can exist
self.UploadChangeset(layout, allElements, generateChangeXML, continuation);
self.UploadChangeset(layout, allElements, generateChangeXML);
}
)
@ -105,7 +122,60 @@ export class ChangesetHandler {
}
}
public CloseChangeset(changesetId: string = undefined, continuation: (() => void) = () => {
/**
* Deletes the element with the given ID from the OSM database.
* DOES NOT PERFORM ANY SAFETY CHECKS!
*
* For the deletion of an element, a new, seperate changeset is created with a slightly changed comment and some extra flags set.
* The CS will be closed afterwards.
*
* If dryrun is specified, will not actually delete the point but print the CS-XML to console instead
*
*/
public DeleteElement(object: OsmObject,
layout: LayoutConfig,
reason: string,
allElements: ElementStorage,
continuation: () => void) {
function generateChangeXML(csId: string) {
let [lat, lon] = object.centerpoint();
let changes = `<osmChange version='0.6' generator='Mapcomplete ${Constants.vNumber}'>`;
changes +=
`<delete><${object.type} id="${object.id}" version="${object.version}" changeset="${csId}" lat="${lat}" lon="${lon}" /></delete>`;
changes += "</osmChange>";
continuation()
return changes;
}
if (this._dryRun) {
const changesetXML = generateChangeXML("123456");
console.log(changesetXML);
return;
}
const self = this;
this.OpenChangeset(layout, (csId: string) => {
// The cs is open - let us actually upload!
const changes = generateChangeXML(csId)
self.AddChange(csId, changes, allElements, (csId) => {
console.log("Successfully deleted ", object.id)
self.CloseChangeset(csId, continuation)
}, (csId) => {
alert("Deletion failed... Should not happend")
// FAILED
self.CloseChangeset(csId, continuation)
})
}, true, reason)
}
private CloseChangeset(changesetId: string = undefined, continuation: (() => void) = () => {
}) {
if (changesetId === undefined) {
changesetId = this.currentChangeset.data;
@ -133,15 +203,25 @@ export class ChangesetHandler {
private OpenChangeset(
layout: LayoutConfig,
continuation: (changesetId: string) => void) {
continuation: (changesetId: string) => void,
isDeletionCS: boolean = false,
deletionReason: string = undefined) {
const commentExtra = layout.changesetmessage !== undefined ? " - " + layout.changesetmessage : "";
let comment = `Adding data with #MapComplete for theme #${layout.id}${commentExtra}`
if (isDeletionCS) {
comment = `Deleting a point with #MapComplete for theme #${layout.id}${commentExtra}`
if (deletionReason) {
comment += ": " + deletionReason;
}
}
let path = window.location.pathname;
path = path.substr(1, path.lastIndexOf("/"));
const metadata = [
["created_by", `MapComplete ${Constants.vNumber}`],
["comment", `Adding data with #MapComplete for theme #${layout.id}${commentExtra}`],
["comment", comment],
["deletion", isDeletionCS ? "yes" : undefined],
["theme", layout.id],
["language", Locale.language.data],
["host", window.location.host],
@ -172,11 +252,21 @@ export class ChangesetHandler {
});
}
/**
* Upload a changesetXML
* @param changesetId
* @param changesetXML
* @param allElements
* @param continuation
* @param onFail
* @constructor
* @private
*/
private AddChange(changesetId: string,
changesetXML: string,
allElements: ElementStorage,
continuation: ((changesetId: string, idMapping: any) => void),
onFail: ((changesetId: string) => void) = undefined) {
onFail: ((changesetId: string, reason: string) => void) = undefined) {
this.auth.xhr({
method: 'POST',
options: {header: {'Content-Type': 'text/xml'}},
@ -186,7 +276,7 @@ export class ChangesetHandler {
if (response == null) {
console.log("err", err);
if (onFail) {
onFail(changesetId);
onFail(changesetId, err);
}
return;
}

229
Logic/Osm/DeleteAction.ts Normal file
View file

@ -0,0 +1,229 @@
import {UIEventSource} from "../UIEventSource";
import {Translation} from "../../UI/i18n/Translation";
import Translations from "../../UI/i18n/Translations";
import {OsmObject} from "./OsmObject";
import State from "../../State";
import Constants from "../../Models/Constants";
export default class DeleteAction {
public readonly canBeDeleted: UIEventSource<{ canBeDeleted?: boolean, reason: Translation }>;
public readonly isDeleted = new UIEventSource<boolean>(false);
private readonly _id: string;
private readonly _allowDeletionAtChangesetCount: number;
constructor(id: string, allowDeletionAtChangesetCount?: number) {
this._id = id;
this._allowDeletionAtChangesetCount = allowDeletionAtChangesetCount ?? Number.MAX_VALUE;
this.canBeDeleted = new UIEventSource<{ canBeDeleted?: boolean; reason: Translation }>({
canBeDeleted: undefined,
reason: Translations.t.delete.loading
})
this.CheckDeleteability(false)
}
/**
* Does actually delete the feature; returns the event source 'this.isDeleted'
* If deletion is not allowed, triggers the callback instead
*/
public DoDelete(reason: string, onNotAllowed : () => void): UIEventSource<boolean> {
const isDeleted = this.isDeleted
const self = this;
let deletionStarted = false;
this.canBeDeleted.addCallbackAndRun(
canBeDeleted => {
if (isDeleted.data || deletionStarted) {
// Already deleted...
return;
}
if(canBeDeleted.canBeDeleted === false){
// We aren't allowed to delete
deletionStarted = true;
onNotAllowed();
isDeleted.setData(true);
return;
}
if (!canBeDeleted) {
// We are not allowed to delete (yet), this might change in the future though
return;
}
deletionStarted = true;
OsmObject.DownloadObject(self._id).addCallbackAndRun(obj => {
if (obj === undefined) {
return;
}
State.state.osmConnection.changesetHandler.DeleteElement(
obj,
State.state.layoutToUse.data,
reason,
State.state.allElements,
() => {
isDeleted.setData(true)
}
)
})
}
)
return isDeleted;
}
/**
* Checks if the currently logged in user can delete the current point.
* State is written into this._canBeDeleted
* @constructor
* @private
*/
public CheckDeleteability(useTheInternet: boolean): void {
const t = Translations.t.delete;
const id = this._id;
const state = this.canBeDeleted
if (!id.startsWith("node")) {
this.canBeDeleted.setData({
canBeDeleted: false,
reason: t.isntAPoint
})
return;
}
// Does the currently logged in user have enough experience to delete this point?
const deletingPointsOfOtherAllowed = State.state.osmConnection.userDetails.map(ud => {
if (ud === undefined) {
return undefined;
}
if (!ud.loggedIn) {
return false;
}
return ud.csCount >= Math.min(Constants.userJourney.deletePointsOfOthersUnlock, this._allowDeletionAtChangesetCount);
})
const previousEditors = new UIEventSource<number[]>(undefined)
const allByMyself = previousEditors.map(previous => {
if (previous === null || previous === undefined) {
// Not yet downloaded
return null;
}
const userId = State.state.osmConnection.userDetails.data.uid;
return !previous.some(editor => editor !== userId)
}, [State.state.osmConnection.userDetails])
// User allowed OR only edited by self?
const deletetionAllowed = deletingPointsOfOtherAllowed.map(isAllowed => {
if (isAllowed === undefined) {
// No logged in user => definitively not allowed to delete!
return false;
}
if (isAllowed === true) {
return true;
}
// At this point, the logged in user is not allowed to delete points created/edited by _others_
// however, we query OSM and if it turns out the current point has only be edited by the current user, deletion is allowed after all!
if (allByMyself.data === null && useTheInternet) {
// We kickoff the download here as it hasn't yet been downloaded. Note that this is mapped onto 'all by myself' above
OsmObject.DownloadHistory(id).map(versions => versions.map(version => version.tags["_last_edit:contributor:uid"])).syncWith(previousEditors)
}
if (allByMyself.data === true) {
// Yay! We can download!
return true;
}
if (allByMyself.data === false) {
// Nope, downloading not allowed...
return false;
}
// At this point, we don't have enough information yet to decide if the user is allowed to delete the current point...
return undefined;
}, [allByMyself])
const hasRelations: UIEventSource<boolean> = new UIEventSource<boolean>(null)
const hasWays: UIEventSource<boolean> = new UIEventSource<boolean>(null)
deletetionAllowed.addCallbackAndRunD(deletetionAllowed => {
if (deletetionAllowed === false) {
// Nope, we are not allowed to delete
state.setData({
canBeDeleted: false,
reason: t.notEnoughExperience
})
return;
}
if (!useTheInternet) {
return;
}
// All right! We have arrived at a point that we should query OSM again to check that the point isn't a part of ways or relations
OsmObject.DownloadReferencingRelations(id).addCallbackAndRunD(rels => {
hasRelations.setData(rels.length > 0)
})
OsmObject.DownloadReferencingWays(id).addCallbackAndRunD(ways => {
hasWays.setData(ways.length > 0)
})
})
const hasWaysOrRelations = hasRelations.map(hasRelationsData => {
if (hasRelationsData === true) {
return true;
}
if (hasWays.data === true) {
return true;
}
if (hasWays.data === null || hasRelationsData === null) {
return null;
}
if (hasWays.data === false && hasRelationsData === false) {
return false;
}
return null;
}, [hasWays])
hasWaysOrRelations.addCallbackAndRun(
waysOrRelations => {
if (waysOrRelations == null) {
// Not yet loaded - we still wait a little bit
return;
}
if (waysOrRelations) {
// not deleteble by mapcomplete
state.setData({
canBeDeleted: false,
reason: t.partOfOthers
})
}else{
// alright, this point can be safely deleted!
state.setData({
canBeDeleted: true,
reason: allByMyself.data === true ? t.onlyEditedByLoggedInUser : t.safeDelete
})
}
}
)
}
}

View file

@ -1,5 +1,6 @@
import $ from "jquery"
import State from "../../State";
import {Utils} from "../../Utils";
export class Geocoding {
private static readonly host = "https://nominatim.openstreetmap.org/search?";
@ -9,17 +10,12 @@ export class Geocoding {
osm_type: string, osm_id: string}[]) => void),
onFail: (() => void)) {
const b = State.state.leafletMap.data.getBounds();
console.log(b);
$.getJSON(
Geocoding.host + "format=json&limit=1&viewbox=" +
const url = Geocoding.host + "format=json&limit=1&viewbox=" +
`${b.getEast()},${b.getNorth()},${b.getWest()},${b.getSouth()}`+
"&accept-language=nl&q=" + query,
function (data) {
handleResult(data);
}).fail(() => {
onFail();
});
"&accept-language=nl&q=" + query;
Utils.downloadJson(
url)
.then(handleResult)
.catch(onFail);
}
}

View file

@ -8,17 +8,24 @@ import Svg from "../../Svg";
import LayoutConfig from "../../Customizations/JSON/LayoutConfig";
import Img from "../../UI/Base/Img";
import {Utils} from "../../Utils";
import {OsmObject} from "./OsmObject";
export default class UserDetails {
public loggedIn = false;
public name = "Not logged in";
public uid: number;
public csCount = 0;
public img: string;
public unreadMessages = 0;
public totalMessages = 0;
public dryRun: boolean;
home: { lon: number; lat: number };
public backend: string;
constructor(backend: string) {
this.backend = backend;
}
}
export class OsmConnection {
@ -61,9 +68,10 @@ export class OsmConnection {
this._singlePage = singlePage;
this._oauth_config = OsmConnection._oauth_configs[osmConfiguration] ?? OsmConnection._oauth_configs.osm;
console.debug("Using backend", this._oauth_config.url)
OsmObject.SetBackendUrl(this._oauth_config.url + "/")
this._iframeMode = Utils.runningFromConsole ? false : window !== window.top;
this.userDetails = new UIEventSource<UserDetails>(new UserDetails(), "userDetails");
this.userDetails = new UIEventSource<UserDetails>(new UserDetails(this._oauth_config.url), "userDetails");
this.userDetails.data.dryRun = dryRun;
const self =this;
this.isLoggedIn = this.userDetails.map(user => user.loggedIn).addCallback(isLoggedIn => {
@ -102,10 +110,8 @@ export class OsmConnection {
public UploadChangeset(
layout: LayoutConfig,
allElements: ElementStorage,
generateChangeXML: (csid: string) => string,
continuation: () => void = () => {
}) {
this.changesetHandler.UploadChangeset(layout, allElements, generateChangeXML, continuation);
generateChangeXML: (csid: string) => string) {
this.changesetHandler.UploadChangeset(layout, allElements, generateChangeXML);
}
public GetPreference(key: string, prefix: string = "mapcomplete-"): UIEventSource<string> {
@ -167,6 +173,7 @@ export class OsmConnection {
data.loggedIn = true;
console.log("Login completed, userinfo is ", userInfo);
data.name = userInfo.getAttribute('display_name');
data.uid= Number(userInfo.getAttribute("id"))
data.csCount = userInfo.getElementsByTagName("changesets")[0].getAttribute("count");
data.img = undefined;

View file

@ -1,10 +1,16 @@
import * as $ from "jquery"
import {Utils} from "../../Utils";
import * as polygon_features from "../../assets/polygon-features.json";
import {UIEventSource} from "../UIEventSource";
export abstract class OsmObject {
protected static backendURL = "https://www.openstreetmap.org/"
private static polygonFeatures = OsmObject.constructPolygonFeatures()
private static objectCache = new Map<string, UIEventSource<OsmObject>>();
private static referencingWaysCache = new Map<string, UIEventSource<OsmWay[]>>();
private static referencingRelationsCache = new Map<string, UIEventSource<OsmRelation[]>>();
private static historyCache = new Map<string, UIEventSource<OsmObject[]>>();
type: string;
id: number;
tags: {} = {};
@ -12,9 +18,6 @@ export abstract class OsmObject {
public changed: boolean = false;
timestamp: Date;
private static polygonFeatures = OsmObject.constructPolygonFeatures()
protected constructor(type: string, id: number) {
this.id = id;
this.type = type;
@ -23,31 +26,99 @@ export abstract class OsmObject {
}
}
static DownloadObject(id, continuation: ((element: OsmObject, meta: OsmObjectMeta) => void)) {
public static SetBackendUrl(url: string) {
if (!url.endsWith("/")) {
throw "Backend URL must end with a '/'"
}
if (!url.startsWith("http")) {
throw "Backend URL must begin with http"
}
this.backendURL = url;
}
static DownloadObject(id): UIEventSource<OsmObject> {
if (OsmObject.objectCache.has(id)) {
return OsmObject.objectCache.get(id)
}
const splitted = id.split("/");
const type = splitted[0];
const idN = splitted[1];
const newContinuation = (element: OsmObject, meta: OsmObjectMeta) => {
continuation(element, meta);
const src = new UIEventSource<OsmObject>(undefined)
OsmObject.objectCache.set(id, src);
const newContinuation = (element: OsmObject) => {
src.setData(element)
}
switch (type) {
case("node"):
return new OsmNode(idN).Download(newContinuation);
new OsmNode(idN).Download(newContinuation);
break;
case("way"):
return new OsmWay(idN).Download(newContinuation);
new OsmWay(idN).Download(newContinuation);
break;
case("relation"):
return new OsmRelation(idN).Download(newContinuation);
new OsmRelation(idN).Download(newContinuation);
break;
}
return src;
}
public static DownloadHistory(id: string, continuation: (versions: OsmObject[]) => void): void {
/**
* Downloads the ways that are using this node.
* Beware: their geometry will be incomplete!
*/
public static DownloadReferencingWays(id: string): UIEventSource<OsmWay[]> {
if (OsmObject.referencingWaysCache.has(id)) {
return OsmObject.referencingWaysCache.get(id);
}
const waysSrc = new UIEventSource<OsmWay[]>([])
OsmObject.referencingWaysCache.set(id, waysSrc);
Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${id}/ways`)
.then(data => {
const ways = data.elements.map(wayInfo => {
const way = new OsmWay(wayInfo.id)
way.LoadData(wayInfo)
return way
})
waysSrc.setData(ways)
})
return waysSrc;
}
/**
* Downloads the relations that are using this feature.
* Beware: their geometry will be incomplete!
*/
public static DownloadReferencingRelations(id: string): UIEventSource<OsmRelation[]> {
if (OsmObject.referencingRelationsCache.has(id)) {
return OsmObject.referencingRelationsCache.get(id);
}
const relsSrc = new UIEventSource<OsmRelation[]>([])
OsmObject.referencingRelationsCache.set(id, relsSrc);
Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${id}/relations`)
.then(data => {
const rels = data.elements.map(wayInfo => {
const rel = new OsmRelation(wayInfo.id)
rel.LoadData(wayInfo)
return rel
})
relsSrc.setData(rels)
})
return relsSrc;
}
public static DownloadHistory(id: string): UIEventSource<OsmObject []> {
if (OsmObject.historyCache.has(id)) {
return OsmObject.historyCache.get(id)
}
const splitted = id.split("/");
const type = splitted[0];
const idN = splitted[1];
$.getJSON("https://openStreetMap.org/api/0.6/" + type + "/" + idN + "/history", data => {
const src = new UIEventSource<OsmObject[]>([]);
OsmObject.historyCache.set(id, src);
Utils.downloadJson(`${OsmObject.backendURL}api/0.6/${type}/${idN}/history`).then(data => {
const elements: any[] = data.elements;
const osmObjects: OsmObject[] = []
for (const element of elements) {
@ -67,30 +138,42 @@ export abstract class OsmObject {
osmObject?.SaveExtraData(element, []);
osmObjects.push(osmObject)
}
continuation(osmObjects)
src.setData(osmObjects)
})
return src;
}
// bounds should be: [[maxlat, minlon], [minlat, maxlon]] (same as Utils.tile_bounds)
public static LoadArea(bounds: [[number, number], [number, number]], callback: (objects: OsmObject[]) => void) {
const minlon = bounds[0][1]
const maxlon = bounds[1][1]
const minlat = bounds[1][0]
const maxlat = bounds[0][0];
const url = `${OsmObject.backendURL}api/0.6/map.json?bbox=${minlon},${minlat},${maxlon},${maxlat}`
Utils.downloadJson(url).then( data => {
const elements: any[] = data.elements;
const objects = OsmObject.ParseObjects(elements)
callback(objects);
})
}
private static constructPolygonFeatures(): Map<string, { values: Set<string>, blacklist: boolean }> {
const result = new Map<string, { values: Set<string>, blacklist: boolean }>();
public static DownloadAll(neededIds): UIEventSource<OsmObject[]> {
// local function which downloads all the objects one by one
// this is one big loop, running one download, then rerunning the entire function
for (const polygonFeature of polygon_features) {
const key = polygonFeature.key;
if (polygonFeature.polygon === "all") {
result.set(key, {values: null, blacklist: false})
continue
const allSources: UIEventSource<OsmObject> [] = neededIds.map(id => OsmObject.DownloadObject(id))
const allCompleted = new UIEventSource(undefined).map(_ => {
return !allSources.some(uiEventSource => uiEventSource.data === undefined)
}, allSources)
return allCompleted.map(completed => {
if (completed) {
return allSources.map(src => src.data)
}
const blacklist = polygonFeature.polygon === "blacklist"
result.set(key, {values: new Set<string>(polygonFeature.values), blacklist: blacklist})
}
return result;
return []
});
}
protected static isPolygon(tags: any): boolean {
for (const tagsKey in tags) {
if (!tags.hasOwnProperty(tagsKey)) {
@ -109,43 +192,23 @@ export abstract class OsmObject {
}
}
// bounds should be: [[maxlat, minlon], [minlat, maxlon]] (same as Utils.tile_bounds)
public static LoadArea(bounds: [[number, number], [number, number]], callback: (objects: OsmObject[]) => void) {
const minlon = bounds[0][1]
const maxlon = bounds[1][1]
const minlat = bounds[1][0]
const maxlat = bounds[0][0];
const url = `https://www.openstreetmap.org/api/0.6/map.json?bbox=${minlon},${minlat},${maxlon},${maxlat}`
$.getJSON(url, data => {
const elements: any[] = data.elements;
const objects = OsmObject.ParseObjects(elements)
callback(objects);
private static constructPolygonFeatures(): Map<string, { values: Set<string>, blacklist: boolean }> {
const result = new Map<string, { values: Set<string>, blacklist: boolean }>();
})
}
for (const polygonFeature of polygon_features) {
const key = polygonFeature.key;
//Loads an area from the OSM-api.
public static DownloadAll(neededIds, knownElements: any = {}, continuation: ((knownObjects: any) => void)) {
// local function which downloads all the objects one by one
// this is one big loop, running one download, then rerunning the entire function
if (neededIds.length == 0) {
continuation(knownElements);
return;
}
const neededId = neededIds.pop();
if (neededId in knownElements) {
OsmObject.DownloadAll(neededIds, knownElements, continuation);
return;
}
OsmObject.DownloadObject(neededId,
function (element) {
knownElements[neededId] = element; // assign the element for later, continue downloading the next element
OsmObject.DownloadAll(neededIds, knownElements, continuation);
if (polygonFeature.polygon === "all") {
result.set(key, {values: null, blacklist: false})
continue
}
);
const blacklist = polygonFeature.polygon === "blacklist"
result.set(key, {values: new Set<string>(polygonFeature.values), blacklist: blacklist})
}
return result;
}
private static ParseObjects(elements: any[]): OsmObject[] {
@ -209,8 +272,8 @@ export abstract class OsmObject {
Download(continuation: ((element: OsmObject, meta: OsmObjectMeta) => void)) {
const self = this;
const full = this.type !== "way" ? "" : "/full";
const url = "https://www.openstreetmap.org/api/0.6/" + this.type + "/" + this.id + full;
$.getJSON(url, function (data) {
const url = `${OsmObject.backendURL}api/0.6/${this.type}/${this.id}${full}`;
Utils.downloadJson(url).then(data => {
const element = data.elements.pop();

View file

@ -1,8 +1,8 @@
import * as $ from "jquery"
import * as OsmToGeoJson from "osmtogeojson";
import Bounds from "../../Models/Bounds";
import {TagsFilter} from "../Tags/TagsFilter";
import ExtractRelations from "./ExtractRelations";
import {Utils} from "../../Utils";
/**
* Interfaces overpass to get all the latest data
@ -27,14 +27,8 @@ export class Overpass {
console.log("Using testing URL")
query = Overpass.testUrl;
}
$.getJSON(query,
function (json, status) {
if (status !== "success") {
console.log("Query failed")
onFail(status);
return;
}
Utils.downloadJson(query)
.then(json => {
if (json.elements === [] && json.remarks.indexOf("runtime error") > 0) {
console.log("Timeout or other runtime error");
onFail("Runtime error (timeout)")
@ -47,8 +41,7 @@ export class Overpass {
const osmTime = new Date(json.osm3s.timestamp_osm_base);
continuation(geojson, osmTime);
}).fail(onFail)
}).catch(onFail)
}
buildQuery(bbox: string): string {

View file

@ -162,7 +162,7 @@ export default class SimpleMetaTagger {
}
const tagsSource = State.state.allElements.getEventSourceById(feature.properties.id);
tagsSource.addCallbackAndRun(tags => {
tagsSource.addCallbackAndRunD(tags => {
if (tags.opening_hours === undefined || tags._country === undefined) {
return;
}

View file

@ -51,7 +51,7 @@ export class Tag extends TagsFilter {
return new Tag(this.key, TagUtils.ApplyTemplate(this.value as string, tags));
}
asHumanString(linkToWiki: boolean, shorten: boolean) {
asHumanString(linkToWiki?: boolean, shorten?: boolean) {
let v = this.value;
if (shorten) {
v = Utils.EllipsesAfter(v, 25);

View file

@ -159,4 +159,11 @@ export class UIEventSource<T> {
return newSource;
}
addCallbackAndRunD(callback: (data :T ) => void) {
this.addCallbackAndRun(data => {
if(data !== undefined && data !== null){
callback(data)
}
})
}
}

View file

@ -1,6 +1,6 @@
import {UIEventSource} from "../UIEventSource";
import * as $ from "jquery"
import {Utils} from "../../Utils";
/**
* Fetches data from random data sources, used in the metatagging
*/
@ -25,7 +25,7 @@ export default class LiveQueryHandler {
LiveQueryHandler[url] = source;
console.log("Fetching live data from a third-party (unknown) API:",url)
$.getJSON(url, function (data) {
Utils.downloadJson(url).then(data => {
for (const shorthandDescription of shorthandsSet) {
const descr = shorthandDescription.trim().split(":");

View file

@ -10,8 +10,8 @@ export class MangroveIdentity {
constructor(mangroveIdentity: UIEventSource<string>) {
const self = this;
this._mangroveIdentity = mangroveIdentity;
mangroveIdentity.addCallbackAndRun(str => {
if (str === undefined || str === "") {
mangroveIdentity.addCallbackAndRunD(str => {
if (str === "") {
return;
}
mangrove.jwkToKeypair(JSON.parse(str)).then(keypair => {

View file

@ -2,16 +2,18 @@ import { Utils } from "../Utils";
export default class Constants {
public static vNumber = "0.8.2";
public static vNumber = "0.8.3";
// The user journey states thresholds when a new feature gets unlocked
public static userJourney = {
moreScreenUnlock: 1,
personalLayoutUnlock: 15,
historyLinkVisible: 20,
personalLayoutUnlock: 5,
historyLinkVisible: 10,
deletePointsOfOthersUnlock: 20,
tagsVisibleAt: 25,
mapCompleteHelpUnlock: 50,
tagsVisibleAndWikiLinked: 30,
mapCompleteHelpUnlock: 50,
themeGeneratorReadOnlyUnlock: 50,
themeGeneratorFullUnlock: 500,
addNewPointWithUnreadMessagesUnlock: 500,

7
UI/Base/Loading.ts Normal file
View file

@ -0,0 +1,7 @@
import {FixedUiElement} from "./FixedUiElement";
export default class Loading extends FixedUiElement {
constructor() {
super("Loading..."); // TODO to be improved
}
}

View file

@ -65,8 +65,8 @@ export default class AttributionPanel extends Combine {
...Utils.NoNull(Array.from(layoutToUse.data.ExtractImages()))
.map(AttributionPanel.IconAttribution)
]);
this.SetClass("flex flex-col link-underline")
this.SetStyle("max-width: calc(100vw - 5em); width: 40em;")
this.SetClass("flex flex-col link-underline overflow-hidden")
this.SetStyle("max-width: calc(100vw - 5em); width: 40rem;")
}
private static CodeContributors(): BaseUIElement {
@ -105,7 +105,7 @@ export default class AttributionPanel extends Combine {
const sources = Utils.NoNull(Utils.NoEmpty(license.sources))
return new Combine([
`<img src='${iconPath}' style="width: 50px; height: 50px; margin-right: 0.5em;">`,
`<img src='${iconPath}' style="width: 50px; height: 50px; min-width: 50px; min-height: 50px; margin-right: 0.5em;">`,
new Combine([
new FixedUiElement(license.authors.join("; ")).SetClass("font-bold"),
new Combine([license.license,
@ -120,9 +120,10 @@ export default class AttributionPanel extends Combine {
return new Link(sourceLinkContent, lnk, true);
})
]
).SetClass("block")
]).SetClass("flex flex-col")
]).SetClass("flex")
).SetClass("block m-2")
]).SetClass("flex flex-col").SetStyle("width: calc(100% - 50px - 0.5em); min-width: 12rem;")
]).SetClass("flex flex-wrap border-b border-gray-300 m-2 border-box")
}
private static GenerateLicenses() {

View file

@ -26,12 +26,6 @@ export class Basemap {
attributionControl: extraAttribution !== undefined
});
L.control.scale(
{
position: 'topright',
}
).addTo(this.map)
// Users are not allowed to zoom to the 'copies' on the left and the right, stuff goes wrong then
// We give a bit of leeway for people on the edges

View file

@ -41,7 +41,8 @@ export default class SimpleAddUI extends Toggle {
constructor(isShown: UIEventSource<boolean>) {
const loginButton = Translations.t.general.add.pleaseLogin.Clone().onClick(State.state.osmConnection.AttemptLogin);
const loginButton = new SubtleButton(Svg.osm_logo_ui(), Translations.t.general.add.pleaseLogin.Clone())
.onClick(() => State.state.osmConnection.AttemptLogin());
const readYourMessages = new Combine([
Translations.t.general.readYourMessages.Clone().SetClass("alert"),
new SubtleButton(Svg.envelope_ui(),

View file

@ -1,6 +1,3 @@
/**
* Handles and updates the user badge
*/
import {VariableUiElement} from "../Base/VariableUIElement";
import Svg from "../../Svg";
import State from "../../State";
@ -21,7 +18,7 @@ export default class UserBadge extends Toggle {
const loginButton = Translations.t.general.loginWithOpenStreetMap
.Clone()
.SetClass("userbadge-login pt-3 w-full")
.SetClass("userbadge-login pt-3 w-full h-full")
.onClick(() => State.state.osmConnection.AttemptLogin());
@ -32,7 +29,7 @@ export default class UserBadge extends Toggle {
});
const userBadge = userDetails.map(user => {
const userBadge = new VariableUiElement(userDetails.map(user => {
{
const homeButton = new VariableUiElement(
userDetails.map((userinfo) => {
@ -56,7 +53,7 @@ export default class UserBadge extends Toggle {
let messageSpan =
new Link(
new Combine([Svg.envelope, "" + user.totalMessages]).SetClass(linkStyle),
'https://www.openstreetmap.org/messages/inbox',
`${user.backend}/messages/inbox`,
true
)
@ -64,41 +61,32 @@ export default class UserBadge extends Toggle {
const csCount =
new Link(
new Combine([Svg.star, "" + user.csCount]).SetClass(linkStyle),
`https://www.openstreetmap.org/user/${user.name}/history`,
`${user.backend}/user/${user.name}/history`,
true);
if (user.unreadMessages > 0) {
messageSpan = new Link(
new Combine([Svg.envelope, "" + user.unreadMessages]),
'https://www.openstreetmap.org/messages/inbox',
'${user.backend}/messages/inbox',
true
).SetClass("alert")
}
let dryrun = new FixedUiElement("");
if (user.dryRun) {
dryrun = new FixedUiElement("TESTING").SetClass("alert");
dryrun = new FixedUiElement("TESTING").SetClass("alert font-xs p-0 max-h-4");
}
const settings =
new Link(Svg.gear_svg(),
`https://www.openstreetmap.org/user/${encodeURIComponent(user.name)}/account`,
`${user.backend}/user/${encodeURIComponent(user.name)}/account`,
true)
const userIcon = new Link(
new Img(user.img)
.SetClass("rounded-full opacity-0 m-0 p-0 duration-500 w-16 h16 float-left")
,
`https://www.openstreetmap.org/user/${encodeURIComponent(user.name)}`,
true
);
const userName = new Link(
new FixedUiElement(user.name),
`https://www.openstreetmap.org/user/${user.name}`,
`${user.backend}/user/${user.name}`,
true);
@ -113,24 +101,40 @@ export default class UserBadge extends Toggle {
.SetClass("userstats")
const usertext = new Combine([
userName,
dryrun,
new Combine([userName, dryrun]).SetClass("flex justify-end w-full"),
userStats
]).SetClass("usertext")
]).SetClass("flex flex-col sm:w-auto sm:pl-2 overflow-hidden w-0")
const userIcon =
(user.img === undefined ? Svg.osm_logo_ui() : new Img(user.img)).SetClass("rounded-full opacity-0 m-0 p-0 duration-500 w-16 min-width-16 h16 float-left")
.onClick(() => {
if(usertext.HasClass("w-0")){
usertext.RemoveClass("w-0")
usertext.SetClass("w-min pl-2")
}else{
usertext.RemoveClass("w-min")
usertext.RemoveClass("pl-2")
usertext.SetClass("w-0")
}
})
return new Combine([
userIcon,
usertext,
]).SetClass("h-16")
userIcon,
]).SetClass("h-16 flex bg-white")
}
});
}));
userBadge.SetClass("inline-block m-0 w-full").SetStyle("pointer-events: all")
super(
new VariableUiElement(userBadge),
userBadge,
loginButton,
State.state.osmConnection.isLoggedIn
)
this.SetClass("shadow rounded-full h-min overflow-hidden block w-max")
}

View file

@ -57,10 +57,7 @@ export default class CheckBoxes extends InputElement<number[]> {
wrapper.appendChild(label)
el.appendChild(wrapper)
value.addCallbackAndRun(selectedValues => {
if (selectedValues === undefined) {
return;
}
value.addCallbackAndRunD(selectedValues => {
if (selectedValues.indexOf(i) >= 0) {
input.checked = true;
}

View file

@ -16,10 +16,7 @@ private readonly _element : HTMLElement
el.type = "color"
this.value.addCallbackAndRun(v => {
if(v === undefined){
return;
}
this.value.addCallbackAndRunD(v => {
el.value =v
});

View file

@ -59,7 +59,7 @@ export default class DirectionInput extends InputElement<string> {
.ConstructElement()
this.value.addCallbackAndRun(rotation => {
this.value.addCallbackAndRunD(rotation => {
const cone = element.getElementsByClassName("direction-svg")[0] as HTMLElement
cone.style.transform = `rotate(${rotation}deg)`;

View file

@ -23,10 +23,7 @@ export default class SimpleDatePicker extends InputElement<string> {
}
this.value.addCallbackAndRun(v => {
if(v === undefined){
return;
}
this.value.addCallbackAndRunD(v => {
el.value = v;
});

View file

@ -64,11 +64,8 @@ export class TextField extends InputElement<string> {
const field = inputEl;
this.value.addCallbackAndRun(value => {
if (value === undefined || value === null) {
// We leave the textfield as is - make sure we do not erase it!
return;
}
this.value.addCallbackAndRunD(value => {
// We leave the textfield as is in the case of undefined or null (handled by addCallbackAndRunD) - make sure we do not erase it!
field["value"] = value;
if (self.IsValid(value)) {
self.RemoveClass("invalid")

View file

@ -17,7 +17,7 @@ export default class PublicHolidayInput extends InputElement<string> {
this._value = value;
}
GetValue(): UIEventSource<string> {
return this._value;
}
@ -25,18 +25,56 @@ export default class PublicHolidayInput extends InputElement<string> {
IsValid(t: string): boolean {
return true;
}
protected InnerConstructElement(): HTMLElement {
const dropdown = new DropDown(
Translations.t.general.opening_hours.open_during_ph.Clone(),
[
{shown: Translations.t.general.opening_hours.ph_not_known.Clone(), value: ""},
{shown: Translations.t.general.opening_hours.ph_closed.Clone(), value: "off"},
{shown: Translations.t.general.opening_hours.ph_open_as_usual.Clone(), value: "open"},
{shown: Translations.t.general.opening_hours.ph_open.Clone(), value: " "},
]
).SetClass("inline-block");
/*
* Either "" (unknown), " " (opened) or "off" (closed)
* */
const mode = dropdown.GetValue();
const start = new TextField({
placeholder: "starthour",
htmlType: "time"
}).SetClass("inline-block");
const end = new TextField({
placeholder: "starthour",
htmlType: "time"
}).SetClass("inline-block");
const askHours = new Toggle(
new Combine([
Translations.t.general.opening_hours.opensAt.Clone(),
start,
Translations.t.general.opening_hours.openTill.Clone(),
end
]),
undefined,
mode.map(mode => mode === " ")
)
this.SetupDataSync(mode, start.GetValue(), end.GetValue())
return new Combine([
dropdown,
askHours
]).ConstructElement()
}
private SetupDataSync(mode: UIEventSource<string>, startTime: UIEventSource<string>, endTime: UIEventSource<string>) {
const value = this._value;
value.addCallbackAndRun(ph => {
if (ph === undefined) {
return;
}
const parsed = OH.ParsePHRule(ph);
if (parsed === null) {
return;
}
value.map(ph => OH.ParsePHRule(ph))
.addCallbackAndRunD(parsed => {
mode.setData(parsed.mode)
startTime.setData(parsed.start)
endTime.setData(parsed.end)
@ -72,50 +110,5 @@ export default class PublicHolidayInput extends InputElement<string> {
}, [startTime, endTime]
)
}
protected InnerConstructElement(): HTMLElement {
const dropdown = new DropDown(
Translations.t.general.opening_hours.open_during_ph.Clone(),
[
{shown: Translations.t.general.opening_hours.ph_not_known.Clone(), value: ""},
{shown: Translations.t.general.opening_hours.ph_closed.Clone(), value: "off"},
{shown:Translations.t.general.opening_hours.ph_open_as_usual.Clone(), value: "open"},
{shown: Translations.t.general.opening_hours.ph_open.Clone(), value: " "},
]
).SetClass("inline-block");
/*
* Either "" (unknown), " " (opened) or "off" (closed)
* */
const mode = dropdown.GetValue();
const start = new TextField({
placeholder: "starthour",
htmlType: "time"
}).SetClass("inline-block");
const end = new TextField({
placeholder: "starthour",
htmlType: "time"
}).SetClass("inline-block");
const askHours = new Toggle(
new Combine([
Translations.t.general.opening_hours.opensAt.Clone(),
start,
Translations.t.general.opening_hours.openTill.Clone(),
end
]),
undefined,
mode.map(mode => mode === " ")
)
this.SetupDataSync(mode, start.GetValue(), end.GetValue())
return new Combine([
dropdown,
askHours
]).ConstructElement()
}
}

263
UI/Popup/DeleteWizard.ts Normal file
View file

@ -0,0 +1,263 @@
import {VariableUiElement} from "../Base/VariableUIElement";
import State from "../../State";
import Toggle from "../Input/Toggle";
import Translations from "../i18n/Translations";
import Svg from "../../Svg";
import DeleteAction from "../../Logic/Osm/DeleteAction";
import {Tag} from "../../Logic/Tags/Tag";
import {UIEventSource} from "../../Logic/UIEventSource";
import {TagsFilter} from "../../Logic/Tags/TagsFilter";
import TagRenderingQuestion from "./TagRenderingQuestion";
import TagRenderingConfig from "../../Customizations/JSON/TagRenderingConfig";
import Combine from "../Base/Combine";
import {SubtleButton} from "../Base/SubtleButton";
import {FixedUiElement} from "../Base/FixedUiElement";
import {Translation} from "../i18n/Translation";
import {AndOrTagConfigJson} from "../../Customizations/JSON/TagConfigJson";
import BaseUIElement from "../BaseUIElement";
import {Changes} from "../../Logic/Osm/Changes";
import {And} from "../../Logic/Tags/And";
import Constants from "../../Models/Constants";
import DeleteConfig from "../../Customizations/JSON/DeleteConfig";
export default class DeleteWizard extends Toggle {
/**
* The UI-element which triggers 'deletion' (either soft or hard).
*
* - A 'hard deletion' is if the point is actually deleted from the OSM database
* - A 'soft deletion' is if the point is not deleted, but the tagging is modified which will result in the point not being picked up by the filters anymore.
* Apart having needing theme-specific tags added (which must be supplied by the theme creator), fixme='marked for deletion' will be added too
*
* A deletion is only possible if the user is logged in.
* A soft deletion is only possible if tags are provided
* A hard deletion is only possible if the user has sufficient rigts
*
* There is also the possibility to have a 'trojan horse' option. If the user selects that option, it is NEVER removed, but the tags are applied.
* Ideal for the case of "THIS PATH IS ON MY GROUND AND SHOULD BE DELETED IMMEDIATELY OR I WILL GET MY LAWYER" but to mark it as private instead.
* (Note that _delete_reason is used as trigger to do actual deletion - setting such a tag WILL delete from the database with that as changeset comment)
*
* @param id: The id of the element to remove
* @param options softDeletionTags: the tags to apply if the user doesn't have permission to delete, e.g. 'disused:amenity=public_bookcase', 'amenity='. After applying, the element should not be picked up on the map anymore. If undefined, the wizard will only show up if the point can be (hard) deleted
*/
constructor(id: string,
options: DeleteConfig) {
const deleteAction = new DeleteAction(id, options.neededChangesets);
const tagsSource = State.state.allElements.getEventSourceById(id)
const allowSoftDeletion = !!options.softDeletionTags
const confirm = new UIEventSource<boolean>(false)
function softDelete(reason: string, tagsToApply: { k: string, v: string }[]) {
if (reason !== undefined) {
tagsToApply.splice(0, 0, {
k: "fixme",
v: `A mapcomplete user marked this feature to be deleted (${reason})`
})
}
(State.state?.changes ?? new Changes())
.addTag(id, new And(tagsToApply.map(kv => new Tag(kv.k, kv.v))), tagsSource);
}
function doDelete(selected: TagsFilter) {
const tgs = selected.asChange(tagsSource.data)
const deleteReasonMatch = tgs.filter(kv => kv.k === "_delete_reason")
if (deleteReasonMatch.length > 0) {
// We should actually delete!
const deleteReason = deleteReasonMatch[0].v
deleteAction.DoDelete(deleteReason, () => {
// The user doesn't have sufficient permissions to _actually_ delete the feature
// We 'soft delete' instead (and add a fixme)
softDelete(deleteReason, tgs.filter(kv => kv.k !== "_delete_reason"))
});
return
} else {
// This is a 'non-delete'-option that was selected
softDelete(undefined, tgs)
}
}
const t = Translations.t.delete
const cancelButton = t.cancel.Clone().SetClass("block btn btn-secondary").onClick(() => confirm.setData(false));
const question = new VariableUiElement(tagsSource.map(currentTags => {
const config = DeleteWizard.generateDeleteTagRenderingConfig(options.softDeletionTags, options.nonDeleteMappings, options.extraDeleteReasons, currentTags)
return new TagRenderingQuestion(
tagsSource,
config,
{
cancelButton: cancelButton,
/*Using a custom save button constructor erases all logic to actually save, so we have to listen for the click!*/
saveButtonConstr: (v) => DeleteWizard.constructConfirmButton(v).onClick(() => {
doDelete(v.data)
}),
bottomText: (v) => DeleteWizard.constructExplanation(v, deleteAction)
}
)
}))
/**
* The button which is shown first. Opening it will trigger the check for deletions
*/
const deleteButton = new SubtleButton(
Svg.delete_icon_ui().SetStyle("width: 2rem; height: 2rem;"), t.delete.Clone()).onClick(
() => {
deleteAction.CheckDeleteability(true)
confirm.setData(true);
}
).SetClass("w-1/2 float-right");
const isShown = new UIEventSource<boolean>(id.indexOf("-")< 0)
super(
new Toggle(
new Combine([Svg.delete_icon_svg().SetClass("h-16 w-16 p-2 m-2 block bg-gray-300 rounded-full"),
t.isDeleted.Clone()]).SetClass("flex m-2 rounded-full"),
new Toggle(
new Toggle(
new Toggle(
new Toggle(
question,
new SubtleButton(Svg.envelope_ui(), t.readMessages.Clone()),
State.state.osmConnection.userDetails.map(ud => ud.csCount > Constants.userJourney.addNewPointWithUnreadMessagesUnlock || ud.unreadMessages == 0)
),
deleteButton,
confirm),
new VariableUiElement(deleteAction.canBeDeleted.map(cbd => new Combine([cbd.reason.Clone(), t.useSomethingElse.Clone()]))),
deleteAction.canBeDeleted.map(cbd => allowSoftDeletion || cbd.canBeDeleted !== false)),
t.loginToDelete.Clone().onClick(State.state.osmConnection.AttemptLogin),
State.state.osmConnection.isLoggedIn
),
deleteAction.isDeleted),
undefined,
isShown)
}
private static constructConfirmButton(deleteReasons: UIEventSource<TagsFilter>): BaseUIElement {
const t = Translations.t.delete;
const btn = new Combine([
Svg.delete_icon_ui().SetClass("w-6 h-6 mr-3 block"),
t.delete.Clone()
]).SetClass("flex btn bg-red-500")
const btnNonActive = new Combine([
Svg.delete_icon_ui().SetClass("w-6 h-6 mr-3 block"),
t.delete.Clone()
]).SetClass("flex btn btn-disabled bg-red-200")
return new Toggle(
btn,
btnNonActive,
deleteReasons.map(reason => reason !== undefined)
)
}
private static constructExplanation(tags: UIEventSource<TagsFilter>, deleteAction: DeleteAction) {
const t = Translations.t.delete;
return new VariableUiElement(tags.map(
currentTags => {
const cbd = deleteAction.canBeDeleted.data;
if (currentTags === undefined) {
return t.explanations.selectReason.Clone().SetClass("subtle");
}
const hasDeletionTag = currentTags.asChange(currentTags).some(kv => kv.k === "_delete_reason")
if (cbd.canBeDeleted && hasDeletionTag) {
return t.explanations.hardDelete.Clone()
}
return new Combine([t.explanations.softDelete.Subs({reason: cbd.reason}),
new FixedUiElement(currentTags.asHumanString(false, true, currentTags)).SetClass("subtle")
]).SetClass("flex flex-col")
}
, [deleteAction.canBeDeleted]
)).SetClass("block")
}
private static generateDeleteTagRenderingConfig(softDeletionTags: TagsFilter,
nonDeleteOptions: { if: TagsFilter; then: Translation }[],
extraDeleteReasons: { explanation: Translation; changesetMessage: string }[],
currentTags: any) {
const t = Translations.t.delete
nonDeleteOptions = nonDeleteOptions ?? []
let softDeletionTagsStr = []
if (softDeletionTags !== undefined) {
softDeletionTags.asChange(currentTags)
}
const extraOptionsStr: { if: AndOrTagConfigJson, then: any }[] = []
for (const nonDeleteOption of nonDeleteOptions) {
const newIf: string[] = nonDeleteOption.if.asChange({}).map(kv => kv.k + "=" + kv.v)
extraOptionsStr.push({
if: {and: newIf},
then: nonDeleteOption.then
})
}
for (const extraDeleteReason of (extraDeleteReasons ?? [])) {
extraOptionsStr.push({
if: {and: ["_delete_reason=" + extraDeleteReason.changesetMessage]},
then: extraDeleteReason.explanation
})
}
return new TagRenderingConfig(
{
question: t.whyDelete,
render: "Deleted because {_delete_reason}",
freeform: {
key: "_delete_reason",
addExtraTags: softDeletionTagsStr
},
mappings: [
...extraOptionsStr,
{
if: {
and: [
"_delete_reason=testing point",
...softDeletionTagsStr
]
},
then: t.reasons.test
},
{
if: {
and: [
"_delete_reason=disused",
...softDeletionTagsStr
]
},
then: t.reasons.disused
},
{
if: {
and: [
"_delete_reason=not found",
...softDeletionTagsStr
]
},
then: t.reasons.notFound
}
]
}, undefined, "Delete wizard"
)
}
}

View file

@ -43,11 +43,14 @@ export default class EditableTagRendering extends Toggle {
editMode.setData(false)
});
const question = new TagRenderingQuestion(tags, configuration,units,
() => {
editMode.setData(false)
},
cancelbutton)
const question = new TagRenderingQuestion(tags, configuration,
{
units: units,
cancelButton: cancelbutton,
afterSave: () => {
editMode.setData(false)
}
})
rendering = new Toggle(

View file

@ -12,6 +12,7 @@ import Constants from "../../Models/Constants";
import SharedTagRenderings from "../../Customizations/SharedTagRenderings";
import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import DeleteWizard from "./DeleteWizard";
export default class FeatureInfoBox extends ScrollableFullScreen {
@ -21,7 +22,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
) {
super(() => FeatureInfoBox.GenerateTitleBar(tags, layerConfig),
() => FeatureInfoBox.GenerateContent(tags, layerConfig),
tags.data.id);
undefined);
if (layerConfig === undefined) {
throw "Undefined layerconfig";
@ -70,18 +71,30 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
renderings.push(new TagRenderingAnswer(tags, SharedTagRenderings.SharedTagRendering.get("minimap")))
}
if (layerConfig.deletion) {
renderings.push(
new VariableUiElement(tags.map(tags => tags.id).map(id =>
new DeleteWizard(
id,
layerConfig.deletion
))
))
}
renderings.push(
new VariableUiElement(
State.state.osmConnection.userDetails.map(userdetails => {
if (userdetails.csCount <= Constants.userJourney.historyLinkVisible
&& State.state.featureSwitchIsDebugging.data == false
&& State.state.featureSwitchIsTesting.data === false) {
return undefined
}
State.state.osmConnection.userDetails
.map(ud => ud.csCount)
.map(csCount => {
if (csCount <= Constants.userJourney.historyLinkVisible
&& State.state.featureSwitchIsDebugging.data == false
&& State.state.featureSwitchIsTesting.data === false) {
return undefined
}
return new TagRenderingAnswer(tags, SharedTagRenderings.SharedTagRendering.get("last_edit"));
return new TagRenderingAnswer(tags, SharedTagRenderings.SharedTagRendering.get("last_edit"));
}, [State.state.featureSwitchIsDebugging])
}, [State.state.featureSwitchIsDebugging, State.state.featureSwitchIsTesting])
)
)

View file

@ -27,17 +27,20 @@ export default class QuestionBox extends VariableUiElement {
}
const tagRenderingQuestions = tagRenderings
.map((tagRendering, i) => new TagRenderingQuestion(tagsSource, tagRendering, units,
() => {
// We save
skippedQuestions.ping();
},
Translations.t.general.skip.Clone()
.SetClass("btn btn-secondary mr-3")
.onClick(() => {
skippedQuestions.data.push(i);
.map((tagRendering, i) => new TagRenderingQuestion(tagsSource, tagRendering,
{
units: units,
afterSave: () => {
// We save
skippedQuestions.ping();
})
},
cancelButton: Translations.t.general.skip.Clone()
.SetClass("btn btn-secondary mr-3")
.onClick(() => {
skippedQuestions.data.push(i);
skippedQuestions.ping();
})
}
));
const skippedQuestionsButton = Translations.t.general.skippedQuestions.Clone()

View file

@ -17,16 +17,16 @@ export class SaveButton extends Toggle {
const isSaveable = value.map(v => v !== false && (v ?? "") !== "")
const saveEnabled = Translations.t.general.save.Clone().SetClass(`btn`);
const saveDisabled = Translations.t.general.save.Clone().SetClass(`btn btn-disabled`);
const text = Translations.t.general.save
const saveEnabled = text.Clone().SetClass(`btn`);
const saveDisabled = text.SetClass(`btn btn-disabled`);
const save = new Toggle(
saveEnabled,
saveDisabled,
isSaveable
)
super(
save,
save,
pleaseLogin,
osmConnection?.isLoggedIn ?? new UIEventSource<any>(false)
)

View file

@ -30,23 +30,27 @@ import {Unit} from "../../Customizations/JSON/Denomination";
* Note that the value _migh_ already be known, e.g. when selected or when changing the value
*/
export default class TagRenderingQuestion extends Combine {
constructor(tags: UIEventSource<any>,
configuration: TagRenderingConfig,
units: Unit[],
afterSave?: () => void,
cancelButton?: BaseUIElement
options?: {
units?: Unit[],
afterSave?: () => void,
cancelButton?: BaseUIElement,
saveButtonConstr?: (src: UIEventSource<TagsFilter>) => BaseUIElement,
bottomText?: (src: UIEventSource<TagsFilter>) => BaseUIElement
}
) {
if (configuration === undefined) {
throw "A question is needed for a question visualization"
}
const applicableUnit = (units ?? []).filter(unit => unit.isApplicableToKey(configuration.freeform?.key))[0];
options = options ?? {}
const applicableUnit = (options.units ?? []).filter(unit => unit.isApplicableToKey(configuration.freeform?.key))[0];
const question = new SubstitutedTranslation(configuration.question, tags)
.SetClass("question-text");
const inputElement = TagRenderingQuestion.GenerateInputElement(configuration, applicableUnit, tags)
const inputElement: InputElement<TagsFilter> = TagRenderingQuestion.GenerateInputElement(configuration, applicableUnit, tags)
const save = () => {
const selection = inputElement.GetValue().data;
console.log("Save button clicked, the tags are is", selection)
@ -55,48 +59,54 @@ export default class TagRenderingQuestion extends Combine {
.addTag(tags.data.id, selection, tags);
}
if (afterSave) {
afterSave();
if (options.afterSave) {
options.afterSave();
}
}
if (options.saveButtonConstr === undefined) {
options.saveButtonConstr = v => new SaveButton(v,
State.state?.osmConnection)
.onClick(save)
}
const saveButton = new SaveButton(inputElement.GetValue(),
State.state?.osmConnection)
.onClick(save)
const saveButton = options.saveButtonConstr(inputElement.GetValue())
const appliedTags = new VariableUiElement(
inputElement.GetValue().map(
(tagsFilter: TagsFilter) => {
const csCount = State.state?.osmConnection?.userDetails?.data?.csCount ?? 1000;
if (csCount < Constants.userJourney.tagsVisibleAt) {
return "";
let bottomTags: BaseUIElement;
if (options.bottomText !== undefined) {
bottomTags = options.bottomText(inputElement.GetValue())
} else {
bottomTags = new VariableUiElement(
inputElement.GetValue().map(
(tagsFilter: TagsFilter) => {
const csCount = State.state?.osmConnection?.userDetails?.data?.csCount ?? 1000;
if (csCount < Constants.userJourney.tagsVisibleAt) {
return "";
}
if (tagsFilter === undefined) {
return Translations.t.general.noTagsSelected.Clone().SetClass("subtle");
}
if (csCount < Constants.userJourney.tagsVisibleAndWikiLinked) {
const tagsStr = tagsFilter.asHumanString(false, true, tags.data);
return new FixedUiElement(tagsStr).SetClass("subtle");
}
return tagsFilter.asHumanString(true, true, tags.data);
}
if (tagsFilter === undefined) {
return Translations.t.general.noTagsSelected.Clone().SetClass("subtle");
}
if (csCount < Constants.userJourney.tagsVisibleAndWikiLinked) {
const tagsStr = tagsFilter.asHumanString(false, true, tags.data);
return new FixedUiElement(tagsStr).SetClass("subtle");
}
return tagsFilter.asHumanString(true, true, tags.data);
}
)
).SetClass("block break-all")
super ([
)
).SetClass("block break-all")
}
super([
question,
inputElement,
cancelButton,
inputElement,
options.cancelButton,
saveButton,
appliedTags]
bottomTags]
)
this .SetClass("question")
this.SetClass("question")
}
private static GenerateInputElement(configuration: TagRenderingConfig, applicableUnit: Unit, tagsSource: UIEventSource< any>): InputElement<TagsFilter> {
private static GenerateInputElement(configuration: TagRenderingConfig, applicableUnit: Unit, tagsSource: UIEventSource<any>): InputElement<TagsFilter> {
let inputEls: InputElement<TagsFilter>[];
const mappings = (configuration.mappings ?? [])
@ -105,7 +115,7 @@ export default class TagRenderingQuestion extends Combine {
return false;
}
return !(typeof (mapping.hideInAnswer) !== "boolean" && mapping.hideInAnswer.matchesProperties(tagsSource.data));
})
@ -255,10 +265,10 @@ export default class TagRenderingQuestion extends Combine {
private static GenerateMappingElement(
tagsSource: UIEventSource<any>,
mapping: {
if: TagsFilter,
then: Translation,
hideInAnswer: boolean | TagsFilter
}, ifNot?: TagsFilter[]): InputElement<TagsFilter> {
if: TagsFilter,
then: Translation,
hideInAnswer: boolean | TagsFilter
}, ifNot?: TagsFilter[]): InputElement<TagsFilter> {
let tagging = mapping.if;
if (ifNot.length > 0) {

View file

@ -62,6 +62,9 @@ export default class ShowDataLayer {
const allFeats = features.data.map(ff => ff.feature);
geoLayer = self.CreateGeojsonLayer();
for (const feat of allFeats) {
if (feat === undefined) {
continue
}
// @ts-ignore
geoLayer.addData(feat);
}
@ -76,7 +79,13 @@ export default class ShowDataLayer {
}
if (zoomToFeatures) {
mp.fitBounds(geoLayer.getBounds())
try {
mp.fitBounds(geoLayer.getBounds())
} catch (e) {
console.error(e)
}
}
@ -124,7 +133,6 @@ export default class ShowDataLayer {
})
});
}
private postProcessFeature(feature, leafletLayer: L.Layer) {
const layer: LayerConfig = this._layerDict[feature._matching_layer_id];
if (layer === undefined) {
@ -152,6 +160,7 @@ export default class ShowDataLayer {
leafletLayer.on("popupopen", () => {
State.state.selectedElement.setData(feature)
if (infobox === undefined) {
const tags = State.state.allElements.getEventSourceById(feature.properties.id);
infobox = new FeatureInfoBox(tags, layer);
@ -166,11 +175,11 @@ export default class ShowDataLayer {
infobox.AttachTo(id)
infobox.Activate();
infobox.Activate();
});
const self = this;
State.state.selectedElement.addCallbackAndRun(selected => {
if (selected === undefined || self._leafletMap.data === undefined) {
State.state.selectedElement.addCallbackAndRunD(selected => {
if (self._leafletMap.data === undefined) {
return;
}
if (leafletLayer.getPopup().isOpen()) {
@ -178,8 +187,10 @@ export default class ShowDataLayer {
}
if (selected.properties.id === feature.properties.id) {
// A small sanity check to prevent infinite loops:
// If a feature is rendered both as way and as point, opening one popup might trigger the other to open, which might trigger the one to open again
if (selected.geometry.type === feature.geometry.type) {
if (selected.geometry.type === feature.geometry.type // If a feature is rendered both as way and as point, opening one popup might trigger the other to open, which might trigger the one to open again
&& feature.id === feature.properties.id // the feature might have as id 'node/-1' and as 'feature.properties.id' = 'the newly assigned id'. That is no good too
) {
leafletLayer.openPopup()
}

View file

@ -25,7 +25,7 @@ import Loc from "../Models/Loc";
import {Utils} from "../Utils";
import BaseLayer from "../Models/BaseLayer";
export interface SpecialVisualization{
export interface SpecialVisualization {
funcName: string,
constr: ((state: State, tagSource: UIEventSource<any>, argument: string[]) => BaseUIElement),
docs: string,
@ -36,6 +36,12 @@ export interface SpecialVisualization{
export default class SpecialVisualizations {
static constructMiniMap: (options?: {
background?: UIEventSource<BaseLayer>,
location?: UIEventSource<Loc>,
allowMoving?: boolean
}) => BaseUIElement;
static constructShowDataLayer: (features: UIEventSource<{ feature: any; freshness: Date }[]>, leafletMap: UIEventSource<any>, layoutToUse: UIEventSource<any>, enablePopups?: boolean, zoomToFeatures?: boolean) => any;
public static specialVisualizations: SpecialVisualization[] =
[
{
@ -137,7 +143,7 @@ export default class SpecialVisualizations {
zoom = parsed;
}
}
const locationSource =new UIEventSource<Loc>({
const locationSource = new UIEventSource<Loc>({
lat: Number(properties._lat),
lon: Number(properties._lon),
zoom: zoom
@ -149,9 +155,9 @@ export default class SpecialVisualizations {
allowMoving: false
}
)
locationSource.addCallback(loc => {
if(loc.zoom > zoom){
if (loc.zoom > zoom) {
// We zoom back
locationSource.data.zoom = zoom;
locationSource.ping();
@ -373,27 +379,7 @@ export default class SpecialVisualizations {
}
]
private static byName() : Map<string, SpecialVisualization>{
const result = new Map<string, SpecialVisualization>();
for (const specialVisualization of SpecialVisualizations.specialVisualizations) {
result.set(specialVisualization.funcName, specialVisualization)
}
return result;
}
public static specialVisualisationsByName: Map<string, SpecialVisualization> = SpecialVisualizations.byName();
static HelpMessage: BaseUIElement = SpecialVisualizations.GenHelpMessage();
static constructMiniMap: (options?: {
background?: UIEventSource<BaseLayer>,
location?: UIEventSource<Loc>,
allowMoving?: boolean
}) => BaseUIElement;
static constructShowDataLayer: (features: UIEventSource<{ feature: any; freshness: Date }[]>, leafletMap: UIEventSource<any>, layoutToUse: UIEventSource<any>, enablePopups?: boolean, zoomToFeatures?: boolean) => any;
private static GenHelpMessage() {
const helpTexts =

View file

@ -30,6 +30,9 @@ export default class Translations {
console.error(msg, t);
throw msg
}
if(t instanceof Translation){
return t;
}
return new Translation(t, context);
}

View file

@ -9,12 +9,10 @@ export class Utils {
*/
public static runningFromConsole = false;
public static readonly assets_path = "./assets/svg/";
public static externalDownloadFunction: (url: string) => Promise<any>;
private static knownKeys = ["addExtraTags", "and", "calculatedTags", "changesetmessage", "clustering", "color", "condition", "customCss", "dashArray", "defaultBackgroundId", "description", "descriptionTail", "doNotDownload", "enableAddNewPoints", "enableBackgroundLayerSelection", "enableGeolocation", "enableLayers", "enableMoreQuests", "enableSearch", "enableShareScreen", "enableUserBadge", "freeform", "hideFromOverview", "hideInAnswer", "icon", "iconOverlays", "iconSize", "id", "if", "ifnot", "isShown", "key", "language", "layers", "lockLocation", "maintainer", "mappings", "maxzoom", "maxZoom", "minNeededElements", "minzoom", "multiAnswer", "name", "or", "osmTags", "passAllFeatures", "presets", "question", "render", "roaming", "roamingRenderings", "rotation", "shortDescription", "socialImage", "source", "startLat", "startLon", "startZoom", "tagRenderings", "tags", "then", "title", "titleIcons", "type", "version", "wayHandling", "widenFactor", "width"]
private static extraKeys = ["nl", "en", "fr", "de", "pt", "es", "name", "phone", "email", "amenity", "leisure", "highway", "building", "yes", "no", "true", "false"]
static EncodeXmlValue(str) {
if (typeof str !== "string") {
str = "" + str
@ -72,10 +70,10 @@ export class Utils {
return res;
}
public static TimesT<T>(count : number, f: ((i: number) => T)): T[] {
let res : T[] = [];
public static TimesT<T>(count: number, f: ((i: number) => T)): T[] {
let res: T[] = [];
for (let i = 0; i < count; i++) {
res .push(f(i));
res.push(f(i));
}
return res;
}
@ -158,7 +156,7 @@ export class Utils {
public static SubstituteKeys(txt: string, tags: any) {
for (const key in tags) {
if(!tags.hasOwnProperty(key)) {
if (!tags.hasOwnProperty(key)) {
continue
}
txt = txt.replace(new RegExp("{" + key + "}", "g"), tags[key])
@ -292,10 +290,10 @@ export class Utils {
public static UnMinify(minified: string): string {
if(minified === undefined || minified === null){
if (minified === undefined || minified === null) {
return undefined;
}
const parts = minified.split("|");
let result = parts.shift();
const keys = Utils.knownKeys.concat(Utils.extraKeys);
@ -323,33 +321,36 @@ export class Utils {
}
return result;
}
public static externalDownloadFunction: (url: string) => Promise<any>;
public static downloadJson(url: string): Promise<any>{
if(this.externalDownloadFunction !== undefined){
public static downloadJson(url: string): Promise<any> {
if (this.externalDownloadFunction !== undefined) {
return this.externalDownloadFunction(url)
}
return new Promise(
(resolve, reject) => {
try{
try {
const xhr = new XMLHttpRequest();
xhr.onload = () => {
if (xhr.status == 200) {
resolve(JSON.parse(xhr.response))
try {
resolve(JSON.parse(xhr.response))
} catch (e) {
reject("Not a valid json: " + xhr.response)
}
} else {
reject(xhr.statusText)
}
};
xhr.open('GET', url);
xhr.setRequestHeader("accept", "application/json")
xhr.send();
}catch(e){
} catch (e) {
reject(e)
}
}
)
}
/**

View file

@ -374,5 +374,12 @@
"type": "url"
}
}
]
],
"deletion": {
"softDeletionTags": {"and":[
"disused:amenity=public_bookcase",
"amenity="
]},
"neededChangesets": 5
}
}

View file

@ -15,15 +15,6 @@ Contains tweaks for small screens
display: none !important;
}
#help-button-mobile div {
box-shadow: 0 0 10px #0006;
margin-bottom: 10px;
}
#geolocate-button {
display: block;
}
#centermessage {
top: 30%;
left: 15%;
@ -57,21 +48,5 @@ Contains tweaks for small screens
}
@media only screen and (max-width: 600px) {
/* Portrait */
#userbadge-and-search {
display: inline-block;
width: auto;
max-width: 100vw;
}
.userbadge-login {
min-width: unset;
}
#userbadge {
margin-bottom: 0.3em;
}
}

View file

@ -1,16 +1,4 @@
#userbadge {
display: inline-block;
background-color: var(--background-color);
color: var(--foreground-color);
margin: 0;
margin-bottom: 0.5em;
width: 100%;
min-width: 20em;
pointer-events: all;
border-radius: 999em;
max-width: 100vw;
overflow-x: hidden;
}
.userstats {
display: flex;
@ -45,28 +33,6 @@
display: block;
}
.usertext {
display: block;
width: max-content;
margin: 0;
padding: 0.9em;
padding-left: 4.7em; /* Should be half of profile-pic's width + actual padding (same as padding-right)*/
padding-right: 1.5em;
border-radius: 2em; /*Half border radius width/height*/
height: 2.2em; /*SHould equal profile-pic height - padding*/
z-index: 5000;
text-align: left;
background-color: var(--background-color);
color: var(--foreground-color);
background-size: 100%;
line-height: 0.75em;
}
.userbadge-login {
font-weight: bold;
font-size: large;
@ -81,10 +47,3 @@
min-width: 20em;
pointer-events: all;
}
#userbadge-and-search {
display: inline-block;
width: min-content;
overflow-x: hidden;
max-width: 100vw;
}

View file

@ -93,6 +93,18 @@ a {
color: var(--foreground-color);
}
.h-min {
height: min-content;
}
.w-min {
width: min-content;
}
.space-between{
justify-content: space-between;
}
.link-underline a {
text-decoration: underline 1px #0078a855;;
color: #0078A8;

View file

@ -59,9 +59,9 @@
<div id="fullscreen" class="hidden md:hidden fixed inset-0 block z-above-controls"></div>
<div id="topleft-tools" class="z-index-above-map">
<div id="userbadge-and-search" class="p-3">
<div id="userbadge" class="shadow rounded-3xl overflow-hidden"></div>
<div id="searchbox" class="shadow rounded-3xl overflow-hidden"></div>
<div class="p-3 flex flex-col items-end sm:items-start sm:flex-row sm:flex-wrap w-full sm:justify-between">
<div id="searchbox" class="shadow rounded-full h-min w-full overflow-hidden sm:max-w-sm"></div>
<div id="userbadge" class="m-1"></div>
</div>
<div id="messagesbox" class="rounded-3xl overflow-hidden ml-3"></div>
</div>

View file

@ -45,7 +45,8 @@ if (location.href.indexOf("buurtnatuur.be") >= 0) {
let testing: UIEventSource<string>;
if (location.hostname === "localhost" || location.hostname === "127.0.0.1") {
if (QueryParameters.GetQueryParameter("backend", undefined).data !== "osm-test" &&
(location.hostname === "localhost" || location.hostname === "127.0.0.1")) {
testing = QueryParameters.GetQueryParameter("test", "true");
// Set to true if testing and changes should NOT be saved
testing.setData(testing.data ?? "true")

1
langs/*.json Normal file
View file

@ -0,0 +1 @@
{}

View file

@ -27,6 +27,31 @@
"intro": "MapComplete is an OpenStreetMap-viewer and editor, which shows you information about a specific theme.",
"pickTheme": "Pick a theme below to get started."
},
"delete": {
"delete": "Delete",
"cancel": "Cancel",
"isDeleted": "This feature is deleted",
"loginToDelete": "You must be logged in to delete a point",
"safeDelete": "This point can be safely deleted.",
"isntAPoint": "Only points can be deleted, the selected feature is a way, area or relation.",
"onlyEditedByLoggedInUser": "This point has only be edited by yourself, you can safely delete it.",
"notEnoughExperience": "This point was made by someone else.",
"useSomethingElse": "Use another OpenStreetMap-editor to delete it instead",
"partOfOthers": "This point is part of some way or relation and can not be deleted directly.",
"loading": "Inspecting properties to check if this feature can be deleted.",
"whyDelete": "Why should this point be deleted?",
"reasons": {
"test": "This was a testing point - the feature was never actually there",
"disused": "This feature is disused or removed",
"notFound": "This feature couldn't be found"
},
"explanations": {
"selectReason": "Please, select why this feature should be deleted",
"hardDelete": "This point will be deleted in OpenStreetMap. It can be recovered by an experienced contributor",
"softDelete": "This feature will be updated and hidden from this application. <span class='subtle'>{reason}</span>"
},
"readMessages": "You have unread messages. Read these before deleting a point - someone might have feedback"
},
"general": {
"loginWithOpenStreetMap": "Login with OpenStreetMap",
"welcomeBack": "You are logged in, welcome back!",

View file

@ -8,6 +8,16 @@
"pleaseLogin": "Faça login para adicionar uma imagem",
"uploadingMultiple": "Fazendo upload de {count} imagens…",
"uploadingPicture": "Enviando sua imagem…",
"addPicture": "Adicionar imagem"
"addPicture": "Adicionar imagem",
"isDeleted": "Excluída",
"doDelete": "Remover imagem",
"dontDelete": "Cancelar",
"uploadDone": "<span class=\"thanks\">Sua foto foi adicionada. Obrigado por ajudar!</span>",
"uploadFailed": "Não foi possível enviar sua foto. Você está conectado à Internet e permite APIs de terceiros? O navegador Brave ou o plugin uMatrix podem bloqueá-los."
},
"centerMessage": {
"ready": "Concluído!",
"zoomIn": "Amplie para ver ou editar os dados",
"loadingData": "Carregando dados…"
}
}

View file

@ -0,0 +1 @@
{}

View file

@ -1,6 +1,6 @@
{
"aed": {
"title": "Открытая карта AED (Автоматизированных внешних дефибрилляторов)",
"title": "Открытая карта АВД (Автоматизированных внешних дефибрилляторов)",
"description": "На этой карте вы можете найти и отметить ближайшие дефибрилляторы"
},
"artworks": {
@ -522,4 +522,4 @@
"trees": {
"title": "Деревья"
}
}
}

View file

@ -20,7 +20,7 @@
"generate:layouts": "ts-node scripts/generateLayouts.ts",
"generate:docs": "ts-node scripts/generateDocs.ts && ts-node scripts/generateTaginfoProjectFiles.ts",
"generate:cache:speelplekken": "npm run generate:layeroverview && ts-node scripts/generateCache.ts speelplekken 14 ../pietervdvn.github.io/speelplekken_cache/ 51.20 4.35 51.09 4.56",
"generate:layeroverview": "ts-node scripts/generateLayerOverview.ts --no-fail",
"generate:layeroverview": "echo {\\\"layers\\\":[], \\\"themes\\\":[]} > ./assets/generated/known_layers_and_themes.json && ts-node scripts/generateLayerOverview.ts --no-fail",
"generate:licenses": "ts-node scripts/generateLicenseInfo.ts --no-fail",
"generate:report": "cd Docs/Tools && ./compileStats.sh && git commit . -m 'New statistics ands graphs' && git push",
"generate:contributor-list": "git log --pretty='%aN' | sort | uniq -c | sort -hr | sed 's/ *\\([0-9]*\\) \\(.*\\)$/{\"contributor\":\"\\2\", \"commits\":\\1}/' | tr '\\n' ',' | sed 's/^/{\"contributors\":[/' | sed 's/,$/]}/' > assets/contributors.json",

View file

@ -1,5 +1,6 @@
import {lstatSync, readdirSync, readFileSync} from "fs";
import {Utils} from "../Utils";
Utils.runningFromConsole = true
import * as https from "https";
import {LayerConfigJson} from "../Customizations/JSON/LayerConfigJson";
@ -52,8 +53,15 @@ export default class ScriptUtils {
return new Promise((resolve, reject) => {
try {
https.get(url, (res) => {
const urlObj = new URL(url)
https.get({
host: urlObj.host,
path: urlObj.pathname,
port: urlObj.port,
headers: {
"accept": "application/json"
}
}, (res) => {
const parts: string[] = []
res.setEncoding('utf8');
res.on('data', function (chunk) {

View file

@ -47,8 +47,9 @@ function generateLayerUsage(layer: LayerConfig, layout: LayoutConfig): any [] {
for (const tr of layer.tagRenderings) {
{
const usesImageCarousel = (tr.render?.txt?.indexOf("image_carousel()") ?? -2) > 0
const usesImageUpload = (tr.render?.txt?.indexOf("image_upload()") ?? -2) > 0
const usesImageCarousel = (tr.render?.txt?.indexOf("image_carousel") ?? -2) > 0
const usesImageUpload = (tr.render?.txt?.indexOf("image_upload") ?? -2) > 0
if (usesImageCarousel || usesImageUpload) {
const descrNoUpload = `The layer '${layer.name.txt} shows images based on the keys image, image:0, image:1,... and wikidata, wikipedia, wikimedia_commons and mapillary`;

155
test.ts
View file

@ -1,4 +1,13 @@
import ValidatedTextField from "./UI/Input/ValidatedTextField";
import {OsmObject} from "./Logic/Osm/OsmObject";
import DeleteButton from "./UI/Popup/DeleteWizard";
import Combine from "./UI/Base/Combine";
import State from "./State";
import DeleteWizard from "./UI/Popup/DeleteWizard";
import {UIEventSource} from "./Logic/UIEventSource";
import {Tag} from "./Logic/Tags/Tag";
import {QueryParameters} from "./Logic/Web/QueryParameters";
import {Translation} from "./UI/i18n/Translation";
/*import ValidatedTextField from "./UI/Input/ValidatedTextField";
import Combine from "./UI/Base/Combine";
import {VariableUiElement} from "./UI/Base/VariableUIElement";
import {UIEventSource} from "./Logic/UIEventSource";
@ -69,75 +78,89 @@ function TestAllInputMethods() {
})).AttachTo("maindiv")
}
function TestMiniMap() {
const location = new UIEventSource<Loc>({
lon: 4.84771728515625,
lat: 51.17920846421931,
zoom: 14
})
const map0 = new Minimap({
location: location,
allowMoving: true,
background: new AvailableBaseLayers(location).availableEditorLayers.map(layers => layers[2])
})
map0.SetStyle("width: 500px; height: 250px; overflow: hidden; border: 2px solid red")
.AttachTo("maindiv")
const layout = AllKnownLayouts.layoutsList[1]
State.state = new State(layout)
console.log("LAYOUT is", layout.id)
const feature = {
"type": "Feature",
_matching_layer_id: "bike_repair_station",
"properties": {
id: "node/-1",
"amenity": "bicycle_repair_station"
},
"geometry": {
"type": "Point",
"coordinates": [
4.84771728515625,
51.17920846421931
]
}
}
;
State.state.allElements.addOrGetElement(feature)
const featureSource = new UIEventSource([{
freshness: new Date(),
feature: feature
}])
new ShowDataLayer(
featureSource,
map0.leafletMap,
new UIEventSource<LayoutConfig>(layout)
)
const map1 = new Minimap({
const location = new UIEventSource<Loc>({
lon: 4.84771728515625,
lat: 51.17920846421931,
zoom: 14
})
const map0 = new Minimap({
location: location,
allowMoving: true,
background: new AvailableBaseLayers(location).availableEditorLayers.map(layers => layers[5])
},
)
background: new AvailableBaseLayers(location).availableEditorLayers.map(layers => layers[2])
})
map0.SetStyle("width: 500px; height: 250px; overflow: hidden; border: 2px solid red")
.AttachTo("maindiv")
map1.SetStyle("width: 500px; height: 250px; overflow: hidden; border : 2px solid black")
.AttachTo("extradiv")
const layout = AllKnownLayouts.layoutsList[1]
State.state = new State(layout)
console.log("LAYOUT is", layout.id)
const feature = {
"type": "Feature",
_matching_layer_id: "bike_repair_station",
"properties": {
id: "node/-1",
"amenity": "bicycle_repair_station"
},
"geometry": {
"type": "Point",
"coordinates": [
4.84771728515625,
51.17920846421931
]
}
}
;
State.state.allElements.addOrGetElement(feature)
const featureSource = new UIEventSource([{
freshness: new Date(),
feature: feature
}])
new ShowDataLayer(
featureSource,
map0.leafletMap,
new UIEventSource<LayoutConfig>(layout)
)
const map1 = new Minimap({
location: location,
allowMoving: true,
background: new AvailableBaseLayers(location).availableEditorLayers.map(layers => layers[5])
},
)
map1.SetStyle("width: 500px; height: 250px; overflow: hidden; border : 2px solid black")
.AttachTo("extradiv")
new ShowDataLayer(
featureSource,
map1.leafletMap,
new UIEventSource<LayoutConfig>(layout)
)
new ShowDataLayer(
featureSource,
map1.leafletMap,
new UIEventSource<LayoutConfig>(layout)
)
featureSource.ping()
// */
featureSource.ping()
}
//*/
QueryParameters.GetQueryParameter("test", "true").setData("true")
State.state= new State(undefined)
const id = "node/5414688303"
State.state.allElements.addElementById(id, new UIEventSource<any>({id: id}))
new Combine([
new DeleteWizard(id, {
noDeleteOptions: [
{
if:[ new Tag("access","private")],
then: new Translation({
en: "Very private! Delete now or me send lawfull lawyer"
})
}
]
}),
]).AttachTo("maindiv")

32
test/OsmObject.spec.ts Normal file
View file

@ -0,0 +1,32 @@
import T from "./TestHelper";
import {OsmObject} from "../Logic/Osm/OsmObject";
import ScriptUtils from "../scripts/ScriptUtils";
export default class OsmObjectSpec extends T {
constructor() {
super("OsmObject", [
[
"Download referencing ways",
() => {
let downloaded = false;
OsmObject.DownloadReferencingWays("node/1124134958").addCallbackAndRunD(ways => {
downloaded = true;
console.log(ways)
})
let timeout = 10
while (!downloaded && timeout >= 0) {
ScriptUtils.sleep(1000)
timeout--;
}
if(!downloaded){
throw "Timeout: referencing ways not found"
}
}
]
]);
}
}

View file

@ -1,5 +1,4 @@
import {Utils} from "../Utils";
Utils.runningFromConsole = true;
import {Utils} from "../Utils";Utils.runningFromConsole = true;
import TagSpec from "./Tag.spec";
import ImageAttributionSpec from "./ImageAttribution.spec";
import GeoOperationsSpec from "./GeoOperations.spec";
@ -10,6 +9,10 @@ import OsmConnectionSpec from "./OsmConnection.spec";
import T from "./TestHelper";
import {FixedUiElement} from "../UI/Base/FixedUiElement";
import Combine from "../UI/Base/Combine";
import OsmObjectSpec from "./OsmObject.spec";
import ScriptUtils from "../scripts/ScriptUtils";
export default class TestAll {
private needsBrowserTests: T[] = [new OsmConnectionSpec()]
@ -26,8 +29,9 @@ export default class TestAll {
}
}
}
ScriptUtils.fixUtils()
const allTests = [
new OsmObjectSpec(),
new TagSpec(),
new ImageAttributionSpec(),
new GeoOperationsSpec(),
@ -39,6 +43,6 @@ const allTests = [
for (const test of allTests) {
if (test.failures.length > 0) {
throw "Some test failed"
throw "Some test failed: "+test.failures.join(", ")
}
}