Merge branch 'develop'
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
||||
|
|
47
Customizations/JSON/DeleteConfig.ts
Normal 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
|
||||
}
|
||||
|
||||
|
||||
}
|
66
Customizations/JSON/DeleteConfigJson.ts
Normal 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
|
||||
}
|
|
@ -7,14 +7,49 @@ import Combine from "../../UI/Base/Combine";
|
|||
export class Unit {
|
||||
public readonly appliesToKeys: Set<string>;
|
||||
public readonly denominations: Denomination[];
|
||||
public readonly denominationsSorted: Denomination[];
|
||||
public readonly defaultDenom: Denomination;
|
||||
public readonly eraseInvalid : boolean;
|
||||
public readonly eraseInvalid: boolean;
|
||||
private readonly possiblePostFixes: string[] = []
|
||||
|
||||
constructor(appliesToKeys: string[], applicableUnits: Denomination[], eraseInvalid: boolean) {
|
||||
this.appliesToKeys = new Set(appliesToKeys);
|
||||
this.denominations = applicableUnits;
|
||||
this.defaultDenom = applicableUnits.filter(denom => denom.default)[0]
|
||||
this.eraseInvalid = eraseInvalid
|
||||
|
||||
const seenUnitExtensions = new Set<string>();
|
||||
for (const denomination of this.denominations) {
|
||||
if(seenUnitExtensions.has(denomination.canonical)){
|
||||
throw "This canonical unit is already defined in another denomination: "+denomination.canonical
|
||||
}
|
||||
const duplicate = denomination.alternativeDenominations.filter(denom => seenUnitExtensions.has(denom))
|
||||
if(duplicate.length > 0){
|
||||
throw "A denomination is used multiple times: "+duplicate.join(", ")
|
||||
}
|
||||
|
||||
seenUnitExtensions.add(denomination.canonical)
|
||||
denomination.alternativeDenominations.forEach(d => seenUnitExtensions.add(d))
|
||||
}
|
||||
this.denominationsSorted = [...this.denominations]
|
||||
this.denominationsSorted.sort((a, b) => b.canonical.length - a.canonical.length)
|
||||
|
||||
|
||||
const possiblePostFixes = new Set<string>()
|
||||
function addPostfixesOf(str){
|
||||
str = str.toLowerCase()
|
||||
for (let i = 0; i < str.length + 1; i++) {
|
||||
const substr = str.substring(0,i)
|
||||
possiblePostFixes.add(substr)
|
||||
}
|
||||
}
|
||||
|
||||
for (const denomination of this.denominations) {
|
||||
addPostfixesOf(denomination.canonical)
|
||||
denomination.alternativeDenominations.forEach(addPostfixesOf)
|
||||
}
|
||||
this.possiblePostFixes = Array.from(possiblePostFixes)
|
||||
this.possiblePostFixes.sort((a, b) => b.length - a .length)
|
||||
}
|
||||
|
||||
isApplicableToKey(key: string | undefined): boolean {
|
||||
|
@ -29,7 +64,10 @@ export class Unit {
|
|||
* Finds which denomination is applicable and gives the stripped value back
|
||||
*/
|
||||
findDenomination(valueWithDenom: string): [string, Denomination] {
|
||||
for (const denomination of this.denominations) {
|
||||
if(valueWithDenom === undefined){
|
||||
return undefined;
|
||||
}
|
||||
for (const denomination of this.denominationsSorted) {
|
||||
const bare = denomination.StrippedValue(valueWithDenom)
|
||||
if (bare !== null) {
|
||||
return [bare, denomination]
|
||||
|
@ -49,6 +87,27 @@ export class Unit {
|
|||
return new Combine(elems)
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value without any (sub)parts of any denomination - usefull as preprocessing step for validating inputs.
|
||||
* E.g.
|
||||
* if 'megawatt' is a possible denomination, then '5 Meg' will be rewritten to '5' (which can then be validated as a valid pnat)
|
||||
*
|
||||
* Returns the original string if nothign matches
|
||||
*/
|
||||
stripUnitParts(str: string) {
|
||||
if(str === undefined){
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const denominationPart of this.possiblePostFixes) {
|
||||
if(str.endsWith(denominationPart)){
|
||||
return str.substring(0, str.length - denominationPart.length).trim()
|
||||
}
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
export class Denomination {
|
||||
|
@ -56,12 +115,12 @@ export class Denomination {
|
|||
readonly default: boolean;
|
||||
readonly prefix: boolean;
|
||||
private readonly _human: Translation;
|
||||
private readonly alternativeDenominations: string [];
|
||||
public readonly alternativeDenominations: string [];
|
||||
|
||||
constructor(json: UnitConfigJson, context: string) {
|
||||
context = `${context}.unit(${json.canonicalDenomination})`
|
||||
this.canonical = json.canonicalDenomination.trim()
|
||||
if ((this.canonical ?? "") === "") {
|
||||
if (this.canonical === undefined) {
|
||||
throw `${context}: this unit has no decent canonical value defined`
|
||||
}
|
||||
|
||||
|
@ -93,7 +152,7 @@ export class Denomination {
|
|||
if (stripped === null) {
|
||||
return null;
|
||||
}
|
||||
return stripped + this.canonical
|
||||
return stripped + " " + this.canonical.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -109,8 +168,9 @@ export class Denomination {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
value = value.toLowerCase()
|
||||
if (this.prefix) {
|
||||
if (value.startsWith(this.canonical)) {
|
||||
if (value.startsWith(this.canonical) && this.canonical !== "") {
|
||||
return value.substring(this.canonical.length).trim();
|
||||
}
|
||||
for (const alternativeValue of this.alternativeDenominations) {
|
||||
|
@ -119,11 +179,11 @@ export class Denomination {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
if (value.endsWith(this.canonical)) {
|
||||
if (value.endsWith(this.canonical.toLowerCase()) && this.canonical !== "") {
|
||||
return value.substring(0, value.length - this.canonical.length).trim();
|
||||
}
|
||||
for (const alternativeValue of this.alternativeDenominations) {
|
||||
if (value.endsWith(alternativeValue)) {
|
||||
if (value.endsWith(alternativeValue.toLowerCase())) {
|
||||
return value.substring(0, value.length - alternativeValue.length).trim();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
|
||||
|
|
|
@ -15,9 +15,9 @@ import {FixedUiElement} from "../../UI/Base/FixedUiElement";
|
|||
import SourceConfig from "./SourceConfig";
|
||||
import {TagsFilter} from "../../Logic/Tags/TagsFilter";
|
||||
import {Tag} from "../../Logic/Tags/Tag";
|
||||
import SubstitutingTag from "../../Logic/Tags/SubstitutingTag";
|
||||
import BaseUIElement from "../../UI/BaseUIElement";
|
||||
import {Denomination, Unit} from "./Denomination";
|
||||
import {Unit} from "./Denomination";
|
||||
import DeleteConfig from "./DeleteConfig";
|
||||
|
||||
export default class LayerConfig {
|
||||
|
||||
|
@ -48,6 +48,7 @@ export default class LayerConfig {
|
|||
dashArray: TagRenderingConfig;
|
||||
wayHandling: number;
|
||||
public readonly units: Unit[];
|
||||
public readonly deletion: DeleteConfig | null
|
||||
|
||||
presets: {
|
||||
title: Translation,
|
||||
|
@ -58,10 +59,10 @@ export default class LayerConfig {
|
|||
tagRenderings: TagRenderingConfig [];
|
||||
|
||||
constructor(json: LayerConfigJson,
|
||||
units:Unit[],
|
||||
units?:Unit[],
|
||||
context?: string,
|
||||
official: boolean = true,) {
|
||||
this.units = units;
|
||||
this.units = units ?? [];
|
||||
context = context + "." + json.id;
|
||||
const self = this;
|
||||
this.id = json.id;
|
||||
|
@ -167,7 +168,7 @@ export default class LayerConfig {
|
|||
return [];
|
||||
}
|
||||
|
||||
return tagRenderings.map(
|
||||
return Utils.NoNull(tagRenderings.map(
|
||||
(renderingJson, i) => {
|
||||
if (typeof renderingJson === "string") {
|
||||
|
||||
|
@ -187,10 +188,14 @@ export default class LayerConfig {
|
|||
|
||||
const keys = Array.from(SharedTagRenderings.SharedTagRendering.keys())
|
||||
|
||||
if(Utils.runningFromConsole){
|
||||
return undefined;
|
||||
}
|
||||
|
||||
throw `Predefined tagRendering ${renderingJson} not found in ${context}.\n Try one of ${(keys.join(", "))}\n If you intent to output this text literally, use {\"render\": <your text>} instead"}`;
|
||||
}
|
||||
return new TagRenderingConfig(renderingJson, self.source.osmTags, `${context}.tagrendering[${i}]`);
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
this.tagRenderings = trs(json.tagRenderings, false);
|
||||
|
@ -237,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) {
|
||||
|
|
|
@ -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
|
||||
|
||||
}
|
|
@ -7,6 +7,7 @@ import {Utils} from "../../Utils";
|
|||
import {TagUtils} from "../../Logic/Tags/TagUtils";
|
||||
import {And} from "../../Logic/Tags/And";
|
||||
import {TagsFilter} from "../../Logic/Tags/TagsFilter";
|
||||
import {SubstitutedTranslation} from "../../UI/SubstitutedTranslation";
|
||||
|
||||
|
||||
/***
|
||||
|
@ -67,14 +68,13 @@ export default class TagRenderingConfig {
|
|||
if (json.freeform) {
|
||||
|
||||
|
||||
|
||||
this.freeform = {
|
||||
key: json.freeform.key,
|
||||
type: json.freeform.type ?? "string",
|
||||
addExtraTags: json.freeform.addExtraTags?.map((tg, i) =>
|
||||
FromJSON.Tag(tg, `${context}.extratag[${i}]`)) ?? [],
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
if (json.freeform["extraTags"] !== undefined) {
|
||||
throw `Freeform.extraTags is defined. This should probably be 'freeform.addExtraTag' (at ${context})`
|
||||
|
@ -82,9 +82,8 @@ export default class TagRenderingConfig {
|
|||
if (this.freeform.key === undefined || this.freeform.key === "") {
|
||||
throw `Freeform.key is undefined or the empty string - this is not allowed; either fill out something or remove the freeform block alltogether. Error in ${context}`
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (ValidatedTextField.AllTypes[this.freeform.type] === undefined) {
|
||||
const knownKeys = ValidatedTextField.tpList.map(tp => tp.name).join(", ");
|
||||
throw `Freeform.key ${this.freeform.key} is an invalid type. Known keys are ${knownKeys}`
|
||||
|
@ -328,4 +327,25 @@ export default class TagRenderingConfig {
|
|||
return usedIcons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this tag rendering has a minimap in some language.
|
||||
* Note: this might be hidden by conditions
|
||||
*/
|
||||
public hasMinimap(): boolean {
|
||||
const translations : Translation[]= Utils.NoNull([this.render, ...(this.mappings ?? []).map(m => m.then)]);
|
||||
for (const translation of translations) {
|
||||
for (const key in translation.translations) {
|
||||
if(!translation.translations.hasOwnProperty(key)){
|
||||
continue
|
||||
}
|
||||
const template = translation.translations[key]
|
||||
const parts = SubstitutedTranslation.ExtractSpecialComponents(template)
|
||||
const hasMiniMap = parts.filter(part =>part.special !== undefined ).some(special => special.special.func.funcName === "minimap")
|
||||
if(hasMiniMap){
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
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 +13,12 @@ export default class SharedTagRenderings {
|
|||
|
||||
function add(key, store) {
|
||||
try {
|
||||
dict.set(key, new TagRenderingConfig(store[key], key))
|
||||
dict.set(key, new TagRenderingConfig(store[key], undefined, `SharedTagRenderings.${key}`))
|
||||
} catch (e) {
|
||||
console.error("BUG: could not parse", key, " from questions.json or icons.json - this error happened during the build step of the SharedTagRenderings", e)
|
||||
if(!Utils.runningFromConsole){
|
||||
console.error("BUG: could not parse", key, " from questions.json or icons.json - this error happened during the build step of the SharedTagRenderings", e)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 178 KiB |
Before Width: | Height: | Size: 926 KiB After Width: | Height: | Size: 244 KiB |
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 228 KiB |
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 98 KiB |
Before Width: | Height: | Size: 395 KiB After Width: | Height: | Size: 177 KiB |
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 220 KiB |
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 170 KiB |
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 174 KiB |
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 167 KiB |
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 170 KiB |
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 171 KiB |
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 196 KiB |
After Width: | Height: | Size: 374 KiB |
After Width: | Height: | Size: 187 KiB |
After Width: | Height: | Size: 207 KiB |
After Width: | Height: | Size: 431 KiB |
After Width: | Height: | Size: 104 KiB |
After Width: | Height: | Size: 362 KiB |
After Width: | Height: | Size: 645 KiB |
After Width: | Height: | Size: 129 KiB |
After Width: | Height: | Size: 274 KiB |
After Width: | Height: | Size: 152 KiB |
After Width: | Height: | Size: 642 KiB |
After Width: | Height: | Size: 134 KiB |
After Width: | Height: | Size: 162 KiB |
After Width: | Height: | Size: 169 KiB |
After Width: | Height: | Size: 93 KiB |
After Width: | Height: | Size: 222 KiB |
After Width: | Height: | Size: 219 KiB |
After Width: | Height: | Size: 162 KiB |
After Width: | Height: | Size: 140 KiB |
After Width: | Height: | Size: 90 KiB |
After Width: | Height: | Size: 140 KiB |
After Width: | Height: | Size: 220 KiB |
After Width: | Height: | Size: 169 KiB |
After Width: | Height: | Size: 102 KiB |
After Width: | Height: | Size: 155 KiB |
After Width: | Height: | Size: 257 KiB |
After Width: | Height: | Size: 232 KiB |
After Width: | Height: | Size: 296 KiB |
After Width: | Height: | Size: 165 KiB |
After Width: | Height: | Size: 90 KiB |
Before Width: | Height: | Size: 628 KiB After Width: | Height: | Size: 179 KiB |
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 372 KiB |
Before Width: | Height: | Size: 643 KiB After Width: | Height: | Size: 332 KiB |
Before Width: | Height: | Size: 438 KiB After Width: | Height: | Size: 409 KiB |
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 81 KiB |
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 140 KiB |
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 135 KiB |
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 109 KiB |
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 122 KiB |
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 119 KiB |
Before Width: | Height: | Size: 309 KiB After Width: | Height: | Size: 284 KiB |
Before Width: | Height: | Size: 463 KiB After Width: | Height: | Size: 442 KiB |
Before Width: | Height: | Size: 433 KiB After Width: | Height: | Size: 404 KiB |
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 123 KiB |
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 221 KiB |
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 238 KiB |
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 271 KiB |
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 263 KiB |
Before Width: | Height: | Size: 66 KiB |
Before Width: | Height: | Size: 70 KiB |
Before Width: | Height: | Size: 65 KiB |
Before Width: | Height: | Size: 395 KiB After Width: | Height: | Size: 350 KiB |
Before Width: | Height: | Size: 568 KiB After Width: | Height: | Size: 628 KiB |
Before Width: | Height: | Size: 664 KiB After Width: | Height: | Size: 678 KiB |
|
@ -1,5 +1,4 @@
|
|||
import csv
|
||||
import string
|
||||
from datetime import datetime
|
||||
|
||||
from matplotlib import pyplot
|
||||
|
@ -7,6 +6,7 @@ import re
|
|||
|
||||
useLegend = True
|
||||
|
||||
|
||||
def counts(lst):
|
||||
counts = {}
|
||||
for v in lst:
|
||||
|
@ -33,7 +33,7 @@ class Hist:
|
|||
for v in self.dictionary.values():
|
||||
allV += list(set(v))
|
||||
return list(set(allV))
|
||||
|
||||
|
||||
def keys(self):
|
||||
return self.dictionary.keys()
|
||||
|
||||
|
@ -148,27 +148,28 @@ def create_usercount_graphs(stats, extra_text=""):
|
|||
pyplot.savefig("CumulativeContributors" + extra_text + ".png", dpi=400, facecolor='w', edgecolor='w')
|
||||
|
||||
|
||||
def create_contributors_per_total_cs(contents, extra_text = "", cutoff=25, per_day=False):
|
||||
def create_contributors_per_total_cs(contents, extra_text="", cutoff=25, per_day=False):
|
||||
hist = Hist("contributor")
|
||||
for cs in contents:
|
||||
hist.add(cs[1], cs[0])
|
||||
|
||||
count_per_contributor = hist.map(lambda dates : len(set(dates))) if per_day else hist.map(len)
|
||||
|
||||
|
||||
count_per_contributor = hist.map(lambda dates: len(set(dates))) if per_day else hist.map(len)
|
||||
|
||||
per_count = Hist("per cs count")
|
||||
for cs_count in count_per_contributor:
|
||||
per_count.add(min(cs_count, cutoff), 1)
|
||||
per_count.add(min(cs_count, cutoff), 1)
|
||||
|
||||
to_plot = per_count.flatten(len)
|
||||
to_plot.sort(key=lambda a: a[0])
|
||||
to_plot[ - 1] = (str(cutoff)+ " or more", to_plot[-1][1])
|
||||
to_plot[- 1] = (str(cutoff) + " or more", to_plot[-1][1])
|
||||
pyplot_init()
|
||||
pyplot.bar(list(map(lambda a : str(a[0]), to_plot)), list(map(lambda a: a[1], to_plot)) )
|
||||
pyplot.title("Contributors per total number of changesets"+extra_text)
|
||||
pyplot.bar(list(map(lambda a: str(a[0]), to_plot)), list(map(lambda a: a[1], to_plot)))
|
||||
pyplot.title("Contributors per total number of changesets" + extra_text)
|
||||
pyplot.ylabel("Number of contributors")
|
||||
pyplot.xlabel("Mapping days with MapComplete" if per_day else "Number of changesets with MapComplete")
|
||||
pyplot.savefig("Contributors per total number of "+("mapping days" if per_day else "changesets")+extra_text+".png", dpi=400)
|
||||
|
||||
pyplot.savefig(
|
||||
"Contributors per total number of " + ("mapping days" if per_day else "changesets") + extra_text + ".png",
|
||||
dpi=400)
|
||||
|
||||
|
||||
def create_theme_breakdown(stats, fileExtra="", cutoff=15):
|
||||
|
@ -203,6 +204,7 @@ def create_theme_breakdown(stats, fileExtra="", cutoff=15):
|
|||
bbox_inches='tight')
|
||||
return themes
|
||||
|
||||
|
||||
def summed_changes_per(contents, extraText, sum_column=5):
|
||||
newPerDay = build_hist(contents, 0, 5)
|
||||
kv = newPerDay.flatten(sum)
|
||||
|
@ -216,7 +218,7 @@ def summed_changes_per(contents, extraText, sum_column=5):
|
|||
return
|
||||
|
||||
pyplot_init()
|
||||
text = "New and changed nodes per day "+extraText
|
||||
text = "New and changed nodes per day " + extraText
|
||||
pyplot.title(text)
|
||||
if len(keysChanged) > 0:
|
||||
pyplot.bar(keysChanged, valuesChanged, label="Changed")
|
||||
|
@ -226,6 +228,7 @@ def summed_changes_per(contents, extraText, sum_column=5):
|
|||
pyplot.legend()
|
||||
pyplot.savefig(text)
|
||||
|
||||
|
||||
def cumulative_changes_per(contents, index, subject, filenameextra="", cutoff=5, cumulative=True, sort=True):
|
||||
print("Creating graph about " + subject + filenameextra)
|
||||
themes = Hist("date")
|
||||
|
@ -259,7 +262,7 @@ def cumulative_changes_per(contents, index, subject, filenameextra="", cutoff=5,
|
|||
edits_per_day_cumul = themes.map(lambda themes_for_date: len([x for x in themes_for_date if theme == x]))
|
||||
|
||||
if (not cumulative) or (running_totals is None):
|
||||
running_totals = edits_per_day_cumul
|
||||
running_totals = edits_per_day_cumul
|
||||
else:
|
||||
running_totals = list(map(lambda ab: ab[0] + ab[1], zip(running_totals, edits_per_day_cumul)))
|
||||
|
||||
|
@ -310,15 +313,15 @@ def contents_where(contents, index, starts_with, invert=False):
|
|||
|
||||
def sortable_user_number(kv):
|
||||
str = kv[0]
|
||||
ls = list(map(lambda str : "0"+str if len(str) < 2 else str, re.findall("[0-9]+", str)))
|
||||
ls = list(map(lambda str: "0" + str if len(str) < 2 else str, re.findall("[0-9]+", str)))
|
||||
return ".".join(ls)
|
||||
|
||||
|
||||
def create_graphs(contents):
|
||||
summed_changes_per(contents, "")
|
||||
# summed_changes_per(contents, "")
|
||||
create_contributors_per_total_cs(contents)
|
||||
create_contributors_per_total_cs(contents, per_day=True)
|
||||
|
||||
|
||||
cumulative_changes_per(contents, 4, "version number", cutoff=1, sort=sortable_user_number)
|
||||
create_usercount_graphs(contents)
|
||||
create_theme_breakdown(contents)
|
||||
|
@ -345,8 +348,7 @@ def create_graphs(contents):
|
|||
sort=sortable_user_number)
|
||||
cumulative_changes_per(contents_filtered, 4, "version number", extratext, cutoff=1, sort=sortable_user_number)
|
||||
cumulative_changes_per(contents_filtered, 8, "host", extratext, cutoff=1)
|
||||
summed_changes_per(contents_filtered, "for year "+str(year))
|
||||
|
||||
# summed_changes_per(contents_filtered, "for year " + str(year))
|
||||
|
||||
|
||||
def create_per_theme_graphs(contents, cutoff=10):
|
||||
|
@ -359,10 +361,8 @@ def create_per_theme_graphs(contents, cutoff=10):
|
|||
contributors = set(map(lambda row: row[1], filtered))
|
||||
if len(contributors) >= 2:
|
||||
cumulative_changes_per(filtered, 1, "contributor", " for theme " + theme, cutoff=1)
|
||||
if len(filtered) > 25:
|
||||
summed_changes_per(filtered, "for theme "+theme)
|
||||
|
||||
|
||||
# if len(filtered) > 25:
|
||||
# summed_changes_per(filtered, "for theme " + theme)
|
||||
|
||||
|
||||
def create_per_contributor_graphs(contents, least_needed_changesets):
|
||||
|
@ -370,20 +370,20 @@ def create_per_contributor_graphs(contents, least_needed_changesets):
|
|||
for contrib in all_contributors:
|
||||
filtered = list(contents_where(contents, 1, contrib))
|
||||
if len(filtered) < least_needed_changesets:
|
||||
print("Skipping "+contrib+" - too little changesets");
|
||||
print("Skipping " + contrib + " - too little changesets");
|
||||
continue
|
||||
themes = set(map(lambda row: row[3], filtered))
|
||||
if len(themes) >= 2:
|
||||
cumulative_changes_per(filtered, 3, "theme", " for contributor " + contrib, cutoff=1)
|
||||
if len(filtered) > 25:
|
||||
summed_changes_per(filtered, "for contributor "+contrib)
|
||||
# if len(filtered) > 25:
|
||||
# summed_changes_per(filtered, "for contributor " + contrib)
|
||||
|
||||
|
||||
theme_remappings = {
|
||||
"metamap": "maps",
|
||||
"groen": "buurtnatuur",
|
||||
"updaten van metadata met mapcomplete": "buurtnatuur",
|
||||
"Toevoegen of dit natuurreservaat toegangkelijk is":"buurtnatuur",
|
||||
"Toevoegen of dit natuurreservaat toegangkelijk is": "buurtnatuur",
|
||||
"wiki:mapcomplete/fritures": "fritures",
|
||||
"wiki:MapComplete/Fritures": "fritures",
|
||||
"lits": "lit",
|
||||
|
@ -394,12 +394,12 @@ theme_remappings = {
|
|||
"wiki-User-joost_schouppe-campersite": "campersite",
|
||||
"wiki-User-joost_schouppe-geveltuintjes": "geveltuintjes",
|
||||
"wiki:User:joost_schouppe/campersite": "campersite",
|
||||
"arbres":"arbres_llefia",
|
||||
"arbres": "arbres_llefia",
|
||||
"aed_brugge": "aed",
|
||||
"https://llefia.org/arbres/mapcomplete.json":"arbres_llefia",
|
||||
"https://llefia.org/arbres/mapcomplete1.json":"arbres_llefia",
|
||||
"toevoegen of dit natuurreservaat toegangkelijk is":"buurtnatuur",
|
||||
"testing mapcomplete 0.0.0":"buurtnatuur",
|
||||
"https://llefia.org/arbres/mapcomplete.json": "arbres_llefia",
|
||||
"https://llefia.org/arbres/mapcomplete1.json": "arbres_llefia",
|
||||
"toevoegen of dit natuurreservaat toegangkelijk is": "buurtnatuur",
|
||||
"testing mapcomplete 0.0.0": "buurtnatuur",
|
||||
"https://raw.githubusercontent.com/osmbe/play/master/mapcomplete/geveltuinen/geveltuinen.json": "geveltuintjes"
|
||||
}
|
||||
|
||||
|
@ -414,7 +414,7 @@ def clean_input(contents):
|
|||
if theme in theme_remappings:
|
||||
theme = theme_remappings[theme]
|
||||
if theme.rfind('/') > 0:
|
||||
theme = theme[theme.rfind('/') + 1 : ]
|
||||
theme = theme[theme.rfind('/') + 1:]
|
||||
row[3] = theme
|
||||
row[4] = row[4].strip().strip("\"")[len("MapComplete "):]
|
||||
row[4] = re.findall("[0-9]*\.[0-9]*\.[0-9]*", row[4])[0]
|
||||
|
@ -424,25 +424,54 @@ def clean_input(contents):
|
|||
yield row
|
||||
|
||||
|
||||
def contributor_count(stats, index=1, item = "contributor"):
|
||||
# Merges changesets of the same theme and the samecontributos within the same hour, so that the stats are comparable
|
||||
def mergeChangesets(contents):
|
||||
open_changesets = dict() # {contributor --> {theme --> hour of last change}}
|
||||
for row in contents:
|
||||
theme = row[3]
|
||||
contributor = row[1]
|
||||
date = datetime.strptime(row[0], "%Y-%m-%dT%H:%M:%SZ")
|
||||
if (contributor not in open_changesets):
|
||||
open_changesets[contributor] = dict()
|
||||
perTheme = open_changesets[contributor]
|
||||
if (theme in perTheme):
|
||||
lastChange = perTheme[theme]
|
||||
diff = (date - lastChange).total_seconds()
|
||||
if(diff > 60*60):
|
||||
yield row
|
||||
else:
|
||||
yield row
|
||||
perTheme[theme] = date
|
||||
|
||||
|
||||
# Removes the time from the date component
|
||||
def datesOnly(contents):
|
||||
for row in contents:
|
||||
row[0] = row[0].split("T")[0]
|
||||
|
||||
|
||||
def contributor_count(stats, index=1, item="contributor"):
|
||||
seen_contributors = set()
|
||||
for line in stats:
|
||||
contributor = line[index]
|
||||
if(contributor in seen_contributors):
|
||||
if (contributor in seen_contributors):
|
||||
continue
|
||||
print("New " + item + " " + str(len(seen_contributors) + 1) + ": "+contributor)
|
||||
print("New " + item + " " + str(len(seen_contributors) + 1) + ": " + contributor)
|
||||
seen_contributors.add(contributor)
|
||||
print(line)
|
||||
|
||||
|
||||
def main():
|
||||
print("Creating graphs...")
|
||||
with open('stats.csv', newline='') as csvfile:
|
||||
stats = list(clean_input(csv.reader(csvfile, delimiter=',', quotechar='"')))
|
||||
stats = list(mergeChangesets(stats))
|
||||
datesOnly(stats)
|
||||
print("Found " + str(len(stats)) + " changesets")
|
||||
|
||||
contributor_count(stats, 3, "theme")
|
||||
# create_graphs(stats)
|
||||
# create_per_theme_graphs(stats, 15)
|
||||
|
||||
# contributor_count(stats, 3, "theme")
|
||||
create_graphs(stats)
|
||||
create_per_theme_graphs(stats, 15)
|
||||
# create_per_contributor_graphs(stats, 25)
|
||||
print("All done!")
|
||||
|
||||
|
|
|
@ -17,6 +17,6 @@ do
|
|||
echo "" >> tmp.csv
|
||||
done
|
||||
|
||||
sed "/^$/d" tmp.csv | sed "s/^ //" | sed "s/ / /g" | sed "s/\"\(....-..-..\)T........./\"\1/" | sort > stats-latest.csv
|
||||
cat stats2020.csv stats-latest.csv > stats.csv
|
||||
sed "/^$/d" tmp.csv | sed "s/^ //" | sed "s/ / /g" | sort > stats-latest.csv
|
||||
cat stats2020.csv stats2021Q1.csv stats-latest.csv > stats.csv
|
||||
rm tmp.csv stats-latest.csv
|
||||
|
|
|
@ -6,7 +6,7 @@ then
|
|||
COUNTER="$1"
|
||||
fi
|
||||
|
||||
NEXT_URL=$(echo "https://osmcha.org/api/v1/changesets/?date__gte=2021-01-01&date__lte=$DATE&editor=mapcomplete&page=$COUNTER&page_size=1000")
|
||||
NEXT_URL=$(echo "https://osmcha.org/api/v1/changesets/?date__gte=2021-07-01&date__lte=$DATE&editor=mapcomplete&page=$COUNTER&page_size=1000")
|
||||
rm stats.*.json
|
||||
while [[ "$NEXT_URL" != "null" ]]
|
||||
do
|
||||
|
|
4047
Docs/Tools/stats.csv
1772
Docs/Tools/stats2021Q1.csv
Normal 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);
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
|
|
@ -1,27 +1,25 @@
|
|||
import * as L from "leaflet";
|
||||
import {UIEventSource} from "../UIEventSource";
|
||||
import {UIElement} from "../../UI/UIElement";
|
||||
import {Utils} from "../../Utils";
|
||||
import Svg from "../../Svg";
|
||||
import Img from "../../UI/Base/Img";
|
||||
import {LocalStorageSource} from "../Web/LocalStorageSource";
|
||||
import LayoutConfig from "../../Customizations/JSON/LayoutConfig";
|
||||
import BaseUIElement from "../../UI/BaseUIElement";
|
||||
import {VariableUiElement} from "../../UI/Base/VariableUIElement";
|
||||
|
||||
export default class GeoLocationHandler extends UIElement {
|
||||
export default class GeoLocationHandler extends VariableUiElement {
|
||||
|
||||
/**
|
||||
* Wether or not the geolocation is active, aka the user requested the current location
|
||||
* @private
|
||||
*/
|
||||
private readonly _isActive: UIEventSource<boolean> = new UIEventSource<boolean>(false);
|
||||
private readonly _isActive: UIEventSource<boolean>;
|
||||
|
||||
/**
|
||||
* The callback over the permission API
|
||||
* @private
|
||||
*/
|
||||
private readonly _permission: UIEventSource<string> = new UIEventSource<string>("");
|
||||
private readonly _permission: UIEventSource<string>;
|
||||
/***
|
||||
* The marker on the map, in order to update it
|
||||
* @private
|
||||
|
@ -51,21 +49,37 @@ export default class GeoLocationHandler extends UIElement {
|
|||
* If the user denies the geolocation this time, we unset this flag
|
||||
* @private
|
||||
*/
|
||||
private readonly _previousLocationGrant: UIEventSource<string> = LocalStorageSource.Get("geolocation-permissions");
|
||||
private readonly _previousLocationGrant: UIEventSource<string>;
|
||||
private readonly _layoutToUse: UIEventSource<LayoutConfig>;
|
||||
|
||||
|
||||
private readonly _element: BaseUIElement;
|
||||
|
||||
constructor(currentGPSLocation: UIEventSource<{ latlng: any; accuracy: number }>,
|
||||
leafletMap: UIEventSource<L.Map>,
|
||||
layoutToUse: UIEventSource<LayoutConfig>) {
|
||||
super();
|
||||
|
||||
const hasLocation = currentGPSLocation.map((location) => location !== undefined);
|
||||
const previousLocationGrant = LocalStorageSource.Get("geolocation-permissions")
|
||||
const isActive = new UIEventSource<boolean>(false);
|
||||
|
||||
super(
|
||||
hasLocation.map(hasLocation => {
|
||||
|
||||
if (hasLocation) {
|
||||
return Svg.crosshair_blue_ui()
|
||||
}
|
||||
if (isActive.data) {
|
||||
return Svg.crosshair_blue_center_ui();
|
||||
}
|
||||
return Svg.crosshair_ui();
|
||||
}, [isActive])
|
||||
);
|
||||
this._isActive = isActive;
|
||||
this._permission = new UIEventSource<string>("")
|
||||
this._previousLocationGrant = previousLocationGrant;
|
||||
this._currentGPSLocation = currentGPSLocation;
|
||||
this._leafletMap = leafletMap;
|
||||
this._layoutToUse = layoutToUse;
|
||||
this._hasLocation = currentGPSLocation.map((location) => location !== undefined);
|
||||
|
||||
this._hasLocation = hasLocation;
|
||||
const self = this;
|
||||
|
||||
const currentPointer = this._isActive.map(isActive => {
|
||||
|
@ -77,28 +91,11 @@ export default class GeoLocationHandler extends UIElement {
|
|||
currentPointer.addCallbackAndRun(pointerClass => {
|
||||
self.SetClass(pointerClass);
|
||||
})
|
||||
this._element = new VariableUiElement(
|
||||
this._hasLocation.map(hasLocation => {
|
||||
|
||||
if (hasLocation) {
|
||||
return Svg.crosshair_blue_ui()
|
||||
}
|
||||
if (self._isActive.data) {
|
||||
return Svg.crosshair_blue_center_ui();
|
||||
}
|
||||
return Svg.crosshair_ui();
|
||||
}, [this._isActive])
|
||||
);
|
||||
|
||||
this.onClick(() => self.init(true))
|
||||
this.init(false)
|
||||
|
||||
self.init(false)
|
||||
|
||||
}
|
||||
|
||||
|
||||
protected InnerRender(): string | BaseUIElement {
|
||||
return this._element
|
||||
}
|
||||
|
||||
private init(askPermission: boolean) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -2,33 +2,33 @@ import {Changes} from "../Osm/Changes";
|
|||
import Constants from "../../Models/Constants";
|
||||
import {UIEventSource} from "../UIEventSource";
|
||||
|
||||
export default class PendingChangesUploader{
|
||||
|
||||
private lastChange : Date;
|
||||
|
||||
export default class PendingChangesUploader {
|
||||
|
||||
private lastChange: Date;
|
||||
|
||||
constructor(changes: Changes, selectedFeature: UIEventSource<any>) {
|
||||
const self = this;
|
||||
this.lastChange = new Date();
|
||||
changes.pending.addCallback(() => {
|
||||
self.lastChange = new Date();
|
||||
|
||||
|
||||
window.setTimeout(() => {
|
||||
const diff = (new Date().getTime() - self.lastChange.getTime()) / 1000;
|
||||
if(Constants.updateTimeoutSec >= diff - 1){
|
||||
if (Constants.updateTimeoutSec >= diff - 1) {
|
||||
changes.flushChanges("Flushing changes due to timeout");
|
||||
}
|
||||
}, Constants.updateTimeoutSec * 1000);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
selectedFeature
|
||||
.stabilized(10000)
|
||||
.addCallback(feature => {
|
||||
if(feature === undefined){
|
||||
// The popup got closed - we flush
|
||||
changes.flushChanges("Flushing changes due to popup closed");
|
||||
}
|
||||
});
|
||||
if (feature === undefined) {
|
||||
// The popup got closed - we flush
|
||||
changes.flushChanges("Flushing changes due to popup closed");
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseout', e => {
|
||||
// @ts-ignore
|
||||
|
@ -36,7 +36,7 @@ export default class PendingChangesUploader{
|
|||
changes.flushChanges("Flushing changes due to focus lost");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
document.onfocus = () => {
|
||||
changes.flushChanges("OnFocus")
|
||||
}
|
||||
|
@ -44,18 +44,17 @@ export default class PendingChangesUploader{
|
|||
document.onblur = () => {
|
||||
changes.flushChanges("OnFocus")
|
||||
}
|
||||
try{
|
||||
try {
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
changes.flushChanges("Visibility change")
|
||||
}, false);
|
||||
}catch(e){
|
||||
} catch (e) {
|
||||
console.warn("Could not register visibility change listener", e)
|
||||
}
|
||||
|
||||
|
||||
window.onbeforeunload = function(e){
|
||||
|
||||
if(changes.pending.data.length == 0){
|
||||
function onunload(e) {
|
||||
if (changes.pending.data.length == 0) {
|
||||
return;
|
||||
}
|
||||
changes.flushChanges("onbeforeunload - probably closing or something similar");
|
||||
|
@ -63,8 +62,11 @@ export default class PendingChangesUploader{
|
|||
return "Saving your last changes..."
|
||||
}
|
||||
|
||||
window.onbeforeunload = onunload
|
||||
// https://stackoverflow.com/questions/3239834/window-onbeforeunload-not-working-on-the-ipad#4824156
|
||||
window.addEventListener("pagehide", onunload)
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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}])
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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)))
|
||||
|
|
|
@ -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)
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|