Merge branch 'develop'

This commit is contained in:
Pieter Vander Vennet 2021-07-05 14:31:58 +02:00
commit 1ae36bbfaa
166 changed files with 6431 additions and 7664 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

@ -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();
}
}

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

@ -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) {

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

@ -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;
}
}

View file

@ -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)
}
}
}

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 926 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 220 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 167 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

After

Width:  |  Height:  |  Size: 372 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 643 KiB

After

Width:  |  Height:  |  Size: 332 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

After

Width:  |  Height:  |  Size: 409 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

After

Width:  |  Height:  |  Size: 284 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 KiB

After

Width:  |  Height:  |  Size: 442 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 KiB

After

Width:  |  Height:  |  Size: 404 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 271 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 263 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 KiB

After

Width:  |  Height:  |  Size: 350 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 568 KiB

After

Width:  |  Height:  |  Size: 628 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 664 KiB

After

Width:  |  Height:  |  Size: 678 KiB

Before After
Before After

View file

@ -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!")

View file

@ -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

View file

@ -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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

1772
Docs/Tools/stats2021Q1.csv Normal file

File diff suppressed because it is too large Load diff

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

@ -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) {

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

@ -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)
}
}

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
})
}
}
)
}
}

Some files were not shown because too many files have changed in this diff Show more