Add MapComplete

This commit is contained in:
Pieter Vander Vennet 2020-06-24 00:35:19 +02:00
commit 6187122294
61 changed files with 107059 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
dist/*
node_modules

90
Helpers.ts Normal file
View File

@ -0,0 +1,90 @@
import {OsmConnection} from "./Logic/OsmConnection";
import {Changes} from "./Logic/Changes";
import {UIEventSource} from "./UI/UIEventSource";
import {PendingChanges} from "./UI/PendingChanges";
export class Helpers {
static SetupAutoSave(changes: Changes, secondsTillChangesAreSaved : UIEventSource<number>) {
// This little function triggers the actual upload:
// Either when more then three answers are selected, or when no new answer has been added for the last 20s
// @ts-ignore
window.decreaseTime = function () {
var time = secondsTillChangesAreSaved.data;
if (time <= 0) {
if (changes._pendingChanges.length > 0) {
changes.uploadAll(undefined);
}
} else {
secondsTillChangesAreSaved.setData(time - 1000);
}
window.setTimeout('decreaseTime()', 1000);
};
changes.pendingChangesES.addCallback(function () {
var c = changes._pendingChanges.length;
if (c > 10) {
secondsTillChangesAreSaved.setData(0);
changes.uploadAll(undefined);
return;
}
if (c > 0) {
secondsTillChangesAreSaved.setData(5000);
}
});
// @ts-ignore
window.decreaseTime(); // The timer keeps running...
}
/**
* All elements with class 'activate-osm-authentication' are loaded and get an 'onclick' to authenticate
* @param osmConnection
*/
static registerActivateOsmAUthenticationClass(osmConnection: OsmConnection) {
const authElements = document.getElementsByClassName("activate-osm-authentication");
for (let i = 0; i < authElements.length; i++) {
let element = authElements.item(i);
// @ts-ignore
element.onclick = function () {
osmConnection.AttemptLogin();
}
}
}
/*
* Registers an action that:
* -> Upload everything to OSM
* -> Asks the user not to close. The 'not to close' dialog should profide enough time to upload
* -> WHen uploading is done, the window is closed anyway
*/
static LastEffortSave(changes : Changes){
window.addEventListener("beforeunload", function (e) {
// Quickly save everyting!
if (changes._pendingChanges.length == 0) {
return "";
}
changes.uploadAll(function () {
window.close()
});
var confirmationMessage = "Nog even geduld - je laatset wijzigingen worden opgeslaan!";
(e || window.event).returnValue = confirmationMessage; //Gecko + IE
return confirmationMessage; //Webkit, Safari, Chrome
});
}
}

77
LayerDefinition.ts Normal file
View File

@ -0,0 +1,77 @@
import {Basemap} from "./Logic/Basemap";
import {ElementStorage} from "./Logic/ElementStorage";
import {Changes} from "./Logic/Changes";
import {Question, QuestionDefinition} from "./Logic/Question";
import {TagMapping, TagMappingOptions} from "./UI/TagMapping";
import {UIEventSource} from "./UI/UIEventSource";
import {QuestionPicker} from "./UI/QuestionPicker";
import {VerticalCombine} from "./UI/VerticalCombine";
import {UIElement} from "./UI/UIElement";
import {Tag, TagsFilter} from "./Logic/TagsFilter";
import {FilteredLayer} from "./Logic/FilteredLayer";
import {ImageCarousel} from "./UI/Image/ImageCarousel";
import {FixedUiElement} from "./UI/FixedUiElement";
export class LayerDefinition {
name: string;
newElementTags: Tag[]
icon: string;
minzoom: number;
overpassFilter: TagsFilter;
elementsToShow: (TagMappingOptions | QuestionDefinition | UIElement)[];
questions: QuestionDefinition[]; // Questions are shown below elementsToShow in a questionPicker
style: (tags: any) => any;
removeContainedElements : boolean = false;
removeTouchingElements: boolean = false;
asLayer(basemap: Basemap, allElements: ElementStorage, changes: Changes):
FilteredLayer {
const self = this;
function generateInfoBox(tagsES: UIEventSource<any>) {
var infoboxes: UIElement[] = [];
for (const uiElement of self.elementsToShow) {
if (uiElement instanceof QuestionDefinition) {
const questionDef = uiElement as QuestionDefinition;
const question = new Question(changes, questionDef);
infoboxes.push(question.CreateHtml(tagsES));
} else if (uiElement instanceof TagMappingOptions) {
const tagMappingOpt = uiElement as TagMappingOptions;
infoboxes.push(new TagMapping(tagMappingOpt, tagsES))
} else {
const ui = uiElement as UIElement;
infoboxes.push(ui);
}
}
infoboxes.push(new ImageCarousel(tagsES));
infoboxes.push(new FixedUiElement("<div style='width:750px'></div>"));
const qbox = new QuestionPicker(changes.asQuestions(self.questions), tagsES);
infoboxes.push(qbox);
return new VerticalCombine(infoboxes);
}
return new FilteredLayer(
this.name,
basemap, allElements, changes,
this.overpassFilter,
this.removeContainedElements, this.removeTouchingElements,
generateInfoBox,
this.style);
}
}

90
Layers/Artwork.ts Normal file
View File

@ -0,0 +1,90 @@
import {LayerDefinition} from "../LayerDefinition";
import {FixedUiElement} from "../UI/FixedUiElement";
import L from "leaflet";
import {CommonTagMappings} from "./CommonTagMappings";
import {TagMappingOptions} from "../UI/TagMapping";
import {QuestionDefinition} from "../Logic/Question";
import {Tag} from "../Logic/TagsFilter";
export class Artwork extends LayerDefinition {
constructor() {
super();
this.name = "artwork";
this.newElementTags = [new Tag("tourism", "artwork")];
this.icon = "./assets/statue.svg";
this.overpassFilter = new Tag("tourism", "artwork");
this.minzoom = 13;
this.questions = [
QuestionDefinition.radioAndTextQuestion("What kind of artwork is this?", 10, "artwork_type",
[
{text: "A statue", value: "statue"},
{text: "A bust (thus a statue, but only of the head and shoulders)", value: "bust"},
{text: "A sculpture", value: "sculpture"},
{text: "A mural painting", value: "mural"},
{text: "A painting", value: "painting"},
{text: "A graffiti", value: "graffiti"},
{text: "A relief", value: "relief"},
{text: "An installation", value: "installation"}]),
QuestionDefinition.textQuestion("Whom or what is depicted in this statue?", "subject", 20).addUnrequiredTag("subject:wikidata","*"),
QuestionDefinition.textQuestion("Is there an inscription on this artwork?", "inscription", 16),
QuestionDefinition.textQuestion("What is the name of this artwork? If there is no explicit name, skip the question", "name", 15),
];
this.style = function (tags) {
return {
icon: new L.icon({
iconUrl: "assets/statue.svg",
iconSize: [40, 40],
text: "hi"
})
};
}
this.elementsToShow = [
new TagMappingOptions(
{
key: "name",
template: "<h2>Artwork '{name}'</h2>",
missing: "Artwork"
}),
new TagMappingOptions({
key: "artwork_type",
template: "This artwork is a {artwork_type}"
}),
new TagMappingOptions({
key: "artist_name",
template: "This artwork was made by {artist_name}"
}),
new TagMappingOptions({
key: "subject",
template: "This artwork depicts {subject}"
}),
new TagMappingOptions({
key: "subject:wikidata",
template: "<a href='https://www.wikidata.org/wiki/{subject:wikidata}' target='_blank'>See more data about the subject</a>"
}),
new TagMappingOptions({
key: "website",
template: "<a href='{website}' target='_blank'>Website of the statue</a>"
}),
new TagMappingOptions({key: "image", template: "<img class='popupImg' alt='image' src='{image}' />"}),
CommonTagMappings.osmLink
];
}
}

69
Layers/Bookcases.ts Normal file
View File

@ -0,0 +1,69 @@
import {LayerDefinition} from "../LayerDefinition";
import {Quests} from "../Quests";
import {FixedUiElement} from "../UI/FixedUiElement";
import {TagMapping, TagMappingOptions} from "../UI/TagMapping";
import L from "leaflet";
import {QuestionDefinition} from "../Logic/Question";
import {CommonTagMappings} from "./CommonTagMappings";
import {Tag} from "../Logic/TagsFilter";
export class Bookcases extends LayerDefinition {
constructor() {
super();
this.name = "boekenkast";
this.newElementTags = [new Tag( "amenity", "public_bookcase")];
this.icon = "./assets/bookcase.svg";
this.overpassFilter = new Tag("amenity","public_bookcase");
this.minzoom = 13;
this.questions = [
QuestionDefinition.noNameOrNameQuestion("Wat is de naam van dit boekenruilkastje?", "Dit boekenruilkastje heeft niet echt een naam", 20),
QuestionDefinition.textQuestion("Hoeveel boeken kunnen er in?", "capacity", 15),
QuestionDefinition.textQuestion("Heeft dit boekenkastje een peter, meter of voogd?", "operator", 10),
// QuestionDefinition.textQuestion("Wie kunnen we (per email) contacteren voor dit boekenruilkastje?", "email", 5),
]
;
this.style = function (tags) {
return {
icon: new L.icon({
iconUrl: "assets/bookcase.svg",
iconSize: [40, 40]
})
};
}
this.elementsToShow = [
new TagMappingOptions({
key: "name",
template: "<h2>{name}</h2>",
missing: "<h2>Boekenruilkastje</h2>"
}
),
new TagMappingOptions({key: "capacity", template: "Plaats voor {capacity} boeken"}),
new TagMappingOptions({key: "operator", template: "Onder de hoede van {operator}"}),
new TagMappingOptions({
key: "website",
mapping: "Meer informatie beschikbaar op <a href='{website}'>{website}</a>"
}),
new TagMappingOptions({key: "start_date", template: "Geplaatst op {start_date}"}),
new TagMappingOptions({key: "brand", template: "Deel van het netwerk {brand}"}),
new TagMappingOptions({key: "ref", template: "Referentienummer {ref}"}),
new TagMappingOptions({key: "description", template: "Extra beschrijving: <br /> <p>{description}</p>"}),
CommonTagMappings.osmLink
]
;
}
}

83
Layers/Bos.ts Normal file
View File

@ -0,0 +1,83 @@
import {LayerDefinition} from "../LayerDefinition";
import {Quests} from "../Quests";
import {TagMappingOptions} from "../UI/TagMapping";
import L from "leaflet"
import {CommonTagMappings} from "./CommonTagMappings";
import {Or, Tag} from "../Logic/TagsFilter";
export class Bos extends LayerDefinition {
constructor() {
super();
this.name = "bos";
this.icon = "./assets/tree_white_background.svg";
this.overpassFilter = new Or([
new Tag("natural", "wood"),
new Tag("landuse", "forest"),
new Tag("natural", "scrub")
]
);
this.newElementTags = [
new Tag("landuse", "forest"),
new Tag("fixme", "Toegevoegd met MapComplete, geometry nog uit te tekenen")
];
this.removeContainedElements = true;
this.minzoom = 14;
this.questions = [Quests.nameOf(this.name), Quests.accessNatureReserve, Quests.operator];
this.style = this.generateStyleFunction();
this.elementsToShow = [
new TagMappingOptions({
key: "name",
template: "<h2>{name}</h2>",
missing: "<h2>Naamloos bos</h2>"
}),
CommonTagMappings.access,
CommonTagMappings.operator,
CommonTagMappings.osmLink
];
}
private readonly treeIcon = new L.icon({
iconUrl: "assets/tree_white_background.svg",
iconSize: [40, 40]
})
private generateStyleFunction() {
const self = this;
return function (properties: any) {
let questionSeverity = 0;
for (const qd of self.questions) {
if (qd.isApplicable(properties)) {
questionSeverity = Math.max(questionSeverity, qd.severity);
}
}
let colormapping = {
0: "#00bb00",
1: "#00ff00",
10: "#dddd00",
20: "#ff0000"
};
let colour = colormapping[questionSeverity];
while (colour == undefined) {
questionSeverity--;
colormapping[questionSeverity];
}
return {
color: colour,
icon: self.treeIcon
};
};
}
}

View File

@ -0,0 +1,33 @@
import {TagMappingOptions} from "../UI/TagMapping";
export class CommonTagMappings {
public static access = new TagMappingOptions({
key: "access",
mapping: {
yes: "Vrij toegankelijk (op de paden)",
no: "Niet toegankelijk",
private: "Niet toegankelijk, want privegebied",
permissive: "Toegankelijk, maar het is privegebied",
guided: "Enkel met gids of op activiteit"
}
});
public static operator = new TagMappingOptions({
key: "operator",
template: "Beheer door {operator}",
mapping: {
private: 'Beheer door een privepersoon of organisatie'
}
});
public static osmLink = new TagMappingOptions({
key: "id",
mapping: {
"node/-1": "Over enkele momenten sturen we je punt naar OpenStreetMap"
},
template: "<a href='https://osm.org/{id}'> Op OSM</a>"
})
}

58
Layers/GrbToFix.ts Normal file
View File

@ -0,0 +1,58 @@
import {LayerDefinition} from "../LayerDefinition";
import {QuestionDefinition} from "../Logic/Question";
import {TagMappingOptions} from "../UI/TagMapping";
import {CommonTagMappings} from "./CommonTagMappings";
import L from "leaflet"
import {Regex} from "../Logic/TagsFilter";
export class GrbToFix extends LayerDefinition {
constructor() {
super();
this.name = "grb";
this.newElementTags = undefined;
this.icon = "./assets/star.svg";
this.overpassFilter = new Regex("fixme","GRB");
this.minzoom = 13;
this.questions = [
QuestionDefinition.GrbNoNumberQuestion(),
QuestionDefinition.GrbHouseNumberQuestion()
];
this.style = function (tags) {
return {
icon: new L.icon({
iconUrl: "assets/star.svg",
iconSize: [40, 40],
text: "hi"
})
};
}
this.elementsToShow = [
new TagMappingOptions(
{
key: "fixme",
template: "<h2>Fixme</h2>{fixme}",
}),
new TagMappingOptions({
key: "addr:street",
template: "Straat: <b>{addr:street}</b>",
missing: "<b>Geen straat bekend</b>"
}),
new TagMappingOptions({
key: "addr:housenumber",
template: "Nummer: <b>{addr:housenumber}</b>",
missing: "<b>Geen huisnummer bekend</b>"
}),
CommonTagMappings.osmLink
];
}
}

158
Layers/KnownSet.ts Normal file
View File

@ -0,0 +1,158 @@
import {LayerDefinition} from "../LayerDefinition";
import {NatureReserves} from "./NatureReserves";
import {Toilets} from "./Toilets";
import {Bos} from "./Bos";
import {Park} from "./Park";
import {Playground} from "./Playground";
import {Bookcases} from "./Bookcases";
import {Artwork} from "./Artwork";
import {GrbToFix} from "./GrbToFix";
export class KnownSet {
public name: string;
public title: string;
public layers: LayerDefinition[];
public welcomeMessage: string;
public gettingStartedPlzLogin: string;
public welcomeBackMessage: string;
public startzoom: number;
public startLon: number;
public startLat: number;
static allSets : any = {};
constructor(
name: string,
title: string,
layers: LayerDefinition[],
startzoom: number,
startLat: number,
startLon: number,
welcomeMessage: string,
gettingStartedPlzLogin: string,
welcomeBackMessage: string
) {
this.title = title;
this.startLon = startLon;
this.startLat = startLat;
this.startzoom = startzoom;
this.name = name;
this.layers = layers;
this.welcomeMessage = welcomeMessage;
this.gettingStartedPlzLogin = gettingStartedPlzLogin;
this.welcomeBackMessage = welcomeBackMessage;
KnownSet.allSets[this.name] = this;
}
static groen = new KnownSet("groen",
"Buurtnatuur",
[new NatureReserves(), new Park(), new Bos(), new Playground()],
14,
51.2,
3.2,
" <img src=\"assets/groen.svg\" alt=\"logo partij groen\" id=\"logo\"/>\n" +
"\n" +
" <h3>Buurtnatuur meten</h3>\n" +
"\n" +
"\n" +
" Met deze tool willen we de natuur in Belgie beter inventariseren. <br/>\n" +
" In welke natuurgebieden kan men terecht?<br/>\n" +
" In welke bossen is het goed vertoeven?<br>\n" +
" <p>Natuur maakt immers gelukkig!</p>\n" +
"\n" +
"<p>De data komt van <b>OpenStreetMap</b> en je antwoorden worden daar ook opgeslaan. " +
"Omdat iedereen aan deze data bijdraagt, kunnen we geen garantie op correctheid bieden en heeft deze data geen juridische waarde</p>\n" +
"<p>Je <b>privacy</b> is belangrijk. We tellen wel hoeveel personen de website bezoeken. Om je niet dubbel te tellen wordt er één coockie bijgehouden waar geen persoonlijke informatie in staat. " +
"Als je inlogt, komt er een tweede coockie bij met je inloggegevens.</p>\n",
" <p>Wil je meehelpen? <br/>\n" +
" Begin dan met <a href=\"https://www.openstreetmap.org/user/new\" target=\"_blank\">een account\n" +
" te maken</a> of\n" +
" <span onclick=\"authOsm()\" class=\"activate-osm-authentication\">in te loggen</span>.</p>",
"Je bent aangemeld. Klik op een element om vragen te beantwoorden."
);
static openToiletMap = new KnownSet(
"toilets",
"Open Toilet Map",
[new Toilets()],
14,
51.2,
3.2,
" <h3>Open Toilet Map</h3>\n" +
"\n" +
"<p>Help us to create the most complete map about <i>all</i> the toilets in the world, based on openStreetMap." +
"One can answer questions here, which help users all over the world to find an accessible toilet, close to them.</p>"
,
" <p>Start by <a href=\"https://www.openstreetmap.org/user/new\" target=\"_blank\">creating an account\n" +
" </a> or by " +
" <span onclick=\"authOsm()\" class=\"activate-osm-authentication\">logging in</span>.</p>",
"Start by clicking a pin and answering the questions"
);
static bookcases = new KnownSet(
"bookcases",
"Open Bookcase Map",
[new Bookcases()],
14,
51.2,
3.2,
" <h3>Open BoekenkastjesKaart</h3>\n" +
"\n" +
"<p>" +
"Help mee met het creëeren van een volledige kaart met alle boekenruilkastjes!" +
"Een boekenruilkastje is een vaste plaats in publieke ruimte waar iedereen een boek in kan zetten of uit kan meenemen." +
"Meestal een klein kastje of doosje dat op straat staat, maar ook een oude telefooncellen of een schap in een station valt hieronder."+
"</p>"
,
" <p>Begin met <a href=\"https://www.openstreetmap.org/user/new\" target=\"_blank\">het aanmaken van een account\n" +
" </a> of door je " +
" <span onclick=\"authOsm()\" class=\"activate-osm-authentication\">aan te melden</span>.</p>",
"Klik op een boekenruilkastje om vragen te beantwoorden"
);
static statues = new KnownSet(
"statues",
"Open Artwork Map",
[new Artwork()],
10,
50.8435,
4.3688,
" <h3>Open Statue Map</h3>\n" +
"\n" +
"<p>" +
"Help with creating a map of all statues all over the world!"
,
" <p>Start by <a href=\"https://www.openstreetmap.org/user/new\" target=\"_blank\">creating an account\n" +
" </a> or by " +
" <span onclick=\"authOsm()\" class=\"activate-osm-authentication\">logging in</span>.</p>",
"Start by clicking a pin and answering the questions"
);
static grb = new KnownSet(
"grb",
"Grb import fix tool",
[new GrbToFix()],
10,
50.8435,
4.3688,
"<h3>GRB Fix tool</h3>\n" +
"\n" +
"Expert use only"
,
"",""
);
}

70
Layers/NatureReserves.ts Normal file
View File

@ -0,0 +1,70 @@
import {LayerDefinition} from "../LayerDefinition";
import {Quests} from "../Quests";
import {TagMappingOptions} from "../UI/TagMapping";
import L from "leaflet"
import {CommonTagMappings} from "./CommonTagMappings";
import {Tag} from "../Logic/TagsFilter";
export class NatureReserves extends LayerDefinition {
constructor() {
super();
this.name = "natuurgebied";
this.icon = "./assets/tree_white_background.svg";
this.overpassFilter = new Tag("leisure", "nature_reserve");
this.removeTouchingElements = true;
this.newElementTags = [new Tag("leisure", "nature_reserve"),
new Tag("fixme", "Toegevoegd met MapComplete, geometry nog uit te tekenen")]
this.minzoom = 13;
this.questions = [Quests.nameOf(this.name), Quests.accessNatureReserve, Quests.operator];
this.style = this.generateStyleFunction();
this.elementsToShow = [
new TagMappingOptions({
key: "name",
template: "<h2>{name}</h2>",
missing: "<h2>Naamloos gebied</h2>"
}),
CommonTagMappings.access,
CommonTagMappings.operator,
CommonTagMappings.osmLink
];
}
private readonly treeIcon = new L.icon({
iconUrl: "assets/tree_white_background.svg",
iconSize: [40, 40]
})
private generateStyleFunction() {
const self = this;
return function (properties: any) {
let questionSeverity = 0;
for (const qd of self.questions) {
if (qd.isApplicable(properties)) {
questionSeverity = Math.max(questionSeverity, qd.severity);
}
}
let colormapping = {
0: "#00bb00",
1: "#00ff00",
10: "#dddd00",
20: "#ff0000"
};
let colour = colormapping[questionSeverity];
while (colour == undefined) {
questionSeverity--;
colormapping[questionSeverity];
}
return {
color: colour,
icon: self.treeIcon
};
};
}
}

74
Layers/Park.ts Normal file
View File

@ -0,0 +1,74 @@
import {LayerDefinition} from "../LayerDefinition";
import {Quests} from "../Quests";
import {TagMappingOptions} from "../UI/TagMapping";
import L from "leaflet"
import {CommonTagMappings} from "./CommonTagMappings";
import {Tag} from "../Logic/TagsFilter";
export class Park extends LayerDefinition {
constructor() {
super();
this.name = "park";
this.icon = "./assets/tree_white_background.svg";
this.overpassFilter = new Tag("leisure","park");
this.newElementTags = [new Tag("leisure", "park"),
new Tag("fixme", "Toegevoegd met MapComplete, geometry nog uit te tekenen")];
this.removeTouchingElements = true;
this.minzoom = 13;
this.questions = [Quests.nameOf("park")];
this.style = this.generateStyleFunction();
this.elementsToShow = [
new TagMappingOptions({
key: "name",
template: "<h2>{name}</h2>",
missing: "<h2>Naamloos park</h2>"
}),
CommonTagMappings.access,
CommonTagMappings.operator,
CommonTagMappings.osmLink,
];
}
private readonly treeIcon = new L.icon({
iconUrl: "assets/tree_white_background.svg",
iconSize: [40, 40]
})
private generateStyleFunction() {
const self = this;
return function (properties: any) {
let questionSeverity = 0;
for (const qd of self.questions) {
if (qd.isApplicable(properties)) {
questionSeverity = Math.max(questionSeverity, qd.severity);
}
}
let colormapping = {
0: "#00bb00",
1: "#00ff00",
10: "#dddd00",
20: "#ff0000"
};
let colour = colormapping[questionSeverity];
while (colour == undefined) {
questionSeverity--;
colormapping[questionSeverity];
}
return {
color: colour,
icon: self.treeIcon
};
};
}
}

72
Layers/Playground.ts Normal file
View File

@ -0,0 +1,72 @@
import {LayerDefinition} from "../LayerDefinition";
import {Quests} from "../Quests";
import {TagMappingOptions} from "../UI/TagMapping";
import L from "leaflet"
import {CommonTagMappings} from "./CommonTagMappings";
import {Tag} from "../Logic/TagsFilter";
export class Playground extends LayerDefinition {
constructor() {
super();
this.name = "speeltuin";
this.icon = "./assets/tree_white_background.svg";
this.overpassFilter = new Tag("leisure","playground");
this.newElementTags = [new Tag("leisure", "playground"), new Tag( "fixme", "Toegevoegd met MapComplete, geometry nog uit te tekenen")]
this.removeContainedElements = true;
this.minzoom = 13;
this.questions = [Quests.nameOf(this.name), Quests.accessNatureReserve];
this.style = this.generateStyleFunction();
this.elementsToShow = [
new TagMappingOptions({
key: "name",
template: "<h2>{name}</h2>",
missing: "<h2>Naamloos park</h2>"
}),
CommonTagMappings.access,
CommonTagMappings.operator,
CommonTagMappings.osmLink
];
}
private readonly treeIcon = new L.icon({
iconUrl: "assets/tree_white_background.svg",
iconSize: [40, 40]
})
private generateStyleFunction() {
const self = this;
return function (properties: any) {
let questionSeverity = 0;
for (const qd of self.questions) {
if (qd.isApplicable(properties)) {
questionSeverity = Math.max(questionSeverity, qd.severity);
}
}
let colormapping = {
0: "#00bb00",
1: "#00ff00",
10: "#dddd00",
20: "#ff0000"
};
let colour = colormapping[questionSeverity];
while (colour == undefined) {
questionSeverity--;
colormapping[questionSeverity];
}
return {
color: colour,
icon: self.treeIcon
};
};
}
}

92
Layers/Toilets.ts Normal file
View File

@ -0,0 +1,92 @@
import {LayerDefinition} from "../LayerDefinition";
import {Quests} from "../Quests";
import {FixedUiElement} from "../UI/FixedUiElement";
import {TagMappingOptions} from "../UI/TagMapping";
import L from "leaflet";
import {CommonTagMappings} from "./CommonTagMappings";
import {Tag} from "../Logic/TagsFilter";
export class Toilets extends LayerDefinition{
constructor() {
super();
this.name="toilet";
this.newElementTags = [new Tag( "amenity", "toilets")];
this.icon = "./assets/toilets.svg";
this.overpassFilter = new Tag("amenity","toilets");
this.minzoom = 13;
this.questions = [Quests.hasFee,
Quests.toiletsWheelChairs,
Quests.toiletsChangingTable,
Quests.toiletsChangingTableLocation,
Quests.toiletsPosition];
this.style = function(tags){
if(tags.wheelchair == "yes"){
return {icon : new L.icon({
iconUrl: "assets/wheelchair.svg",
iconSize: [40, 40]
})};
}
return {icon : new L.icon({
iconUrl: "assets/toilets.svg",
iconSize: [40, 40]
})};
}
this.elementsToShow = [
new FixedUiElement("<h2>Toiletten</h2>"),
new TagMappingOptions({
key: "access",
mapping: {
yes: "Toegankelijk",
no: "Niet toegankelijk",
private: "Niet toegankelijk",
customers: "Enkel voor klanten",
}
}),
new TagMappingOptions({
key: "fee",
mapping: {
yes: "Betalend",
no: "Gratis",
["0"]: "Gratis"
},
template: "Betalend, men vraagt {fee}"
}),
new TagMappingOptions({
key: "toilets:position",
mapping: {
seated: 'Gewone zittoiletten',
urinal: 'Een enkele urinoir',
urinals: 'Urinoirs',
['urinals;seated']: "Urinoirs en gewone toiletten",
['seated;urinals']: "Urinoirs en gewone toiletten",
}
}),
new TagMappingOptions({
key: "wheelchair",
mapping: {
yes: "Rolstoeltoegankelijk",
no: "Niet Rolstoeltoegankelijk",
limited: "Beperkt rolstoeltoegankelijk",
}
}),
CommonTagMappings.osmLink
];
}
}

79
Logic/Basemap.ts Normal file
View File

@ -0,0 +1,79 @@
import L from "leaflet"
import {UIEventSource} from "../UI/UIEventSource";
// Contains all setup and baselayers for Leaflet stuff
export class Basemap {
// @ts-ignore
public map: Map;
public Location: UIEventSource<{ zoom: number, lat: number, lon: number }>;
private aivLucht2013Layer = L.tileLayer.wms('https://geoservices.informatievlaanderen.be/raadpleegdiensten/OGW/wms?s',
{
layers: "OGWRGB13_15VL",
attribution: "Luchtfoto's van © AIV Vlaanderen (2013-2015) | Data van OpenStreetMap"
});
private aivLuchtLatestLayer = L.tileLayer("https://tile.informatievlaanderen.be/ws/raadpleegdiensten/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&" +
"LAYER=omwrgbmrvl&STYLE=&FORMAT=image/png&tileMatrixSet=GoogleMapsVL&tileMatrix={z}&tileRow={y}&tileCol={x}",
{
// omwrgbmrvl
attribution: 'Map Data <a href="osm.org">OpenStreetMap</a> | Luchtfoto\'s van © AIV Vlaanderen (Laatste) © AGIV',
maxZoom: 20,
minZoom: 1,
wmts: true
});
private osmLayer = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png",
{
attribution: 'Map Data and background © <a href="osm.org">OpenStreetMap</a>',
maxZoom: 19,
minZoom: 1
});
private osmBeLayer = L.tileLayer("https://tile.osm.be/osmbe/{z}/{x}/{y}.png",
{
attribution: 'Map Data and background © <a href="osm.org">OpenStreetMap</a> | <a href="https://geo6.be/">Tiles courtesy of Geo6</a>',
maxZoom: 18,
minZoom: 1
});
private grbLayer = L.tileLayer("https://tile.informatievlaanderen.be/ws/raadpleegdiensten/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=grb_bsk&STYLE=&FORMAT=image/png&tileMatrixSet=GoogleMapsVL&tileMatrix={z}&tileCol={x}&tileRow={y}",
{
attribution: 'Map Data <a href="osm.org">OpenStreetMap</a> | Background <i>Grootschalig ReferentieBestand</i>(GRB) © AGIV',
maxZoom: 20,
minZoom: 1,
wmts: true
});
private baseLayers = {
"OpenStreetMap Be": this.osmBeLayer,
"OpenStreetMap": this.osmLayer,
"Luchtfoto AIV Vlaanderen (2013-2015)": this.aivLucht2013Layer,
"Luchtfoto AIV Vlaanderen (laatste)": this.aivLuchtLatestLayer,
"GRB Vlaanderen": this.grbLayer
};
constructor(leafletElementId: string, location: UIEventSource<{ zoom: number, lat: number, lon: number }>) {
this. map = L.map(leafletElementId, {
center: [location.data.lat, location.data.lon],
zoom: location.data.zoom,
layers: [this.osmLayer]
});
this.Location = location;
L.control.layers(this.baseLayers).addTo(this.map);
this.map.zoomControl.setPosition("bottomleft");
const self = this;
this.map.on("moveend", function () {
location.data.zoom = self.map.getZoom();
location.data.lat = self.map.getCenter().lat;
location.data.lon = self.map.getCenter().lon;
location.ping();
})
}
}

257
Logic/Changes.ts Normal file
View File

@ -0,0 +1,257 @@
/**
* Handles all changes made to OSM.
* Needs an authenticator via OsmConnection
*/
import {OsmConnection} from "./OsmConnection";
import {OsmNode, OsmObject} from "./OsmObject";
import {ElementStorage} from "./ElementStorage";
import {UIEventSource} from "../UI/UIEventSource";
import {Question, QuestionDefinition} from "./Question";
import {Tag} from "./TagsFilter";
export class Changes {
private static _nextId = -1; // New assined ID's are negative
private readonly login: OsmConnection;
public readonly _allElements: ElementStorage;
public _pendingChanges: { elementId: string, key: string, value: string }[] = []; // Gets reset on uploadAll
private newElements: OsmObject[] = []; // Gets reset on uploadAll
public readonly pendingChangesES = new UIEventSource(this._pendingChanges);
private readonly centerMessage: UIEventSource<string>;
constructor(login: OsmConnection, allElements: ElementStorage, centerMessage: UIEventSource<string>) {
this.login = login;
this._allElements = allElements;
this.centerMessage = centerMessage;
}
/**
* Adds a change to the pending changes
* @param elementId
* @param key
* @param value
*/
addChange(elementId: string, key: string, value: string) {
if (!this.login.userDetails.data.loggedIn) {
this.centerMessage.setData(
"<p>Bedankt voor je antwoord!</p>" +
"<p>Gelieve <span class='activate-osm-authentication'>in te loggen op OpenStreetMap</span> om dit op te slaan.</p>"+
"<p>Nog geen account? <a href=\'https://www.openstreetmap.org/user/new\' target=\'_blank\'>Registreer hier</a></p>"
);
const self = this;
this.login.userDetails.addCallback(() => {
if (self.login.userDetails.data.loggedIn) {
self.centerMessage.setData("");
}
});
return;
}
if (key === undefined || key === null) {
console.log("Invalid key");
return;
}
if (value === undefined || value === null) {
console.log("Invalid value for ",key);
return;
}
const eventSource = this._allElements.getElement(elementId);
eventSource.data[key] = value;
eventSource.ping();
// We get the id from the event source, as that ID might be rewritten
this._pendingChanges.push({elementId: eventSource.data.id, key: key, value: value});
this.pendingChangesES.ping();
}
/**
* Create a new node element at the given lat/long.
* An internal OsmObject is created to upload later on, a geojson represention is returned.
* Note that the geojson version shares the tags (properties) by pointer, but has _no_ id in properties
*/
createElement(basicTags:Tag[], lat: number, lon: number) {
const osmNode = new OsmNode(Changes._nextId);
this.newElements.push(osmNode);
Changes._nextId--;
const id = "node/" + osmNode.id;
osmNode.lat = lat;
osmNode.lon = lon;
const properties = {id: id};
const geojson = {
"type": "Feature",
"properties": properties,
"id": id,
"geometry": {
"type": "Point",
"coordinates": [
lon,
lat
]
}
}
this._allElements.addOrGetElement(geojson);
// The basictags are COPIED, the id is included in the properties
// The tags are not yet written into the OsmObject, but this is applied onto a
for (const kv of basicTags) {
this.addChange(id, kv.key, kv.value); // We use the call, to trigger all the other machinery (including updating the geojson itsel
properties[kv.key] = kv.value;
}
return geojson;
}
public uploadAll(optionalContinuation: (() => void)) {
const self = this;
const pending: { elementId: string; key: string; value: string }[] = this._pendingChanges;
this._pendingChanges = [];
this.pendingChangesES.setData(this._pendingChanges);
const newElements = this.newElements;
this.newElements = [];
const knownElements = {}; // maps string --> OsmObject
function DownloadAndContinue(neededIds, continuation: (() => void)) {
// local function which downloads all the objects one by one
// this is one big loop, running one download, then rerunning the entire function
if (neededIds.length == 0) {
continuation();
return;
}
const neededId = neededIds.pop();
if (neededId in knownElements) {
DownloadAndContinue(neededIds, continuation);
return;
}
console.log("Downloading ", neededId);
OsmObject.DownloadObject(neededId,
function (element) {
knownElements[neededId] = element; // assign the element for later, continue downloading the next element
DownloadAndContinue(neededIds, continuation);
}
);
}
const neededIds = [];
for (const change of pending) {
const id = change.elementId;
if (parseFloat(id.split("/")[1]) < 0) {
console.log("Detected a new element! Exciting!")
} else {
neededIds.push(id);
}
}
DownloadAndContinue(neededIds, function () {
// Here, inside the continuation, we know that all 'neededIds' are loaded in 'knownElements'
// We apply the changes on them
for (const change of pending) {
if (parseInt(change.elementId.split("/")[1]) < 0) {
// This is a new element - we should apply this on one of the new elements
for (const newElement of newElements) {
if (newElement.type + "/" + newElement.id === change.elementId) {
newElement.addTag(change.key, change.value);
}
}
} else {
console.log(knownElements, change.elementId);
knownElements[change.elementId].addTag(change.key, change.value);
// note: addTag will flag changes with 'element.changed' internally
}
}
// Small sanity check for duplicate information
let changedElements = [];
for (const elementId in knownElements) {
const element = knownElements[elementId];
if (element.changed) {
changedElements.push(element);
}
}
if (changedElements.length == 0 && newElements.length == 0) {
console.log("No changes in any object");
return;
}
const handleMapping = function (idMapping) {
for (const oldId in idMapping) {
const newId = idMapping[oldId];
const element = self._allElements.getElement(oldId);
element.data.id = newId;
self._allElements.addElementById(newId, element);
element.ping();
}
}
console.log("Beginning upload...");
// At last, we build the changeset and upload
self.login.UploadChangeset("Updaten van metadata met Mapcomplete",
function (csId) {
let modifications = "";
for (const element of changedElements) {
if (!element.changed) {
continue;
}
modifications += element.ChangesetXML(csId) + "\n";
}
let creations = "";
for (const newElement of newElements) {
creations += newElement.ChangesetXML(csId);
}
let changes = "<osmChange version='0.6' generator='Mapcomplete 0.0.0'>";
if (creations.length > 0) {
changes +=
"<create>" +
creations +
"</create>";
}
if (modifications.length > 0) {
changes +=
"<modify>" +
modifications +
"</modify>";
}
changes += "</osmChange>";
return changes;
},
handleMapping,
optionalContinuation);
});
}
public asQuestions(qs : QuestionDefinition[]){
let ls = [];
for (var i in qs){
ls.push(new Question(this, qs[i]));
}
return ls;
}
}

56
Logic/ElementStorage.ts Normal file
View File

@ -0,0 +1,56 @@
/**
* Keeps track of a dictionary 'elementID' -> element
*/
import {UIEventSource} from "../UI/UIEventSource";
export class ElementStorage {
private _elements = [];
constructor() {
}
addElementById(id: string, eventSource: UIEventSource<any>) {
this._elements[id] = eventSource;
}
addElement(element): UIEventSource<any> {
const eventSource = new UIEventSource<any>(element.properties);
this._elements[element.properties.id] = eventSource;
return eventSource;
}
addOrGetElement(element: any) {
const elementId = element.properties.id;
if (elementId in this._elements) {
const es = this._elements[elementId];
const keptKeys = es.data;
// The element already exists
// We add all the new keys to the old keys
for (const k in element.properties) {
const v = element.properties[k];
if (keptKeys[k] !== v) {
keptKeys[k] = v;
es.ping();
}
}
return es;
}else{
return this.addElement(element);
}
}
getElement(elementId): UIEventSource<any> {
if (elementId in this._elements) {
return this._elements[elementId];
}
console.log("Can not find eventsource with id ", elementId);
}
removeId(oldId: string) {
delete this._elements[oldId];
}
}

222
Logic/FilteredLayer.ts Normal file
View File

@ -0,0 +1,222 @@
import {Basemap} from "./Basemap";
import {TagsFilter, TagUtils} from "./TagsFilter";
import {UIEventSource} from "../UI/UIEventSource";
import {UIElement} from "../UI/UIElement";
import {ElementStorage} from "./ElementStorage";
import {Changes} from "./Changes";
import L from "leaflet"
import {GeoOperations} from "./GeoOperations";
/***
* A filtered layer is a layer which offers a 'set-data' function
* It is initialized with a tagfilter.
*
* When geojson-data is given to 'setData', all the geojson matching the filter, is rendered on this layer.
* If it is not rendered, it is returned in a 'leftOver'-geojson; which can be consumed by the next layer.
*
* This also makes sure that no objects are rendered twice if they are applicable on two layers
*/
export class FilteredLayer {
public readonly name: string;
public readonly filters: TagsFilter;
private readonly _map: Basemap;
private readonly _removeContainedElements;
private readonly _removeTouchingElements;
private readonly _popupContent: ((source: UIEventSource<any>) => UIElement);
private readonly _style: (properties) => any;
private readonly _storage: ElementStorage;
/** The featurecollection from overpass
*/
private _dataFromOverpass;
/** List of new elements, geojson features
*/
private _newElements = [];
/**
* The leaflet layer object which should be removed on rerendering
*/
private _geolayer;
constructor(
name: string,
map: Basemap, storage: ElementStorage,
changes: Changes,
filters: TagsFilter,
removeContainedElements: boolean,
removeTouchingElements: boolean,
popupContent: ((source: UIEventSource<any>) => UIElement),
style: ((properties) => any)) {
if (style === undefined) {
style = function () {
return {};
}
}
this.name = name;
this._map = map;
this.filters = filters;
this._popupContent = popupContent;
this._style = style;
this._storage = storage;
this._removeContainedElements = removeContainedElements;
this._removeTouchingElements = removeTouchingElements;
}
/**
* The main function to load data into this layer.
* The data that is NOT used by this layer, is returned as a geojson object; the other data is rendered
*/
public SetApplicableData(geojson: any): any {
const leftoverFeatures = [];
const selfFeatures = [];
for (const feature of geojson.features) {
// feature.properties contains all the properties
var tags = TagUtils.proprtiesToKV(feature.properties);
if (this.filters.matches(tags)) {
selfFeatures.push(feature);
} else {
leftoverFeatures.push(feature);
}
}
this.RenderLayer({
type: "FeatureCollection",
features: selfFeatures
})
const notShadowed = [];
for (const feature of leftoverFeatures) {
if (this._removeContainedElements || this._removeTouchingElements) {
if (GeoOperations.featureIsContainedInAny(feature, selfFeatures, this._removeTouchingElements)) {
// This feature is filtered away
continue;
}
}
notShadowed.push(feature);
}
return {
type: "FeatureCollection",
features: notShadowed
};
}
public updateStyle() {
if (this._geolayer === undefined) {
return;
}
const self = this;
this._geolayer.setStyle(function (feature) {
return self._style(feature.properties);
});
}
public AddNewElement(element) {
this._newElements.push(element);
console.log("Element added");
this.RenderLayer(this._dataFromOverpass); // Update the layer
}
private RenderLayer(data) {
let self = this;
if (this._geolayer !== undefined && this._geolayer !== null) {
this._map.map.removeLayer(this._geolayer);
}
this._dataFromOverpass = data;
const fusedFeatures = [];
const idsFromOverpass = [];
for (const feature of data.features) {
idsFromOverpass.push(feature.properties.id);
fusedFeatures.push(feature);
}
for (const feature of this._newElements) {
if (idsFromOverpass.indexOf(feature.properties.id) < 0) {
// This element is not yet uploaded or not yet visible in overpass
// We include it in the layer
fusedFeatures.push(feature);
}
}
// We use a new, fused dataset
data = {
type: "FeatureCollection",
features: fusedFeatures
}
// The data is split in two parts: the poinst and the rest
// The points get a special treatment in order to render them properly
// Note that some features might get a point representation as well
this._geolayer = L.geoJSON(data, {
style: function (feature) {
return self._style(feature.properties);
},
pointToLayer: function (feature, latLng) {
const eventSource = self._storage.addOrGetElement(feature);
const style = self._style(feature.properties);
let marker;
if (style.icon === undefined) {
marker = L.marker(latLng);
} else {
marker = L.marker(latLng, {
icon: style.icon
});
}
eventSource.addCallback(function () {
self.updateStyle();
});
const content = self._popupContent(eventSource)
marker.bindPopup(
"<div class='popupcontent'>" +
content.Render() +
"</div>"
).on("popupopen", function () {
content.Activate();
content.Update();
});
return marker;
},
onEachFeature: function (feature, layer) {
let eventSource = self._storage.addOrGetElement(feature);
eventSource.addCallback(function () {
self.updateStyle();
});
const content = self._popupContent(eventSource)
layer.bindPopup(
"<div class='popupcontent'>" +
content.Render() +
"</div>"
).on("popupopen", function () {
content.Activate();
content.Update();
});
}
});
this._geolayer.addTo(this._map.map);
}
}

171
Logic/GeoOperations.ts Normal file
View File

@ -0,0 +1,171 @@
export class GeoOperations {
static featureIsContainedInAny(feature: any, shouldNotContain: any[], noTouching: boolean = false): boolean {
if (feature.geometry.type === "Point") {
const coor = feature.geometry.coordinates;
for (const shouldNotContainElement of shouldNotContain) {
let shouldNotContainBBox = BBox.get(shouldNotContainElement);
let featureBBox = BBox.get(feature);
if (!featureBBox.overlapsWith(shouldNotContainBBox)) {
continue;
}
if (this.inside(coor, shouldNotContainElement)) {
return true
}
}
return false;
}
if (feature.geometry.type === "Polygon") {
const poly = feature;
for (const shouldNotContainElement of shouldNotContain) {
let shouldNotContainBBox = BBox.get(shouldNotContainElement);
let featureBBox = BBox.get(feature);
if (!featureBBox.overlapsWith(shouldNotContainBBox)) {
continue;
}
if (noTouching) {
if (GeoOperations.isPolygonTouching(poly, shouldNotContainElement)) {
return true;
}
} else {
if (GeoOperations.isPolygonInside(poly, shouldNotContainElement)) {
return true;
}
}
}
}
return false;
}
/**
* Simple check: that every point of the polygon is inside the container
* @param polygon
* @param container
*/
static isPolygonInside(polygon, container) {
for (const coor of polygon.geometry.coordinates[0]) {
if (!GeoOperations.inside(coor, container)) {
return false;
}
}
return true;
}
/**
* Simple check: one point of the polygon is inside the container
* @param polygon
* @param container
*/
static isPolygonTouching(polygon, container) {
for (const coor of polygon.geometry.coordinates[0]) {
if (GeoOperations.inside(coor, container)) {
return true;
}
}
return false;
}
static inside(pointCoordinate, feature): boolean {
// ray-casting algorithm based on
// http://www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/pnpoly.html
if (feature.geometry.type === "Point") {
return false;
}
const x: number = pointCoordinate[0];
const y: number = pointCoordinate[1];
let poly = feature.geometry.coordinates[0];
var inside = false;
for (var i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const coori = poly[i];
const coorj = poly[j];
const xi = coori[0];
const yi = coori[1];
const xj = coorj[0];
const yj = coorj[1];
var intersect = ((yi > y) != (yj > y))
&& (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
if (intersect) {
inside = !inside;
}
}
return inside;
};
}
class BBox {
readonly maxLat: number;
readonly maxLon: number;
readonly minLat: number;
readonly minLon: number;
constructor(coordinates) {
this.maxLat = Number.MIN_VALUE;
this.maxLon = Number.MIN_VALUE;
this.minLat = Number.MAX_VALUE;
this.minLon = Number.MAX_VALUE;
for (const coordinate of coordinates) {
this.maxLon = Math.max(this.maxLon, coordinate[0]);
this.maxLat = Math.max(this.maxLat, coordinate[1]);
this.minLon = Math.min(this.minLon, coordinate[0]);
this.minLat = Math.min(this.minLat, coordinate[1]);
}
}
public overlapsWith(other: BBox) {
if (this.maxLon < other.minLon) {
return false;
}
if (this.maxLat < other.minLat) {
return false;
}
if (this.minLon > other.maxLon) {
return false;
}
if (this.minLat > other.maxLat) {
return false;
}
return true;
}
static get(feature) {
if (feature.bbox === undefined) {
if (feature.geometry.type === "Polygon") {
feature.bbox = new BBox(feature.geometry.coordinates[0]);
} else if (feature.geometry.type === "LineString") {
feature.bbox = new BBox(feature.geometry.coordinates);
} else {
// Point
feature.bbox = new BBox([feature.geometry.coordinates]);
}
}
return feature.bbox;
}
}

119
Logic/ImageSearcher.ts Normal file
View File

@ -0,0 +1,119 @@
import {UIEventSource} from "../UI/UIEventSource";
import {ImagesInCategory, Wikidata, Wikimedia} from "./Wikimedia";
import {WikimediaImage} from "../UI/Image/WikimediaImage";
import {SimpleImageElement} from "../UI/Image/SimpleImageElement";
import {UIElement} from "../UI/UIElement";
/**
* Class which search for all the possible locations for images and which builds a list of UI-elements for it.
* Note that this list is embedded into an UIEVentSource, ready to put it into a carousel
*/
export class ImageSearcher extends UIEventSource<string[]> {
private readonly _tags: UIEventSource<any>;
private readonly _wdItem = new UIEventSource<string>("");
private readonly _commons = new UIEventSource<string>("");
private _activated: boolean = false;
constructor(tags: UIEventSource<any>) {
super([]);
// this.ListenTo(this._embeddedImages);
this._tags = tags;
const self = this;
this._wdItem.addCallback(() => {
// Load the wikidata item, then detect usage on 'commons'
let wikidataId = self._wdItem.data;
if (wikidataId.startsWith("Q")) {
wikidataId = wikidataId.substr(1);
}
Wikimedia.GetWikiData(parseInt(wikidataId), (wd: Wikidata) => {
self.AddImage(wd.image);
Wikimedia.GetCategoryFiles(wd.commonsWiki, (images: ImagesInCategory) => {
for (const image of images.images) {
self.AddImage(image.filename);
}
})
})
}
);
this._commons.addCallback(() => {
const commons: string = self._commons.data;
if (commons.startsWith("Category:")) {
Wikimedia.GetCategoryFiles(commons, (images: ImagesInCategory) => {
for (const image of images.images) {
self.AddImage(image.filename);
}
})
} else if (commons.startsWith("File:")) {
self.AddImage(commons);
}
});
}
private AddImage(url: string) {
if(url === undefined || url === null){
return;
}
if (this.data.indexOf(url) < 0) {
this.data.push(url);
this.ping();
}
}
public Activate() {
if(this._activated){
return;
}
this._activated = true;
this.LoadImages();
const self = this;
this._tags.addCallback(() => self.LoadImages());
}
private LoadImages(): void {
if(!this._activated){
return;
}
const imageTag = this._tags.data.image;
if (imageTag !== undefined) {
const bareImages = imageTag.split(";");
for (const bareImage of bareImages) {
this.AddImage(bareImage);
}
}
const wdItem = this._tags.data.wikidata;
if (wdItem !== undefined) {
this._wdItem.setData(wdItem);
}
const commons = this._tags.data.wikimedia_commons;
if (commons !== undefined) {
this._commons.setData(commons);
}
}
/***
* Creates either a 'simpleimage' or a 'wikimediaimage' based on the string
* @param url
* @constructor
*/
static CreateImageElement(url: string): UIElement {
const urlSource = new UIEventSource<string>(url);
// @ts-ignore
if (url.startsWith("File:")) {
return new WikimediaImage(urlSource);
} else {
return new SimpleImageElement(urlSource);
}
}
}

134
Logic/LayerUpdater.ts Normal file
View File

@ -0,0 +1,134 @@
import {Basemap} from "./Basemap";
import {Overpass} from "./Overpass";
import {Or, TagsFilter} from "./TagsFilter";
import {UIEventSource} from "../UI/UIEventSource";
import {FilteredLayer} from "./FilteredLayer";
export class LayerUpdater {
private _map: Basemap;
private _layers: FilteredLayer[];
public readonly runningQuery: UIEventSource<boolean> = new UIEventSource<boolean>(false);
/**
* The previous bounds for which the query has been run
*/
private previousBounds: { north: number, east: number, south: number, west: number };
private _overpass: Overpass;
private _minzoom: number;
/**
* The most important layer should go first, as that one gets first pick for the questions
* @param map
* @param minzoom
* @param layers
*/
constructor(map: Basemap,
minzoom: number,
layers: FilteredLayer[]) {
this._map = map;
this._layers = layers;
this._minzoom = minzoom;
var filters: TagsFilter[] = [];
for (const layer of layers) {
filters.push(layer.filters);
}
this._overpass = new Overpass(new Or(filters));
const self = this;
map.Location.addCallback(function () {
self.update();
});
}
private handleData(geojson: any) {
this.runningQuery.setData(false);
for (const layer of this._layers) {
geojson = layer.SetApplicableData(geojson);
}
if (geojson.features.length > 0) {
console.log("Got some leftovers: ", geojson)
}
}
private handleFail(reason: any) {
this.runningQuery.setData(false);
console.log("QUERY FAILED", reason);
// TODO
}
private update(): void {
if (this.IsInBounds()) {
return;
}
if (this._map.map.getZoom() < this._minzoom) {
console.log("Not running query: zoom not sufficient");
return;
}
if (this.runningQuery.data) {
console.log("Still running a query, skip");
}
var bbox = this.buildBboxFor();
this.runningQuery.setData(true);
const self = this;
this._overpass.queryGeoJson(bbox,
function (data) {
self.handleData(data)
},
function (reason) {
self.handleFail(reason)
}
);
}
buildBboxFor(): string {
const b = this._map.map.getBounds();
const latDiff = Math.abs(b.getNorth() - b.getSouth());
const lonDiff = Math.abs(b.getEast() - b.getWest());
const extra = 0.5;
const n = b.getNorth() + latDiff * extra;
const e = b.getEast() + lonDiff * extra;
const s = b.getSouth() - latDiff * extra;
const w = b.getWest() - lonDiff * extra;
this.previousBounds = {north: n, east: e, south: s, west: w};
const bbox = "[bbox:" + s + "," + w + "," + n + "," + e + "]";
return bbox;
}
private IsInBounds(): boolean {
if (this.previousBounds === undefined) {
return false;
}
const b = this._map.map.getBounds();
if (b.getSouth() < this.previousBounds.south) {
return false;
}
if (b.getNorth() > this.previousBounds.north) {
return false;
}
if (b.getEast() > this.previousBounds.east) {
return false;
}
if (b.getWest() < this.previousBounds.west) {
return false;
}
return true;
}
}

185
Logic/OsmConnection.ts Normal file
View File

@ -0,0 +1,185 @@
// @ts-ignore
import osmAuth from "osm-auth";
import {UIEventSource} from "../UI/UIEventSource";
export class UserDetails {
public loggedIn = false;
public name = "Not logged in";
public csCount = 0;
public img: string;
public unreadMessages = 0;
}
export class OsmConnection {
private auth = new osmAuth({
oauth_consumer_key: 'hivV7ec2o49Two8g9h8Is1VIiVOgxQ1iYexCbvem',
oauth_secret: 'wDBRTCem0vxD7txrg1y6p5r8nvmz8tAhET7zDASI',
auto: true // show a login form if the user is not authenticated and
// you try to do a call
});
public userDetails: UIEventSource<UserDetails>;
private _dryRun: boolean;
constructor(dryRun: boolean) {
this.userDetails = new UIEventSource<UserDetails>(new UserDetails());
this._dryRun = dryRun;
if(dryRun){
alert("Opgelet: testmode actief. Wijzigingen worden NIET opgeslaan")
}
if (this.auth.authenticated()) {
this.AttemptLogin(); // Also updates the user badge
}else{
console.log("Not authenticated");
}
if(dryRun){
console.log("DRYRUN ENABLED");
}
}
public LogOut() {
this.auth.logout();
}
public AttemptLogin() {
const self = this;
this.auth.xhr({
method: 'GET',
path: '/api/0.6/user/details'
}, function (err, details) {
if(err != null){
console.log(err);
self.auth.logout();
self.userDetails.data.loggedIn = false;
self.userDetails.ping();
}
if(details == null){
return;
}
// details is an XML DOM of user details
let userInfo = details.getElementsByTagName("user")[0];
let data = self.userDetails.data;
data.loggedIn = true;
data.name = userInfo.getAttribute('display_name');
data.csCount = userInfo.getElementsByTagName("changesets")[0].getAttribute("count");
data.img = userInfo.getElementsByTagName("img")[0].getAttribute("href");
data.unreadMessages = userInfo.getElementsByTagName("received")[0].getAttribute("unread");
self.userDetails.ping();
});
}
private static parseUploadChangesetResponse(response: XMLDocument) {
const nodes = response.getElementsByTagName("node");
const mapping = {};
// @ts-ignore
for (const node of nodes) {
const oldId = parseInt(node.attributes.old_id.value);
const newId = parseInt(node.attributes.new_id.value);
if (oldId !== undefined && newId !== undefined &&
!isNaN(oldId) && !isNaN(newId)) {
mapping["node/"+oldId] = "node/"+newId;
}
}
return mapping;
}
public UploadChangeset(comment: string, generateChangeXML: ((csid: string) => string),
handleMapping: ((idMapping: any) => void),
continuation: (() => void)) {
if (this._dryRun) {
console.log("NOT UPLOADING as dryrun is true");
var changesetXML = generateChangeXML("123456");
console.log(changesetXML);
return;
}
const self = this;
this.OpenChangeset(comment,
function (csId) {
var changesetXML = generateChangeXML(csId);
self.AddChange(csId, changesetXML,
function (csId, mapping) {
self.CloseChangeset(csId, continuation);
handleMapping(mapping);
}
);
}
);
this.userDetails.data.csCount++;
this.userDetails.ping();
}
private OpenChangeset(comment: string, continuation: ((changesetId: string) => void)) {
this.auth.xhr({
method: 'PUT',
path: '/api/0.6/changeset/create',
options: { header: { 'Content-Type': 'text/xml' } },
content: '<osm><changeset>' +
'<tag k="created_by" v="MapComplete 0.0.0" />' +
'<tag k="comment" v="' + comment + '"/>' +
'</changeset></osm>'
}, function (err, response) {
if (response === undefined) {
console.log("err", err);
return;
} else {
continuation(response);
}
});
}
private AddChange(changesetId: string,
changesetXML: string,
continuation: ((changesetId: string, idMapping: any) => void)){
const self = this;
this.auth.xhr({
method: 'POST',
options: { header: { 'Content-Type': 'text/xml' } },
path: '/api/0.6/changeset/'+changesetId+'/upload',
content: changesetXML
}, function (err, response) {
if (response == null) {
console.log("err", err);
return;
}
const mapping = OsmConnection.parseUploadChangesetResponse(response);
console.log("Uplaoded changeset ", changesetId);
continuation(changesetId, mapping);
});
}
private CloseChangeset(changesetId: string, continuation : (() => void)) {
console.log("closing");
this.auth.xhr({
method: 'PUT',
path: '/api/0.6/changeset/'+changesetId+'/close',
}, function (err, response) {
if (response == null) {
console.log("err", err);
}
console.log("Closed changeset ", changesetId);
if(continuation !== undefined){
continuation();
}
});
}
}

172
Logic/OsmObject.ts Normal file
View File

@ -0,0 +1,172 @@
import * as $ from "jquery"
export abstract class OsmObject {
type: string;
id: number;
tags: {} = {};
version: number;
public changed: boolean = false;
protected constructor(type: string, id: number) {
this.id = id;
this.type = type;
}
static DownloadObject(id, continuation: ((element: OsmObject) => void)) {
const splitted = id.split("/");
const type = splitted[0];
const idN = splitted[1];
switch (type) {
case("node"):
return new OsmNode(idN).Download(continuation);
case("way"):
return new OsmWay(idN).Download(continuation);
case("relation"):
return new OsmRelation(idN).Download(continuation);
}
}
abstract SaveExtraData(element);
/**
* Generates the changeset-XML for tags
* @constructor
*/
TagsXML(): string {
let tags = "";
for (const key in this.tags) {
const v = this.tags[key];
if (v !== "") {
tags += ' <tag k="' + key + '" v="' + this.tags[key] + '"/>\n'
}
}
return tags;
}
Download(continuation: ((element: OsmObject) => void)) {
const self = this;
$.getJSON("https://www.openstreetmap.org/api/0.6/" + this.type + "/" + this.id,
function (data) {
const element = data.elements[0];
self.tags = element.tags;
self.version = element.version;
self.SaveExtraData(element);
continuation(self);
}
);
return this;
}
public addTag(k: string, v: string): void {
if (k in this.tags) {
const oldV = this.tags[k];
if (oldV == v) {
return;
}
console.log("WARNING: overwriting ",oldV, " with ", v," for key ",k)
}
this.tags[k] = v;
this.changed = true;
}
protected VersionXML(){
if(this.version === undefined){
return "";
}
return 'version="'+this.version+'"';
}
abstract ChangesetXML(changesetId: string): string;
}
export class OsmNode extends OsmObject {
lat: number;
lon: number;
constructor(id) {
super("node", id);
}
ChangesetXML(changesetId: string): string {
let tags = this.TagsXML();
let change =
' <node id="' + this.id + '" changeset="' + changesetId + '" ' + this.VersionXML() + ' lat="' + this.lat + '" lon="' + this.lon + '">\n' +
tags +
' </node>\n';
return change;
}
SaveExtraData(element) {
this.lat = element.lat;
this.lon = element.lon;
}
}
export class OsmWay extends OsmObject {
nodes: number[];
constructor(id) {
super("way", id);
}
ChangesetXML(changesetId: string): string {
let tags = this.TagsXML();
let nds = "";
for (const node in this.nodes) {
nds += ' <nd ref="' + this.nodes[node] + '"/>\n';
}
let change =
' <way id="' + this.id + '" changeset="' + changesetId + '" ' + this.VersionXML() + '>\n' +
nds +
tags +
' </way>\n';
return change;
}
SaveExtraData(element) {
this.nodes = element.nodes;
}
}
export class OsmRelation extends OsmObject {
members;
constructor(id) {
super("relation", id);
}
ChangesetXML(changesetId: string): string {
let members = "";
for (const memberI in this.members) {
const member = this.members[memberI];
members += ' <member type="' + member.type + '" ref="' + member.ref + '" role="' + member.role + '"/>\n';
}
let tags = this.TagsXML();
let change =
' <relation id="' + this.id + '" changeset="' + changesetId + '" ' + this.VersionXML() + '>\n' +
members +
tags +
' </relation>\n';
return change;
}
SaveExtraData(element) {
this.members = element.members;
}
}

58
Logic/Overpass.ts Normal file
View File

@ -0,0 +1,58 @@
import {TagsFilter} from "./TagsFilter";
import * as OsmToGeoJson from "osmtogeojson";
import * as $ from "jquery";
import {Basemap} from "./Basemap";
import {UIEventSource} from "../UI/UIEventSource";
/**
* Interfaces overpass to get all the latest data
*/
export class Overpass {
private _filter: TagsFilter;
public static testUrl: string = null;
constructor(filter: TagsFilter) {
this._filter = filter;
}
private buildQuery(bbox: string): string {
const filters = this._filter.asOverpass();
let filter = "";
for (const filterOr of filters) {
filter += 'nwr' + filterOr + ';';
}
const query =
'[out:json][timeout:25]' + bbox + ';(' + filter + ');out body;>;out skel qt;';
console.log(query);
const url = "https://overpass-api.de/api/interpreter?data=" + encodeURIComponent(query);
return url;
}
queryGeoJson(bbox: string, continuation: ((any) => void), onFail: ((reason) => void)): void {
let query = this.buildQuery(bbox);
if(Overpass.testUrl !== null){
console.log("Using testing URL")
query = Overpass.testUrl;
}
$.getJSON(query,
function (json, status) {
console.log("status:", status)
if (status !== "success") {
console.log("Query failed")
onFail(status);
}
// @ts-ignore
const geojson = OsmToGeoJson.default(json);
continuation(geojson);
}).fail(onFail)
;
}
}

508
Logic/Question.ts Normal file
View File

@ -0,0 +1,508 @@
import {Changes} from "./Changes";
import {UIElement} from "../UI/UIElement";
import {UIEventSource} from "../UI/UIEventSource";
export class QuestionUI extends UIElement {
private readonly _q: Question;
private readonly _tags: UIEventSource<any>;
/**
* The ID of the calling question - used to trigger it's onsave
*/
private readonly _qid;
constructor(q: Question, qid: number, tags: UIEventSource<any>) {
super(tags);
this._q = q;
this._tags = tags;
this._qid = qid;
}
private RenderRadio() {
let radios = "";
let c = 0;
for (let answer of this._q.question.answers) {
const human = answer.text;
const ansId = "q" + this._qid + "-answer" + c;
radios +=
"<input type='radio' name='q" + this._qid + "' id='" + ansId + "' value='" + c + "' />" +
"<label for='" + ansId + "'>" + human + "</label>" +
"<br />";
c++;
}
return radios;
}
private RenderRadioText() {
let radios = "";
let c = 0;
for (let answer of this._q.question.answers) {
const human = answer.text;
const ansId = "q" + this._qid + "-answer" + c;
radios +=
"<input type='radio' name='q" + this._qid + "' id='" + ansId + "' value='" + c + "' />" +
"<label for='" + ansId + "'>" + human + "</label>" +
"<br />";
c++;
}
const ansId = "q" + this._qid + "-answer" + c;
radios +=
"<input type='radio' name='q" + this._qid + "' id='" + ansId + "' value='" + c + "' />" +
"<label for='" + ansId + "'><input type='text' id='q-" + this._qid + "-textbox' onclick='checkRadioButton(\"" + ansId + "\")'/></label>" +
"<br />";
return radios;
}
InnerRender(): string {
if (!this._q.Applicable(this._tags.data)) {
return "";
}
const q = this._q.question;
let answers = "";
if (q.type == "radio") {
answers += this.RenderRadio();
} else if (q.type == "text") {
answers += "<input type='text' id='q-" + this._qid + "-textbox'/><br/>"
} else if (q.type == "radio+text") {
answers += this.RenderRadioText();
} else {
alert("PLZ RENDER TYPE " + q.type);
}
const embeddedScriptSave = 'questionAnswered(' + this._qid + ', "' + this._tags.data.id + '", false )';
const embeddedScriptSkip = 'questionAnswered(' + this._qid + ', "' + this._tags.data.id + '", true )';
const saveButton = "<input class='save-button' type='button' onclick='" + embeddedScriptSave + "' value='Opslaan' />";
const skip = "<input class='skip-button' type='button' onclick='" + embeddedScriptSkip + "' value='Ik ben het niet zeker (vraag overslaan)' />";
return q.question + "<br/> " + answers + saveButton + skip;
}
InnerUpdate(htmlElement: HTMLElement) {
}
}
export class QuestionDefinition {
static noNameOrNameQuestion(question: string, noExplicitName : string, severity : number) : QuestionDefinition{
const q = new QuestionDefinition(question);
q.type = 'radio+text';
q.addAnwser(noExplicitName, "noname","yes");
q.addUnrequiredTag("name", "*");
q.addUnrequiredTag("noname", "yes");
q.key = "name";
q.severity = severity;
return q;
}
static textQuestion(
question: string,
key: string,
severity: number
): QuestionDefinition {
const q = new QuestionDefinition(question);
q.type = 'text';
q.key = key;
q.severity = severity;
q.addUnrequiredTag(key, '*');
return q;
}
static radioQuestionSimple(
question: string,
severity: number,
key: string,
answers: { text: string, value: string }[]) {
const answers0: {
text: string,
tags: { k: string, v: string }[],
}[] = [];
for (const i in answers) {
const answer = answers[i];
answers0.push({text: answer.text, tags: [{k: key, v: answer.value}]})
}
var q = this.radioQuestion(question, severity, answers0);
q.key = key;
q.addUnrequiredTag(key, '*');
return q;
}
static radioAndTextQuestion(
question: string,
severity: number,
key: string,
answers: { text: string, value: string }[]) {
const q = this.radioQuestionSimple(question, severity, key, answers);
q.type = 'radio+text';
return q;
}
static radioQuestion(
question: string,
severity: number,
answers:
{
text: string,
tags: { k: string, v: string }[],
}[]
): QuestionDefinition {
const q = new QuestionDefinition(question);
q.severity = severity;
q.type = 'radio';
q.answers = answers;
for (const i in answers) {
const answer = answers[i];
for (const j in answer.tags) {
const tag = answer.tags[j];
q.addUnrequiredTag(tag.k, tag.v);
}
}
return q;
}
static GrbNoNumberQuestion() : QuestionDefinition{
const q = new QuestionDefinition("Heeft dit gebouw een huisnummer?");
q.type = "radio";
q.severity = 10;
q.answers = [{
text: "Ja, het OSM-huisnummer is correct",
tags: [{k: "fixme", v: ""}]
}, {
text: "Nee, het is een enkele garage",
tags: [{k: "building", v: "garage"}, {k: "fixme", v: ""}]
}, {
text: "Nee, het zijn meerdere garages",
tags: [{k: "building", v: "garages"}, {k: "fixme", v: ""}]
}
];
q.addRequiredTag("fixme", "GRB thinks that this has number no number")
return q;
}
static GrbHouseNumberQuestion() : QuestionDefinition{
const q = new QuestionDefinition("Wat is het huisnummer?");
q.type = "radio+text";
q.severity = 10;
q.answers = [{
text: "Het OSM-huisnummer is correct",
tags: [{k: "fixme", v: ""}],
}]
q.key = "addr:housenumber";
q.addRequiredTag("fixme", "*");
return q;
}
private constructor(question: string) {
this.question = question;
}
/**
* Question for humans
*/
public question: string;
/**
* 'type' indicates how the answers are rendered and must be one of:
* 'text' for a free to fill text field
* 'radio' for radiobuttons
* 'radio+text' for radiobuttons and a freefill text field
* 'dropdown' for a dropdown menu
* 'number' for a number field
*
* If 'text' or 'number' is specified, 'key' is used as tag for the answer.
* If 'radio' or 'dropdown' is specified, the answers are used from 'tags'
*
*/
public type: string = 'radio';
/**
* Only used for 'text' or 'number' questions
*/
public key: string = null;
public answers: {
text: string,
tags: { k: string, v: string }[]
}[];
/**
* Indicates that the element must have _all_ the tags defined below
* Dictionary 'key' => [values]; empty list is wildcard
*/
private mustHaveAllTags = [];
/**
* Indicates that the element must _not_ have any of the tags defined below.
* Dictionary 'key' => [values]
*/
private mustNotHaveTags = [];
/**
* Severity: how important the question is
* The higher, the sooner it'll be shown
*/
public severity: number = 0;
addRequiredTag(key: string, value: string) {
if (this.mustHaveAllTags[key] === undefined) {
this.mustHaveAllTags[key] = [value];
} else {
if(this.mustHaveAllTags[key] === []){
// Wildcard
return;
}
this.mustHaveAllTags[key].push(value);
}
if (value === '*') {
this.mustHaveAllTags[key] = [];
}
return this;
}
addUnrequiredTag(key: string, value: string) {
let valueList = this.mustNotHaveTags[key];
if (valueList === undefined) {
valueList = [value];
this.mustNotHaveTags[key] = valueList;
} else {
if (valueList === []) {
return;
}
valueList.push(value);
}
if (value === '*') {
this.mustNotHaveTags[key] = [];
}
return this;
}
private addAnwser(anwser: string, key: string, value: string) {
if (this.answers === undefined) {
this.answers = [{text: anwser, tags: [{k: key, v: value}]}];
} else {
this.answers.push({text: anwser, tags: [{k: key, v: value}]});
}
this.addUnrequiredTag(key, value);
}
public isApplicable(alreadyExistingTags): boolean {
for (let k in this.mustHaveAllTags) {
var actual = alreadyExistingTags[k];
if (actual === undefined) {
return false;
}
let possibleVals = this.mustHaveAllTags[k];
if (possibleVals.length == 0) {
// Wildcard
continue;
}
let index = possibleVals.indexOf(actual);
if (index < 0) {
return false
}
}
for (var k in this.mustNotHaveTags) {
var actual = alreadyExistingTags[k];
if (actual === undefined) {
continue;
}
let impossibleVals = this.mustNotHaveTags[k];
if (impossibleVals.length == 0) {
// Wildcard
return false;
}
let index = impossibleVals.indexOf(actual);
if (index >= 0) {
return false
}
}
return true;
}
}
export class Question {
// All the questions are stored in here, to be able to retrieve them globaly. This is a workaround, see below
static questions = Question.InitCallbackFunction();
static InitCallbackFunction(): Question[] {
// This needs some explanation, as it is a workaround
Question.questions = [];
// The html in a popup is only created when the user actually clicks to open it
// This means that we can not bind code to an HTML-element (as it doesn't exist yet)
// We work around this, by letting the 'save' button just call the function 'questionAnswered' with the ID of the question
// THis defines and registers this global function
/**
* Calls back to the question with either the answer or 'skip'
* @param questionId
* @param elementId
*/
function questionAnswered(questionId, elementId, dontKnow) {
if (dontKnow) {
Question.questions[questionId].Skip(elementId);
} else {
Question.questions[questionId].OnSave(elementId);
}
}
function checkRadioButton(id) {
// @ts-ignore
document.getElementById(id).checked = true;
}
// must cast as any to set property on window
// @ts-ignore
const _global = (window /* browser */ || global /* node */) as any;
_global.questionAnswered = questionAnswered;
_global.checkRadioButton = checkRadioButton;
return [];
}
public readonly question: QuestionDefinition;
private _changeHandler: Changes;
private readonly _qId;
public skippedElements: string[] = [];
constructor(
changeHandler: Changes,
question: QuestionDefinition) {
this.question = question;
this._qId = Question.questions.length;
this._changeHandler = changeHandler;
Question.questions.push(this);
}
/**
* SHould this question be asked?
* Returns false if question is already there or if a premise is missing
*/
public Applicable(tags): boolean {
if (this.skippedElements.indexOf(tags.id) >= 0) {
return false;
}
return this.question.isApplicable(tags);
}
/**
*
* @param elementId: the OSM-id of the element to perform the change on, format 'way/123', 'node/456' or 'relation/789'
* @constructor
*/
protected OnSave(elementId: string) {
let tagsToApply: { k: string, v: string }[] = [];
const q: QuestionDefinition = this.question;
let tp = this.question.type;
if (tp === "radio") {
const selected = document.querySelector('input[name="q' + this._qId + '"]:checked');
if (selected === null) {
console.log("No answer selected");
return
}
let index = (selected as any).value;
tagsToApply = q.answers[index].tags;
} else if (tp === "text") {
// @ts-ignore
let value = document.getElementById("q-" + this._qId + "-textbox").value;
if (value === undefined || value.length == 0) {
console.log("Answer too short");
return;
}
tagsToApply = [{k: q.key, v: value}];
} else if (tp === "radio+text") {
const selected = document.querySelector('input[name="q' + this._qId + '"]:checked');
if (selected === null) {
console.log("No answer selected");
return
}
let index = (selected as any).value;
if (index < q.answers.length) {
// A 'proper' answer was selected
tagsToApply = q.answers[index].tags;
} else {
// The textfield was selected
// @ts-ignore
let value = document.getElementById("q-" + this._qId + "-textbox").value;
if (value === undefined || value.length < 3) {
console.log("Answer too short");
return;
}
tagsToApply = [{k: q.key, v: value}];
}
}
console.log("Question.ts: Applying tags",tagsToApply," to element ", elementId);
for (const toApply of tagsToApply) {
this._changeHandler.addChange(elementId, toApply.k, toApply.v);
}
}
/**
* Creates the HTML question for this tag collection
*/
public CreateHtml(tags: UIEventSource<any>): UIElement {
return new QuestionUI(this, this._qId, tags);
}
private Skip(elementId: any) {
this.skippedElements.push(elementId);
console.log("SKIP");
// Yeah, this is cheating below
// It is an easy way to notify the UIElement that something has changed
this._changeHandler._allElements.getElement(elementId).ping();
}
}

175
Logic/TagsFilter.ts Normal file
View File

@ -0,0 +1,175 @@
export class Regex implements TagsFilter {
private _k: string;
private _r: string;
constructor(k: string, r: string) {
this._k = k;
this._r = r;
}
asOverpass(): string[] {
return ["['" + this._k + "'~'" + this._r + "']"];
}
matches(tags: { k: string; v: string }[]): boolean {
for (const tag of tags) {
if (tag.k === this._k) {
if (tag.v === "") {
// This tag has been removed
return false;
}
if (this._r === "*") {
// Any is allowed
return true;
}
return tag.v.match(this._r).length > 0;
}
}
return false;
}
}
export class Tag implements TagsFilter {
public key: string;
public value: string;
constructor(key: string, value: string) {
this.key = key;
this.value = value;
}
matches(tags: { k: string; v: string }[]): boolean {
for (const tag of tags) {
if (tag.k === this.key) {
if (tag.v === "") {
// This tag has been removed
return false;
}
if (this.value === "*") {
// Any is allowed
return true;
}
return this.value === tag.v;
}
}
return false;
}
asOverpass(): string[] {
if (this.value === "*") {
return ['["' + this.key + '"]'];
}
if (this.value === "") {
// NOT having this key
return ['[!"' + this.key + '"]'];
}
return ['["' + this.key + '"="' + this.value + '"]'];
}
}
export class Or implements TagsFilter {
public or: TagsFilter[]
constructor(or: TagsFilter[]) {
this.or = or;
}
matches(tags: { k: string; v: string }[]): boolean {
for (const tagsFilter of this.or) {
if (tagsFilter.matches(tags)) {
return true;
}
}
return false;
}
asOverpass(): string[] {
const choices = [];
for (const tagsFilter of this.or) {
const subChoices = tagsFilter.asOverpass();
for(const subChoice of subChoices){
choices.push(subChoice)
}
}
return choices;
}
}
export class And implements TagsFilter {
public and: TagsFilter[]
constructor(and: TagsFilter[]) {
this.and = and;
}
matches(tags: { k: string; v: string }[]): boolean {
for (const tagsFilter of this.and) {
if (!tagsFilter.matches(tags)) {
return false;
}
}
return true;
}
private combine(filter: string, choices: string[]): string[] {
var values = []
for (const or of choices) {
values.push(filter + or);
}
return values;
}
asOverpass(): string[] {
var allChoices = null;
for (const andElement of this.and) {
var andElementFilter = andElement.asOverpass();
if (allChoices === null) {
allChoices = andElementFilter;
continue;
}
var newChoices = []
for (var choice of allChoices) {
newChoices.push(
this.combine(choice, andElementFilter)
)
}
allChoices = newChoices;
}
return allChoices;
}
}
export interface TagsFilter {
matches(tags: { k: string, v: string }[]): boolean
asOverpass(): string[]
}
export class TagUtils {
static proprtiesToKV(properties: any): { k: string, v: string }[] {
const result = [];
for (const k in properties) {
result.push({k: k, v: properties[k]})
}
return result;
}
}

131
Logic/Wikimedia.ts Normal file
View File

@ -0,0 +1,131 @@
import * as $ from "jquery"
/**
* This module provides endpoints for wikipedia/wikimedia and others
*/
export class Wikimedia {
static ImageNameToUrl(filename: string, width: number = 500, height: number = 200): string {
filename = encodeURIComponent(filename);
return "https://commons.wikimedia.org/wiki/Special:FilePath/" + filename + "?width=" + width + "&height=" + height;
}
private static knownLicenses = {};
static LicenseData(filename: string, handle: ((LicenseInfo) => void)): void {
if (filename in this.knownLicenses) {
return this.knownLicenses[filename];
}
if (filename === "") {
return;
}
const url = "https://en.wikipedia.org/w/" +
"api.php?action=query&prop=imageinfo&iiprop=extmetadata&" +
"titles=" + filename +
"&format=json&origin=*";
$.getJSON(url, function (data, status) {
const licenseInfo = new LicenseInfo();
const license = data.query.pages[-1].imageinfo[0].extmetadata;
licenseInfo.artist = license.Artist?.value;
licenseInfo.license = license.License?.value;
licenseInfo.copyrighted = license.Copyrighted?.value;
licenseInfo.attributionRequired = license.AttributionRequired?.value;
licenseInfo.usageTerms = license.UsageTerms?.value;
licenseInfo.licenseShortName = license.LicenseShortName?.value;
licenseInfo.credit = license.Credit?.value;
licenseInfo.description = license.ImageDescription?.value;
Wikimedia.knownLicenses[filename] = licenseInfo;
handle(licenseInfo);
});
}
static GetCategoryFiles(categoryName: string, handleCategory: ((ImagesInCategory) => void),
alreadyLoaded = 0, continueParameter: { k: string, param: string } = undefined) {
if (categoryName === undefined || categoryName === null || categoryName === "") {
return;
}
// @ts-ignore
if (!categoryName.startsWith("Category:")) {
categoryName = "Category:" + categoryName;
}
let url = "https://commons.wikimedia.org/w/api.php?" +
"action=query&list=categorymembers&format=json&" +
"&origin=*" +
"&cmtitle=" + encodeURIComponent(categoryName);
if (continueParameter !== undefined) {
url = url + "&" + continueParameter.k + "=" + continueParameter.param;
}
$.getJSON(url, (response) => {
let imageOverview = new ImagesInCategory();
let members = response.query?.categorymembers;
if (members === undefined) {
members = [];
}
for (const member of members) {
imageOverview.images.push(
{filename: member.title, fileid: member.pageid});
}
if (response.continue === undefined || alreadyLoaded > 30) {
handleCategory(imageOverview);
} else {
console.log("Recursive load for ", categoryName)
this.GetCategoryFiles(categoryName, (recursiveImages) => {
for (const image of imageOverview.images) {
recursiveImages.images.push(image);
}
handleCategory(recursiveImages);
},
alreadyLoaded + 10, {k: "cmcontinue", param: response.continue.cmcontinue})
}
});
}
static GetWikiData(id: number, handleWikidata: ((Wikidata) => void)) {
const url = "https://www.wikidata.org/wiki/Special:EntityData/Q" + id + ".json";
$.getJSON(url, (response) => {
const entity = response.entities["Q" + id];
const commons = entity.sitelinks.commonswiki;
const wd = new Wikidata();
wd.commonsWiki = commons?.title;
// P18 is the claim 'depicted in this image'
wd.image = "File:" + entity.claims.P18?.[0]?.mainsnak?.datavalue?.value;
handleWikidata(wd);
});
}
}
export class Wikidata {
commonsWiki: string;
image: string;
}
export class ImagesInCategory {
// Filenames of relevant images
images: { filename: string, fileid: number }[] = [];
}
export class LicenseInfo {
artist: string = "";
license: string = "";
licenseShortName: string = "";
usageTerms: string = "";
attributionRequired: boolean = false;
copyrighted: boolean = false;
credit: string = "";
description: string = "";
}

75
Quests.ts Normal file
View File

@ -0,0 +1,75 @@
import {QuestionDefinition} from "./Logic/Question";
export class Quests {
static hasFee = QuestionDefinition.radioQuestionSimple("Moet men betalen om deze toiletten te gebruiken?", 10,
"fee",
[{text: "ja", value: "yes"}, {text: "nee", value: "no"}]
);
static toiletsWheelChairs = QuestionDefinition.radioQuestionSimple("Zijn deze toiletten rolstoeltoegankelijk?", 20,
"wheelchair",
[{text: "ja", value: "yes"}, {text: "nee", value: "no"}]
).addUnrequiredTag("toilets:position", "urinals");
static toiletsChangingTable = QuestionDefinition.radioQuestionSimple("Is er een luiertafel beschikbaar?", 20,
"changing_table",
[{text: "ja", value: "yes"}, {text: "nee", value: "no"}]
)
// Urinals are often a pitlatrine/something very poor where no changing table is
.addUnrequiredTag("toilets:position", "urinals").addUnrequiredTag("toilets:position", "urinal");
static toiletsChangingTableLocation = QuestionDefinition.radioAndTextQuestion("Waar bevindt de luiertafel zich?", 5,
"changing_table",
[{text: "In de vrouwentoiletten", value: "female_toilet"},
{text: "In de mannentoiletten", value: "male_toilet"},
{text: "In de rolstoeltoegangkelijke toiletten", value: "wheelchair_toilet"},
{text: "In de aparte, speciaal voorziene ruimte", value: "dedicated_room"},
{text: "In de genderneutrale toiletten", value: "unisex_toilet"}]
)
.addRequiredTag("changing_table", "yes");
static toiletsPosition = QuestionDefinition.radioQuestionSimple("Wat voor toiletten zijn dit?", 1,
"toilets:position",
[{text: "Enkel urinoirs", value: "urinals"},
{text: "Enkel 'gewone' toiletten waar men op gaat zitten", value: "seated"},
{text: "Er zijn zowel urinoirs als zittoiletten", value: "seated;urinals"}]);
static accessNatureReserve = QuestionDefinition.radioQuestionSimple(
"Is dit gebied toegankelijk voor het publiek?",
10,
"access",
[
{text: "Nee, dit is afgesloten", value: "no"},
{text: "Nee, dit is een privaat terrein", value: "no"},
{text: "Hoewel het een privebos is, kan men er toch in", value: "permissive"},
{text: "Enkel tijdens activiteiten of met een gids", value: "guided"},
{text: "Ja, het is gewoon toegankelijk", value: "yes"}
]
).addUnrequiredTag("seamark:type", "restricted_area");
static nameOf(name: string) : QuestionDefinition {
return QuestionDefinition.noNameOrNameQuestion("Wat is de naam van dit " + name + "?",
"Dit " + name + " heeft geen naam", 20);
}
static operator =
QuestionDefinition.radioAndTextQuestion(
"Wie is de beheerder van dit gebied?",
1,
"operator",
[{text: "Natuurpunt", value: "Natuurpunt"},
{text: "Het Agenschap voor Natuur en Bos", value: "Agentschap Natuur en Bos"},
{text: "Een prive-eigenaar beheert dit", value: "private"}
]
).addUnrequiredTag("access", "private")
.addUnrequiredTag("access", "no");
}

48
README.md Normal file
View File

@ -0,0 +1,48 @@
# MapComplete
MapComplete attempts to be a webversion of StreetComplete.
The design goals of MapComplete are to be:
- Easy to use
- Easy to deploy
- Easy to modify
The basic functionality is to download some map features with overpass and then ask certain questions. An answer is sent back to OpenStreetMap.
Furthermore, it shows images present in the `image` tag or, if a `wikidata` or `wikimedia_commons`-tag is present, it follows those to get these images too
## License
GPL + pingback.
I love it to see where the project ends up. You are free to reuse the software (under GPL) but, when you have made your own change and are using it, I would like to know about it. Drop me a line, give a pingback in the issues, ...
## Dev
To develop:
1. Install `npm`.
2. Run `npm install` to install the dependencies
3. Run `npm run start` to build and host a local testversion
To deploy:
0. `rm -rf dist/` to remove the local build
1. `npm run build`
2. copy the entire `dist` folder to where you host your website. Visiting `index.html` gives you the website
## Architecture
### High-level overview
The website is purely static. This means that there is no database here, nor one is needed as all the data is kept in OpenStreetMap or Wikimedia (for images).
When viewing, the data is loaded from overpass. The data is then converted (in the browser) to geojson, which is rendered by Leaflet.
When a map feature is clicked, a popup shows the information, images and questions that are relevant for that object.
The answers given by the user are sent (after a few seconds) to OpenStreetMap directly - if the user is logged in. If not logged in, the user is prompted to do so.

147
UI/AddButton.ts Normal file
View File

@ -0,0 +1,147 @@
import {UIEventSource} from "./UIEventSource";
import {UIElement} from "./UIElement";
import {Basemap} from "../Logic/Basemap";
import {Changes} from "../Logic/Changes";
import L from "leaflet";
import {Tag} from "../Logic/TagsFilter";
import {FilteredLayer} from "../Logic/FilteredLayer";
export class AddButton extends UIElement {
public curentAddSelection: UIEventSource<string> = new UIEventSource<string>("");
private zoomlevel: UIEventSource<{ zoom: number }>;
private readonly SELECTING_POI = "selecting_POI";
private readonly PLACING_POI = "placing_POI";
private changes: Changes;
/*State is one of:
* "": the default stated
* "select_POI": show a 'select which POI to add' query (skipped if only one option exists)
* "placing_point": shown while adding a point
* ""
*/
private state: UIEventSource<string> = new UIEventSource<string>("");
private _options: { name: string; icon: string; tags: Tag[]; layerToAddTo: FilteredLayer }[];
constructor(
basemap: Basemap,
changes: Changes,
options: {
name: string,
icon: string,
tags: Tag[],
layerToAddTo: FilteredLayer
}[]) {
super(undefined);
this.zoomlevel = basemap.Location;
this.ListenTo(this.zoomlevel);
this._options = options;
this.ListenTo(this.curentAddSelection);
this.ListenTo(this.state);
this.state.setData(this.SELECTING_POI);
this.changes = changes;
const self = this;
basemap.map.on("click", function (e) {
const location = e.latlng;
console.log("Clicked at ", location)
self.HandleClick(location.lat, location.lng)
}
);
basemap.map.on("mousemove", function(){
if (self.state.data === self.PLACING_POI) {
var icon = "crosshair";
for (const option of self._options) {
if (option.name === self.curentAddSelection.data && option.icon !== undefined) {
icon = 'url("' + option.icon + '") 32 32 ,crosshair';
console.log("Cursor icon: ", icon)
}
}
document.getElementById('leafletDiv').style.cursor = icon;
} else {
// @ts-ignore
document.getElementById('leafletDiv').style.cursor = '';
}
});
}
private HandleClick(lat: number, lon: number): void {
this.state.setData(this.SELECTING_POI);
console.log("Handling click", lat, lon, this.curentAddSelection.data);
for (const option of this._options) {
if (this.curentAddSelection.data === option.name) {
console.log("PLACING a ", option);
let feature = this.changes.createElement(option.tags, lat, lon);
option.layerToAddTo.AddNewElement(feature);
return;
}
}
}
protected InnerRender(): string {
if (this.zoomlevel.data.zoom < 19) {
return "Zoom in om een punt toe te voegen"
}
if (this.state.data === this.SELECTING_POI) {
var html = "<form>";
for (const option of this._options) {
// <button type='button'> looks SO retarded
// the default type of button is 'submit', which performs a POST and page reload
html += "<button type='button' class='addPOIoption' value='" + option.name + "'>Voeg een " + option.name + " toe</button><br/>";
}
html += "</form>";
return html;
}
if (this.state.data === this.PLACING_POI) {
return "<div id='clickOnMapInstruction'>Klik op de kaart om een nieuw punt toe te voegen<div>" +
"<div id='cancelInstruction'>Klik hier om toevoegen te annuleren</div>"
}
if (this.curentAddSelection.data === "") {
return "<span onclick>Voeg een punt toe...</span>"
}
return "Annuleer";
}
InnerUpdate(htmlElement: HTMLElement) {
const self = this;
htmlElement.onclick = function (event) {
if(event.consumed){
return;
}
if (self.state.data === self.PLACING_POI) {
self.state.setData(self.SELECTING_POI);
}
}
const buttons = htmlElement.getElementsByClassName('addPOIoption');
// @ts-ignore
for (const button of buttons) {
button.onclick = function (event) {
self.curentAddSelection.setData(button.value);
self.state.setData(self.PLACING_POI);
event.consumed = true;
}
}
}
}

80
UI/CenterMessageBox.ts Normal file
View File

@ -0,0 +1,80 @@
import {UIElement} from "./UIElement";
import {UIEventSource} from "./UIEventSource";
import {Helpers} from "../Helpers";
import {OsmConnection} from "../Logic/OsmConnection";
export class CenterMessageBox extends UIElement {
private readonly _location: UIEventSource<{ zoom: number }>;
private readonly _zoomInMore = new UIEventSource<boolean>(true);
private readonly _centermessage: UIEventSource<string>;
private readonly _osmConnection: OsmConnection;
private readonly _queryRunning: UIEventSource<boolean>;
constructor(
startZoom: number,
centermessage: UIEventSource<string>,
osmConnection: OsmConnection,
location: UIEventSource<{ zoom: number }>,
queryRunning: UIEventSource<boolean>
) {
super(centermessage);
this._centermessage = centermessage;
this._location = location;
this._osmConnection = osmConnection;
this._queryRunning = queryRunning;
this.ListenTo(queryRunning);
const self = this;
location.addCallback(function () {
self._zoomInMore.setData(location.data.zoom < startZoom);
});
this.ListenTo(this._zoomInMore);
}
protected InnerRender(): string {
if (this._centermessage.data != "") {
return this._centermessage.data;
}
if (this._zoomInMore.data) {
return "Zoom in om de data te zien en te bewerken";
} else if (this._queryRunning.data) {
return "Data wordt geladen...";
}
return "Klaar!";
}
private ShouldShowSomething() : boolean{
if (this._queryRunning.data) {
return true;
}
return this._zoomInMore.data;
}
InnerUpdate(htmlElement: HTMLElement) {
const pstyle = htmlElement.parentElement.style;
if (this._centermessage.data != "") {
pstyle.opacity = "1";
pstyle.pointerEvents = "all";
Helpers.registerActivateOsmAUthenticationClass(this._osmConnection);
return;
}
pstyle.pointerEvents = "none";
if (this.ShouldShowSomething()) {
pstyle.opacity = "0.5";
} else {
pstyle.opacity = "0";
}
}
}

16
UI/FixedUiElement.ts Normal file
View File

@ -0,0 +1,16 @@
import {UIElement} from "./UIElement";
export class FixedUiElement extends UIElement {
private _html: string;
constructor(html: string) {
super(undefined);
this._html = html;
}
protected InnerRender(): string {
return this._html;
}
}

51
UI/Image/ImageCarousel.ts Normal file
View File

@ -0,0 +1,51 @@
import {UIElement} from "../UIElement";
import {ImageSearcher} from "../../Logic/ImageSearcher";
import {UIEventSource} from "../UIEventSource";
import {SlideShow} from "../SlideShow";
import {FixedUiElement} from "../FixedUiElement";
export class ImageCarousel extends UIElement {
/**
* There are multiple way to fetch images for an object
* 1) There is an image tag
* 2) There is an image tag, the image tag contains multiple ';'-seperated URLS
* 3) there are multiple image tags, e.g. 'image', 'image:0', 'image:1', and 'image_0', 'image_1' - however, these are pretty rare so we are gonna ignore them
* 4) There is a wikimedia_commons-tag, which either has a 'File': or a 'category:' containing images
* 5) There is a wikidata-tag, and the wikidata item either has an 'image' attribute or has 'a link to a wikimedia commons category'
* 6) There is a wikipedia article, from which we can deduct the wikidata item
*
* For some images, author and license should be shown
*/
private readonly searcher: ImageSearcher;
private readonly slideshow: SlideShow;
constructor(tags: UIEventSource<any>) {
super(tags);
this.searcher = new ImageSearcher(tags);
let uiElements = this.searcher.map((imageURLS: string[]) => {
const uiElements: UIElement[] = [];
for (const url of imageURLS) {
uiElements.push(ImageSearcher.CreateImageElement(url));
}
return uiElements;
});
this.slideshow = new SlideShow(
new FixedUiElement("<b>Afbeeldingen</b>"),
uiElements,
new FixedUiElement("<i>Geen afbeeldingen gevonden</i>"));
}
InnerRender(): string {
return this.slideshow.Render();
}
Activate() {
super.Activate();
this.searcher.Activate();
}
}

View File

@ -0,0 +1,15 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../UIEventSource";
export class SimpleImageElement extends UIElement {
constructor(source: UIEventSource<string>) {
super(source);
}
protected InnerRender(): string {
return "<img src='" + this._source.data + "' alt='img'>";
}
}

View File

@ -0,0 +1,38 @@
import {UIEventSource} from "../UIEventSource";
import {UIElement} from "../UIElement";
import {SimpleImageElement} from "./SimpleImageElement";
import {LicenseInfo, Wikimedia} from "../../Logic/Wikimedia";
export class WikimediaImage extends UIElement {
private _imageMeta: UIEventSource<LicenseInfo>;
constructor(source: UIEventSource<string>) {
super(source)
const meta = new UIEventSource<LicenseInfo>(new LicenseInfo());
this.ListenTo(meta);
this._imageMeta = meta;
this._source.addCallback(() => {
Wikimedia.LicenseData(source.data, (info) => {
meta.setData(info);
})
});
this._source.ping();
}
protected InnerRender(): string {
let url = Wikimedia.ImageNameToUrl(this._source.data);
url = url.replace(/'/g, '%27');
return "<div class='imgWithAttr'><img class='attributedImage' src='" + url + "' " +
"alt='" + this._imageMeta.data.description + "' >" +
"<br /><span class='attribution'>" +
"<a href='https://commons.wikimedia.org/wiki/"+this._source.data+"' target='_blank'><b>" + (this._source.data) + "</b></a> <br />" +
(this._imageMeta.data.artist ?? "Unknown artist") + " " + (this._imageMeta.data.licenseShortName ?? "") +
"</span>" +
"</div>";
}
}

View File

@ -0,0 +1,31 @@
import {UIElement} from "./UIElement";
import {UserDetails} from "../Logic/OsmConnection";
import {UIEventSource} from "./UIEventSource";
export class LoginDependendMessage extends UIElement {
private _noLoginMsg: string;
private _loginMsg: string;
private _userDetails: UserDetails;
constructor(loginData: UIEventSource<UserDetails>,
noLoginMsg: string,
loginMsg: string) {
super(loginData);
this._userDetails = loginData.data;
this._noLoginMsg = noLoginMsg;
this._loginMsg = loginMsg;
}
protected InnerRender(): string {
if (this._userDetails.loggedIn) {
return this._loginMsg;
} else {
return this._noLoginMsg;
}
}
InnerUpdate(htmlElement: HTMLElement) {
// pass
}
}

65
UI/PendingChanges.ts Normal file
View File

@ -0,0 +1,65 @@
import {UIElement} from "./UIElement";
import {UIEventSource} from "./UIEventSource";
import {Changes} from "../Logic/Changes";
export class PendingChanges extends UIElement{
private readonly changes;
constructor(changes: Changes, countdown: UIEventSource<number>) {
super(undefined); // We do everything manually here!
this.changes = changes;
countdown.addCallback(function () {
const percentage = Math.max(0, 100 * countdown.data / 20000);
let bar = document.getElementById("pending-bar");
if (bar === undefined) {
return;
}
const style = bar.style;
style.width = percentage + "%";
style["margin-left"] = (50 - (percentage / 2)) + "%";
});
changes.pendingChangesES.addCallback(function () {
const c = changes._pendingChanges.length;
const text = document.getElementById("pending-text");
if (c == 0) {
text.style.opacity = "0";
text.innerText = "Saving...";
} else {
text.innerText = c + " pending";
text.style.opacity = "1";
}
const bar = document.getElementById("pending-bar");
if (bar === null) {
return;
}
if (c == 0) {
bar.style.opacity = "0";
} else {
bar.style.opacity = "0.5";
}
});
}
protected InnerRender(): string {
return "<div id='pending-bar' style='width:100%; margin-left:0%'></div>" +
"<div id='pending-text'></div>";
}
InnerUpdate(htmlElement: HTMLElement) {
}
}

50
UI/QuestionPicker.ts Normal file
View File

@ -0,0 +1,50 @@
import {UIElement} from "./UIElement";
import {Question} from "../Logic/Question";
import {UIEventSource} from "./UIEventSource";
export class QuestionPicker extends UIElement {
private readonly _questions: Question[];
private readonly tags: any;
private source: UIEventSource<any>;
constructor(questions: Question[],
tags: UIEventSource<any>) {
super(tags);
this._questions = questions;
this.tags = tags.data;
this.source = tags;
}
protected InnerRender(): string {
let t = this.tags;
let highestPriority = Number.MIN_VALUE;
let highestQ: Question;
for (const q of this._questions) {
if (!q.Applicable(t)) {
continue;
}
const priority = q.question.severity;
if (priority > highestPriority) {
highestPriority = priority;
highestQ = q;
}
}
if (highestQ === undefined) {
return "De vragen zijn op!";
}
return highestQ.CreateHtml(this.source).Render();
}
InnerUpdate(htmlElement: HTMLElement) {
}
Activate() {
}
}

74
UI/SlideShow.ts Normal file
View File

@ -0,0 +1,74 @@
import {UIElement} from "./UIElement";
import {UIEventSource} from "./UIEventSource";
export class SlideShow extends UIElement {
private readonly _embeddedElements: UIEventSource<UIElement[]>
private readonly _currentSlide: UIEventSource<number> = new UIEventSource<number>(0);
private readonly _title: UIElement;
private readonly _noimages: UIElement;
constructor(
title: UIElement,
embeddedElements: UIEventSource<UIElement[]>,
noImages: UIElement) {
super(embeddedElements);
this._title = title;
this._embeddedElements = embeddedElements;
this.ListenTo(this._currentSlide);
this._noimages = noImages;
}
protected InnerRender(): string {
if (this._embeddedElements.data.length == 0) {
return this._noimages.Render();
}
const prevBtn = "<input class='prev-button' type='button' onclick='console.log(\"prev\")' value='<' />"
const nextBtn = "<input class='next-button' type='button' onclick='console.log(\"nxt\")' value='>' />"
let header = this._title.Render();
if (this._embeddedElements.data.length > 1) {
header = header + prevBtn + (this._currentSlide.data + 1) + "/" + this._embeddedElements.data.length + nextBtn;
}
let body = ""
for (let i = 0; i < this._embeddedElements.data.length; i++) {
let embeddedElement = this._embeddedElements.data[i];
let state = "hidden"
if (this._currentSlide.data === i) {
state = "active-slide";
}
body += " <div class=\"slide " + state + "\">" + embeddedElement.Render() + "</div>\n";
}
return "<span class='image-slideshow'>" + header + body + "</span>";
}
InnerUpdate(htmlElement) {
const nextButton = htmlElement.getElementsByClassName('next-button')[0];
if(nextButton === undefined){
return;
}
const prevButton = htmlElement.getElementsByClassName('prev-button')[0];
const self = this;
nextButton.onclick = () => {
const current = self._currentSlide.data;
const next = (current + 1) % self._embeddedElements.data.length;
self._currentSlide.setData(next);
}
prevButton.onclick = () => {
const current = self._currentSlide.data;
let prev = (current - 1);
if (prev < 0) {
prev = self._embeddedElements.data.length - 1;
}
self._currentSlide.setData(prev);
}
}
Activate() {
for (const embeddedElement of this._embeddedElements.data) {
embeddedElement.Activate();
}
}
}

73
UI/TagMapping.ts Normal file
View File

@ -0,0 +1,73 @@
import {UIElement} from "./UIElement";
import {UIEventSource} from "./UIEventSource";
export class TagMappingOptions {
key: string;// The key to show
mapping?: any;// dictionary for specific values, the values are substituted
template?: string; // The template, where {key} will be substituted
missing?: string// What to show when the key is not there
constructor(options: {
key: string,
mapping?: any,
template?: string,
missing?: string
}) {
this.key = options.key;
this.mapping = options.mapping;
this.template = options.template;
this.missing = options.missing;
}
}
export class TagMapping extends UIElement {
private readonly tags;
private readonly options: TagMappingOptions;
constructor(
options: TagMappingOptions,
tags: UIEventSource<any>) {
super(tags);
this.tags = tags.data;
this.options = options;
}
IsEmpty(): boolean {
const o = this.options;
return this.tags[o.key] === undefined && o.missing === undefined;
}
protected InnerRender(): string {
const o = this.options;
const v = this.tags[o.key];
if (v === undefined) {
if (o.missing === undefined) {
return "";
}
return o.missing;
}
if (o.mapping !== undefined) {
const mapped = o.mapping[v];
if (mapped !== undefined) {
return mapped;
}
}
if (o.template === undefined) {
console.log("Warning: no match for " + o.key + "=" + v);
return v;
}
return o.template.replace("{" + o.key + "}", v);
}
InnerUpdate(htmlElement: HTMLElement) {
}
}

59
UI/UIElement.ts Normal file
View File

@ -0,0 +1,59 @@
import {UIEventSource} from "./UIEventSource";
export abstract class UIElement {
private static nextId: number = 0;
public readonly id: string;
public readonly _source: UIEventSource<any>;
protected constructor(source: UIEventSource<any>) {
this.id = "ui-element-" + UIElement.nextId;
this._source = source;
UIElement.nextId++;
this.ListenTo(source);
}
protected ListenTo(source: UIEventSource<any>) {
if(source === undefined){
return;
}
const self = this;
source.addCallback(() => {
self.Update();
})
}
Update(): void {
let element = document.getElementById(this.id);
if (element === null || element === undefined) {
// The element is not painted
return;
}
element.innerHTML = this.InnerRender();
this.InnerUpdate(element);
}
// Called after the HTML has been replaced. Can be used for css tricks
InnerUpdate(htmlElement : HTMLElement){}
Render(): string {
return "<span class='uielement' id='" + this.id + "'>" + this.InnerRender() + "</span>"
}
AttachTo(divId: string) {
let element = document.getElementById(divId);
element.innerHTML = this.Render();
this.Update();
}
protected abstract InnerRender(): string;
public Activate(): void {};
public IsEmpty(): boolean {
return this.InnerRender() === "";
}
}

44
UI/UIEventSource.ts Normal file
View File

@ -0,0 +1,44 @@
export class UIEventSource<T>{
public data : T;
private _callbacks = [];
constructor(data: T) {
this.data = data;
}
public addCallback(callback: (() => void)) {
this._callbacks.push(callback);
return this;
}
public setData(t: T): void {
if (this.data === t) {
return;
}
this.data = t;
this.ping();
}
public ping(): void {
for (let i in this._callbacks) {
this._callbacks[i]();
}
}
public map<J>(f: ((T) => J)): UIEventSource<J> {
const newSource = new UIEventSource<J>(
f(this.data)
);
const self = this;
this.addCallback(function () {
newSource.setData(f(self.data));
});
return newSource;
}
}

43
UI/UserBadge.ts Normal file
View File

@ -0,0 +1,43 @@
import {UIElement} from "./UIElement";
import {UserDetails} from "../Logic/OsmConnection";
import {UIEventSource} from "./UIEventSource";
/**
* Handles and updates the user badge
*/
export class UserBadge extends UIElement {
private _userDetails: UIEventSource<UserDetails>;
constructor(userDetails: UIEventSource<UserDetails>) {
super(userDetails);
this._userDetails = userDetails;
userDetails.addCallback(function () {
const profilePic = document.getElementById("profile-pic");
profilePic.onload = function () {
profilePic.style.opacity = "1"
};
});
}
protected InnerRender(): string {
const user = this._userDetails.data;
if (!user.loggedIn) {
return "<div class='activate-osm-authentication'>Klik hier om aan te melden bij OSM</div>";
}
return "<img id='profile-pic' src='" + user.img + "'/> " +
"<div id='usertext'>"+
"<div id='username'>" +
"<a href='https://www.openstreetmap.org/user/"+user.name+"' target='_blank'>" + user.name + "</a></div> <br />" +
"<div id='csCount'> " +
" <a href='https://www.openstreetmap.org/user/"+user.name+"/history' target='_blank'><img class='star' src='./assets/star.svg'/>" + user.csCount + "</div></a>" +
"</div>";
}
InnerUpdate(htmlElement: HTMLElement) {
}
}

29
UI/VerticalCombine.ts Normal file
View File

@ -0,0 +1,29 @@
import {UIElement} from "./UIElement";
export class VerticalCombine extends UIElement {
private _elements: UIElement[];
constructor(elements: UIElement[]) {
super(undefined);
this._elements = elements;
}
protected InnerRender(): string {
let html = "";
for (const element of this._elements){
if (!element.IsEmpty()) {
html += "<div>" + element.Render() + "</div><br />";
}
}
return html;
}
InnerUpdate(htmlElement: HTMLElement) {
}
Activate() {
for (const element of this._elements){
element.Activate();
}
}
}

73
assets/bookcase.svg Normal file
View File

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 64 64"
height="64"
width="64"
id="svg109"
version="1.1"
sodipodi:docname="Public_bookcase-14.svg"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1001"
id="namedview6"
showgrid="false"
inkscape:zoom="8.4285714"
inkscape:cx="21.124627"
inkscape:cy="25.918314"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer2" />
<metadata
id="metadata115">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs113" />
<g
inkscape:groupmode="layer"
id="layer1"
inkscape:label="background">
<ellipse
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.49738699;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path819"
cx="31.967007"
cy="32.109043"
rx="32.00938"
ry="31.946436" />
</g>
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="icon">
<path
id="path4176"
d="M 32.123859,10.440678 8.6779661,17.139504 v 3.349413 L 32.123859,13.79009 55.569753,20.488917 v -3.349413 z m -11.59865,11.964995 c -0.522431,0.02077 -1.041409,0.124297 -1.556954,0.320538 v 20.835706 c 0,0 8.38755,-0.322013 10.525792,7.817477 V 29.497007 c -1.631616,-3.400613 -5.311804,-7.237191 -8.968838,-7.091334 z m 8.968838,28.973731 C 24.066203,47.228261 16.331902,46.172115 16.331902,46.172115 V 26.376556 l -2.629816,-1.046692 v 23.445893 l 15.791961,5.207293 h 5.259628 L 50.545632,48.775757 V 25.329864 l -2.629812,1.046692 v 20.835709 l -13.162145,4.167139 c -1.754454,-0.663351 -3.795938,-0.545319 -5.259628,0 z M 45.279464,22.726221 C 40.03579,22.448689 33.986193,25.566152 30.808953,28.247518 v 22.045945 c 2.933724,-2.408355 8.190227,-5.573982 14.470511,-6.208191 z"
style="fill:#734a08;fill-opacity:1;stroke:none;stroke-width:3.34941339"
inkscape:connector-curvature="0" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

4
assets/envelope.svg Normal file
View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="114" height="114">
<path stroke="#000" stroke-width="7" fill="none" d="m7,20h98v72H7zl44,44q5,4 10,0l44-44M7,92l36-36m26,0 36,36"/>
</svg>

After

Width:  |  Height:  |  Size: 224 B

1
assets/groen.svg Normal file
View File

@ -0,0 +1 @@
<svg id="SvgjsSvg1000" xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="128" height="35" viewBox="0 0 128 35"><title>Shape 126</title><desc>Created with Avocode.</desc><defs id="SvgjsDefs1001"/><path id="SvgjsPath1007" d="M108.139 29.6705C104.36099999999999 29.6705 101.17 31.0259 98.49459999999999 33.7046C95.85399999999998 36.3442 94.4986 39.5706 94.4986 43.349199999999996C94.4986 47.0905 95.854 50.315 98.49459999999999 52.9948C101.16999999999999 55.633199999999995 104.36099999999999 56.991499999999995 108.139 56.991499999999995C111.917 56.991499999999995 115.107 55.633199999999995 117.785 52.9948C120.461 50.315 121.817 47.0905 121.817 43.349199999999996C121.817 39.5706 120.461 36.344199999999994 117.785 33.7046C115.107 31.0259 111.917 29.6705 108.139 29.6705C104.362 29.6705 108.139 29.6705 108.139 29.6705ZM112.171 47.4204C111.037 48.5191 109.71600000000001 49.0688 108.13900000000001 49.0688C106.52600000000001 49.0688 105.20500000000001 48.5191 104.06900000000002 47.4204C102.96700000000001 46.2817 102.41800000000002 44.9263 102.41800000000002 43.3492C102.41800000000002 41.7719 102.96700000000003 40.4146 104.06900000000002 39.315000000000005C105.20500000000001 38.177200000000006 106.52600000000001 37.62950000000001 108.13900000000001 37.62950000000001C109.71600000000001 37.62950000000001 111.037 38.177200000000006 112.171 39.315000000000005C113.34500000000001 40.41460000000001 113.89500000000001 41.7719 113.89500000000001 43.3492C113.89500000000001 44.926300000000005 113.34500000000001 46.2817 112.171 47.4204C110.998 48.559 112.171 47.4204 112.171 47.4204ZM131.907 46.3315H142.174V39.949799999999996H131.907V37.56679999999999H142.357V30.599099999999993H123.986V56.05029999999999H142.395V48.86079999999999H131.907ZM158.69 30.6078V40.6099L144.587 30.166400000000003V56.0551H152.518V46.0512L166.62 56.4966V30.6078ZM89.1782 49.0882C91.28960000000001 46.9771 92.3892 44.3578 92.4038 41.3793V41.3519C92.3892 38.3725 91.28960000000001 35.7543 89.1782 33.6421C87.0513 31.516 84.4107 30.5991 81.4068 30.5991H68.7925V56.0492H76.74860000000001V52.1322H80.83940000000001L89.28860000000002 63.4096L95.63330000000002 58.641999999999996L88.76120000000002 49.4683C88.90090000000002 49.3433 89.04350000000002 49.225 89.17820000000002 49.0882C89.31290000000001 48.9516 89.17820000000002 49.0882 89.17820000000002 49.0882ZM76.7486 38.4097H81.40679999999999C83.07469999999999 38.4097 84.38139999999999 39.7387 84.40679999999999 41.3658C84.38139999999999 42.9927 83.07469999999999 44.3206 81.40679999999999 44.3206H76.7486ZM66.5581 41.4478H53.4839V46.4926H57.9019C57.6821 46.824600000000004 57.424299999999995 47.1205 57.1294 47.4146C56.024899999999995 48.5169 54.659699999999994 49.0708 53.07769999999999 49.0708C51.495599999999996 49.0708 50.13139999999999 48.5169 48.991699999999994 47.4146C47.884299999999996 46.272 47.333499999999994 44.9086 47.333499999999994 43.3267C47.333499999999994 41.7408 47.884299999999996 40.380500000000005 48.991699999999994 39.276C50.13139999999999 38.1334 51.495599999999996 37.580600000000004 53.07769999999999 37.580600000000004C54.659699999999994 37.580600000000004 55.76809999999999 37.910700000000006 56.871599999999994 39.01690000000001L62.764199999999995 33.63900000000001C60.0757 30.95170000000001 56.871599999999994 29.59050000000001 53.07769999999999 29.59050000000001C49.321799999999996 29.59050000000001 46.08059999999999 30.95170000000001 43.39209999999999 33.63900000000001C40.703599999999994 36.29060000000001 39.380399999999995 39.533800000000014 39.380399999999995 43.32670000000001C39.380399999999995 47.08240000000001 40.703599999999994 50.32290000000001 43.39209999999999 53.01220000000001C46.08059999999999 55.66360000000001 49.321799999999996 57.02570000000001 53.07769999999999 57.02570000000001C56.871599999999994 57.02570000000001 60.07569999999999 55.66360000000001 62.764199999999995 53.01220000000001C65.45169999999999 50.322900000000004 66.5581 47.08240000000001 66.5581 43.32670000000001Z " fill="#008379" fill-opacity="1" transform="matrix(1,0,0,1,-39,-29)"/></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

10
assets/star.svg Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="1278.000000pt" height="1280.000000pt" viewBox="0 0 1278.000000 1280.000000" preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.15, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,1280.000000) scale(0.100000,-0.100000)" fill="#000000" stroke="none">
<path d="M6760 12443 c-137 -26 -302 -163 -453 -375 -207 -293 -384 -645 -802 -1598 -347 -790 -486 -1070 -667 -1337 -211 -311 -357 -373 -878 -374 -303 0 -573 22 -1315 106 -310 36 -666 73 -930 97 -191 17 -792 17 -905 0 -359 -56 -525 -174 -538 -382 -7 -128 43 -265 161 -442 197 -294 514 -612 1317 -1323 955 -845 1247 -1174 1290 -1452 37 -234 -95 -656 -453 -1458 -364 -816 -430 -963 -490 -1110 -252 -611 -352 -998 -318 -1236 31 -222 145 -333 357 -346 311 -21 768 169 1699 704 749 431 885 508 1051 596 451 240 718 338 924 341 121 1 161 -10 310 -84 265 -133 574 -380 1300 -1040 1006 -916 1405 -1206 1752 -1276 102 -21 173 -13 255 27 103 50 160 135 204 304 21 81 23 111 23 315 0 125 -5 267 -12 320 -51 379 -107 674 -253 1335 -229 1034 -279 1327 -279 1647 0 162 16 260 55 346 101 221 462 490 1275 952 661 375 831 473 1005 578 739 446 1065 761 1065 1027 0 155 -96 273 -306 378 -300 150 -748 236 -1764 342 -1052 108 -1334 148 -1637 225 -387 100 -514 201 -648 515 -117 276 -211 629 -391 1482 -135 644 -212 973 -289 1237 -115 398 -240 668 -380 824 -94 105 -221 156 -335 135z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

72
assets/statue.svg Normal file
View File

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 64 64"
height="64"
width="64"
id="svg109"
version="1.1"
sodipodi:docname="Statue.svg"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1680"
inkscape:window-height="1013"
id="namedview6"
showgrid="false"
inkscape:zoom="5.9599"
inkscape:cx="18.575009"
inkscape:cy="24.82515"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer2" />
<metadata
id="metadata115">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs113" />
<g
inkscape:groupmode="layer"
id="layer1"
inkscape:label="icon">
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="background">
<circle
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.484;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path824"
cx="32.215305"
cy="32.12027"
r="31.87973" />
</g>
<path
id="rect4517-0"
d="m 31.580949,1.8448297 c -2.450028,0 -4.438737,1.9887079 -4.438737,4.4387347 0,2.4500309 1.988708,4.4303956 4.438737,4.4303956 2.450028,0 4.438737,-1.9803647 4.438737,-4.4303956 0,-2.4500268 -1.988708,-4.4387347 -4.438737,-4.4387347 z M 27.142212,13.675897 c -1.559562,0 -2.953595,1.417115 -2.953595,2.953595 0,2.957448 5.172963,11.883099 5.172964,14.79301 v 8.86913 h 4.438736 v -8.86913 c 0,-2.85233 5.172964,-11.835562 5.172964,-14.79301 0,-1.636599 -1.323142,-2.953595 -2.953595,-2.953595 z m -8.376864,30.887602 v 4.271867 H 44.39655 v -4.271867 z m 4.271867,8.543734 v 8.543734 h 17.087468 v -8.543734 z"
style="opacity:1;fill:#734a08;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:9.03008842;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:markers stroke fill"
inkscape:connector-curvature="0" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

94258
assets/test.json Normal file

File diff suppressed because it is too large Load Diff

82
assets/toilets.svg Normal file
View File

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="64"
height="64"
viewBox="0 0 64 64"
id="svg2"
sodipodi:docname="toilets.svg"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1001"
id="namedview7"
showgrid="false"
inkscape:zoom="2.6074563"
inkscape:cx="-96.695116"
inkscape:cy="91.228234"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer3" />
<metadata
id="metadata8">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs6" />
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="background"
transform="translate(0,-64)">
<ellipse
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.23933503;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path847"
cx="31.580557"
cy="96.215309"
rx="31.580557"
ry="31.513508" />
</g>
<g
inkscape:groupmode="layer"
id="layer3"
inkscape:label="icon"
transform="translate(0,-64)">
<rect
width="44.118912"
height="44.118912"
x="9.5211029"
y="74.2229"
id="canvas"
style="visibility:hidden;fill:none;stroke:none;stroke-width:2.75743198" />
<path
d="m 29.512484,76.980332 v 38.604048 h 4.136148 V 76.980332 Z m -10.34037,2.757432 c -2.284331,0 -4.136148,1.851809 -4.136148,4.136148 0,2.284339 1.851817,4.136148 4.136148,4.136148 2.284331,0 4.136148,-1.851809 4.136148,-4.136148 0,-2.284339 -1.851817,-4.136148 -4.136148,-4.136148 z m 24.816887,0 c -2.28433,0 -4.136148,1.851809 -4.136148,4.136148 0,2.284339 1.851818,4.136148 4.136148,4.136148 2.284331,0 4.136147,-1.851809 4.136147,-4.136148 0,-2.284339 -1.851816,-4.136148 -4.136147,-4.136148 z M 16.414682,90.767492 c -1.653692,0 -2.757432,1.567738 -2.757432,2.757432 0,1.454076 3.44679,4.126883 3.44679,5.514863 0,1.533053 -4.825506,2.757433 -4.825506,8.272293 h 4.825506 v 8.2723 h 4.136148 v -8.2723 h 4.825506 c 0,-5.51486 -4.825506,-6.8122 -4.825506,-8.272293 0,-1.460087 3.44679,-3.994691 3.44679,-5.514863 0,-1.123626 -1.171156,-2.757432 -2.757432,-2.757432 z m 23.438171,0 c -1.454087,0 -2.757431,1.324863 -2.757431,2.757432 0,2.757431 4.825505,11.074036 4.825505,13.787156 v 8.2723 h 4.136147 v -8.2723 c 0,-2.65943 4.825505,-11.029725 4.825505,-13.787156 0,-1.525908 -1.237249,-2.757432 -2.757431,-2.757432 z"
id="toilets"
style="fill:#734a08;fill-opacity:1;stroke:none;stroke-width:2.75743198"
inkscape:connector-curvature="0" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

167
assets/tree.svg Normal file
View File

@ -0,0 +1,167 @@
<svg width="128" height="128" style="enable-background:new 0 0 128 128;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="Layer_1">
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="64" x2="64" y1="121.3333" y2="93.4158">
<stop offset="2.275410e-03" style="stop-color:#5D4037"/>
<stop offset="1" style="stop-color:#8D6E63"/>
</linearGradient>
<path d="M56.57,79.87c-0.98,0.02-1.73,0.48-1.7,1.03c0.3,5.38,1.54,31.22-1.99,36.43 c-1.49,2.2-3.69,3.82-5.55,4.9c-1.08,0.63-0.26,1.68,1.3,1.68h30.74c1.63,0,2.4-1.11,1.21-1.73c-1.97-1.02-4.19-2.62-6-5.1 c-3.32-4.54-3.2-30.92-3.14-36.53c0.01-0.57-0.84-1.03-1.86-1L56.57,79.87z" style="fill:url(#SVGID_1_);"/>
<g style="opacity:0.2;">
<path d="M67.8,83.22c-0.13,25.12,2,33.47,3.86,36c0.27,0.37,0.55,0.72,0.84,1.07H55.2 c0.24-0.3,0.46-0.61,0.68-0.92c3.41-5.04,3.44-21.75,2.75-35.92L67.8,83.22 M69.66,79.55c-0.03,0-0.05,0-0.08,0l-13.01,0.33 c-0.98,0.02-1.73,0.48-1.7,1.03c0.3,5.38,1.54,31.22-1.99,36.43c-1.49,2.2-3.69,3.82-5.55,4.9c-1.08,0.63-0.26,1.68,1.3,1.68 h30.74c1.63,0,2.4-1.11,1.21-1.73c-1.97-1.02-4.19-2.62-6-5.1c-3.32-4.54-3.2-30.92-3.14-36.53 C71.45,79.99,70.65,79.55,69.66,79.55L69.66,79.55z" style="fill:#424242;"/>
</g>
</g>
<g>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="55.2039" x2="95.8998" y1="42.6083" y2="97.3989">
<stop offset="0" style="stop-color:#9CCC65"/>
<stop offset="0.9952" style="stop-color:#689F38"/>
</linearGradient>
<path d="M56.96,100c-9.49,0-17.78-6.08-20.63-15.13c-0.2-0.63-0.78-1.05-1.43-1.05 c-0.04,0-0.08,0-0.12,0c-0.6,0.05-1.13,0.07-1.61,0.07c-11.39,0-20.66-9.59-20.66-21.38c0-7.32,3.57-14.06,9.55-18.02 c0.57-0.38,0.81-1.1,0.59-1.74c-0.76-2.18-1.14-4.46-1.14-6.77c0-11.39,9.27-20.66,20.66-20.66c1.65,0,3.31,0.2,4.93,0.6 c0.12,0.03,0.24,0.04,0.36,0.04c0.51,0,1-0.26,1.27-0.71c3.8-6.12,10.37-9.77,17.56-9.77c9.12,0,17.28,6.11,19.83,14.87 c0.19,0.65,0.78,1.08,1.44,1.08c0.09,0,0.19-0.01,0.28-0.03c1.29-0.24,2.59-0.37,3.87-0.37c11.39,0,20.66,9.27,20.66,20.66 c0,6.2-2.75,12.02-7.54,15.96c-0.33,0.27-0.53,0.68-0.55,1.11c-0.01,0.43,0.16,0.85,0.48,1.14c4.21,3.9,6.62,9.42,6.62,15.15 c0,11.39-9.27,20.66-20.66,20.66c-5.09,0-9.99-1.87-13.78-5.27c-0.28-0.25-0.63-0.38-1-0.38c-0.06,0-0.12,0-0.17,0.01 c-0.43,0.05-0.81,0.28-1.06,0.63C70.65,96.54,64.02,100,56.96,100z" style="fill:url(#SVGID_3_);"/>
<path d="M66.27,7c8.46,0,16.02,5.67,18.39,13.79c0.38,1.3,1.57,2.16,2.88,2.16c0.19,0,0.37-0.02,0.56-0.05 c1.19-0.23,2.4-0.34,3.59-0.34c10.57,0,19.16,8.6,19.16,19.16c0,5.75-2.55,11.15-7,14.8c-0.67,0.55-1.07,1.36-1.09,2.23 c-0.03,0.87,0.32,1.7,0.96,2.29c3.9,3.62,6.14,8.74,6.14,14.05c0,10.57-8.6,19.16-19.16,19.16c-4.72,0-9.26-1.74-12.78-4.89 c-0.55-0.5-1.27-0.77-2-0.77c-0.11,0-0.23,0.01-0.35,0.02c-0.85,0.1-1.62,0.56-2.11,1.26c-3.77,5.4-9.94,8.63-16.51,8.63 c-8.84,0-16.55-5.66-19.2-14.08c-0.4-1.26-1.56-2.1-2.86-2.1c-0.08,0-0.16,0-0.24,0.01c-0.56,0.04-1.05,0.07-1.49,0.07 C22.6,82.4,14,73.49,14,62.53c0-6.82,3.32-13.09,8.88-16.77c1.14-0.76,1.63-2.19,1.18-3.48C23.36,40.25,23,38.14,23,36 c0-10.57,8.6-19.16,19.16-19.16c1.53,0,3.07,0.19,4.57,0.55c0.24,0.06,0.48,0.09,0.71,0.09c1.02,0,1.99-0.52,2.55-1.42 C53.52,10.39,59.61,7,66.27,7 M66.27,4c-7.95,0-14.92,4.19-18.83,10.48c-1.69-0.41-3.46-0.64-5.28-0.64 C29.92,13.84,20,23.76,20,36c0,2.54,0.43,4.98,1.22,7.26C15.08,47.33,11,54.43,11,62.53c0,12.63,9.92,22.88,22.16,22.88 c0.58,0,1.16-0.03,1.73-0.08c2.95,9.38,11.71,16.18,22.06,16.18c7.86,0,14.79-3.92,18.97-9.91c3.92,3.51,9.1,5.65,14.78,5.65 c12.24,0,22.16-9.92,22.16-22.16c0-6.42-2.74-12.21-7.1-16.25c4.94-4.06,8.09-10.22,8.09-17.12c0-12.24-9.92-22.16-22.16-22.16 c-1.42,0-2.8,0.14-4.15,0.39C84.86,10.73,76.36,4,66.27,4L66.27,4z" style="fill:#689F38;"/>
</g>
<path d=" M22.5,44.25c0,0,7.5-5.62,19.5-5" style="fill:none;stroke:#689F38;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;"/>
<path d=" M44.5,26.5c0,0,0.33-5,4.64-11.79" style="fill:none;stroke:#689F38;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;"/>
<path d=" M48.16,78.85c0,0-3.84,3.21-11.73,4.8" style="fill:none;stroke:#689F38;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;"/>
<path d=" M66.52,85.83c4.89,2.42,12.37,2.28,17.23-0.2" style="fill:none;stroke:#689F38;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;"/>
<path d=" M79.62,61.88c5.62,1.12,12.88-3,14.87-8.2" style="fill:none;stroke:#689F38;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;"/>
<path d=" M88.46,59.85c5.27,1.72,11.96,0.63,16.86-2.58" style="fill:none;stroke:#689F38;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;"/>
<path d=" M39.67,72.5c3.73,4.84,10.56,7.04,16.66,5.5" style="fill:none;stroke:#689F38;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;"/>
</g>
<g id="Layer_2" style="display:none;">
<g style="display:inline;">
<g style="opacity:0.6;">
<circle cx="64" cy="64" r="28" style="opacity:0.61;fill:none;stroke:#000000;stroke-width:0.2625;stroke-miterlimit:10;"/>
<line style="opacity:0.61;fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="84" x2="84" y1="0" y2="128"/>
<line style="opacity:0.61;fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="44" x2="44" y1="0" y2="128"/>
<line style="opacity:0.61;fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="64" x2="64" y1="0" y2="128"/>
<line style="opacity:0.61;fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="128" x2="0" y1="64" y2="64"/>
<line style="opacity:0.61;fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="128" x2="0" y1="44" y2="44"/>
<line style="opacity:0.61;fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="128" x2="0" y1="83.75" y2="83.75"/>
<line style="opacity:0.61;fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="128" x2="0" y1="128" y2="0"/>
<line style="opacity:0.61;fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="0" x2="128" y1="128" y2="0"/>
<g style="opacity:0.61;">
<path d="M64,4.26c32.94,0,59.74,26.8,59.74,59.74S96.94,123.74,64,123.74S4.26,96.94,4.26,64S31.06,4.26,64,4.26 M64,4 C30.86,4,4,30.86,4,64s26.86,60,60,60s60-26.86,60-60S97.14,4,64,4L64,4z"/>
</g>
<path d="M107.97,115.97H20.03 c-4.42,0-8.03-3.61-8.03-8.03V20.03c0-4.42,3.61-8.03,8.03-8.03h87.94c4.42,0,8.03,3.61,8.03,8.03v87.91 C116,112.36,112.39,115.97,107.97,115.97z" style="opacity:0.61;fill:none;stroke:#000000;stroke-width:0.2578;stroke-miterlimit:10;"/>
<path d="M100,124H28c-4.4,0-8-3.6-8-8 V12c0-4.4,3.6-8,8-8h72c4.4,0,8,3.6,8,8v104C108,120.4,104.4,124,100,124z" style="opacity:0.61;fill:none;stroke:#000000;stroke-width:0.2628;stroke-miterlimit:10;"/>
<path d="M113.77,108H14.23 C8.6,108,4,103.4,4,97.77V30.28c0-5.63,4.6-10.23,10.23-10.23h99.54c5.63,0,10.23,4.6,10.23,10.23v67.48 C124,103.4,119.4,108,113.77,108z" style="opacity:0.61;fill:none;stroke:#000000;stroke-width:0.2627;stroke-miterlimit:10;"/>
</g>
<g>
<g style="opacity:0.2;">
<defs>
<rect id="SVGID_2_" height="128" style="opacity:0.2;" width="128" x="0" y="0"/>
</defs>
<clipPath id="SVGID_4_">
<use style="overflow:visible;" xlink:href="#SVGID_2_"/>
</clipPath>
<g style="clip-path:url(#SVGID_4_);">
<g>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-28" x2="-28" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-24" x2="-24" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-20" x2="-20" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-16" x2="-16" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-12" x2="-12" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-8" x2="-8" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-4" x2="-4" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="0" x2="0" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="4" x2="4" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="8" x2="8" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="12" x2="12" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="16" x2="16" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="20" x2="20" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="24" x2="24" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="28" x2="28" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="32" x2="32" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="36" x2="36" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="40" x2="40" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="44" x2="44" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="48" x2="48" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="52" x2="52" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="56" x2="56" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="60" x2="60" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="64" x2="64" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="68" x2="68" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="72" x2="72" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="76" x2="76" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="80" x2="80" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="84" x2="84" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="88" x2="88" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="92" x2="92" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="96" x2="96" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="100" x2="100" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="104" x2="104" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="108" x2="108" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="112" x2="112" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="116" x2="116" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="120" x2="120" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="124" x2="124" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="128" x2="128" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="132" x2="132" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="136" x2="136" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="137" x2="137" y1="166.05" y2="-25.95"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="144" x2="144" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="148" x2="148" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="152" x2="152" y1="160" y2="-32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="156" x2="156" y1="160" y2="-32"/>
</g>
<g>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="-28" y2="-28"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="-24" y2="-24"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="-20" y2="-20"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="-16" y2="-16"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="-12" y2="-12"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="-8" y2="-8"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="-4" y2="-4"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="0" y2="0"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="4" y2="4"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="8" y2="8"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="12" y2="12"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="16" y2="16"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="20" y2="20"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="24" y2="24"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="28" y2="28"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="32" y2="32"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="36" y2="36"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="40" y2="40"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="44" y2="44"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="48" y2="48"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="52" y2="52"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="56" y2="56"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="60" y2="60"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="64" y2="64"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="68" y2="68"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="72" y2="72"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="76" y2="76"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="80" y2="80"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="84" y2="84"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="88" y2="88"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="92" y2="92"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="96" y2="96"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="100" y2="100"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="104" y2="104"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="108" y2="108"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="112" y2="112"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="116" y2="116"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="120" y2="120"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="124" y2="124"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="128" y2="128"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="132" y2="132"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="136" y2="136"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="140" y2="140"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="144" y2="144"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="148" y2="148"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="152" y2="152"/>
<line style="fill:none;stroke:#000000;stroke-width:0.25;stroke-miterlimit:10;" x1="-32" x2="160" y1="156" y2="156"/>
</g>
<path d="M159.75-31.75v191.5h-191.5v-191.5H159.75 M160-32H-32v192h192V-32L160-32z"/>
</g>
</g>
<g>
<rect height="128" style="opacity:0.3;fill:#F44336;" width="4" x="0" y="0"/>
<rect height="128" style="opacity:0.3;fill:#F44336;" width="4" x="124" y="0"/>
<rect height="120" style="opacity:0.3;fill:#F44336;" transform="matrix(-1.836970e-16 1 -1 -1.836970e-16 66 -62)" width="4" x="62" y="-58"/>
<rect height="120" style="opacity:0.3;fill:#F44336;" transform="matrix(-1.836970e-16 1 -1 -1.836970e-16 190 62)" width="4" x="62" y="66"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 37 KiB

73
assets/wheelchair.svg Normal file
View File

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 12.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 51448) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="Layer_1"
width="483.2226563"
height="551.4306641"
viewBox="0 0 483.2226563 551.4306641"
overflow="visible"
enable-background="new 0 0 483.2226563 551.4306641"
xml:space="preserve"
sodipodi:docname="wheelchair.svg"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"><metadata
id="metadata11"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
id="defs9" /><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1001"
id="namedview7"
showgrid="false"
inkscape:zoom="0.8559553"
inkscape:cx="-66.220714"
inkscape:cy="292.29436"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1" />
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="background"><ellipse
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.484;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path838"
cx="241.83505"
cy="274.54706"
rx="241.83505"
ry="275.71533" /><ellipse
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.484;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path819"
cx="240.66678"
cy="275.71533"
rx="241.83505"
ry="274.54706" /></g><g
inkscape:groupmode="layer"
id="layer1"
inkscape:label="wheelchair"><path
inkscape:connector-curvature="0"
style="clip-rule:evenodd;fill:#000000;fill-rule:evenodd;stroke-width:0.66635805"
d="m 189.94589,159.71251 c 16.63422,-1.53575 29.55792,-15.86668 29.55792,-32.62877 0,-18.04178 -14.71518,-32.756968 -32.75696,-32.756968 -18.04178,0 -32.75631,14.715188 -32.75631,32.756968 0,5.50201 1.53509,11.13188 4.09445,15.86635 l 11.67168,164.23513 120.20865,0.0325 49.30463,115.52332 64.73308,-25.38668 -10.02402,-23.86915 -36.22735,13.07727 -47.70511,-110.13583 -111.76793,0.75095 -1.53445,-20.79896 80.91112,0.0322 v -30.77448 l -83.99758,-0.0329 z"
id="path2" /><path
inkscape:connector-curvature="0"
style="clip-rule:evenodd;fill:#000000;fill-rule:evenodd;stroke-width:0.66635805"
d="m 310.84431,395.24795 c -20.28873,40.10642 -62.75413,66.52908 -108.0502,66.52908 -66.52908,0 -120.790412,-54.26133 -120.790412,-120.79041 0,-46.71209 28.310452,-90.1207 70.555212,-109.36341 l 2.73376,35.67521 c -24.98647,15.74498 -40.3895,44.15435 -40.3895,73.93288 0,48.26215 39.36263,87.62413 87.62413,87.62413 44.15407,0 81.80523,-33.88535 86.93958,-77.35545 z"
id="path4" /></g></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

238
index.css Normal file
View File

@ -0,0 +1,238 @@
html, body {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family: 'Helvetica Neue', Arial, sans-serif;
}
#leafletDiv {
height: 100%;
}
.star {
fill: black;
width: 1em;
height: 1em;
}
#profile-pic {
float: left;
width: 4em;
height: 4em;
padding:0;
margin:0;
-webkit-border-radius: 50%;
-moz-border-radius: 50%;
border-radius: 50%;
opacity: 0;
transition: opacity 500ms linear;
}
#usertext a {
text-decoration: none;
color: black;
}
#usertext {
width: auto;
margin:0;
padding: 0.9em;
padding-left: 4.7em; /* Should be half of profile-pic's width + actual padding (same as padding-right)*/
padding-right: 1.5em;
border-radius: 2em; /*Half border radius width/height*/
height: 2.2em; /*SHould equal profile-pic height - padding*/
z-index: 5000;
text-align: left;
background-color: white;
background-size: 100%;
display: block;
line-height: 0.75em;
}
#username {
display: block ruby;
text-decoration: none;
color: black;
}
.imgWithAttr {
max-height: 20em;
}
#userbadge{
text-align: center;
background-color: white;
-webkit-border-radius: 2em;
-moz-border-radius: 2em;
border-radius: 2em;
transition: all 500ms linear;
}
#authbox {
position: absolute;
margin: 1em;
margin-right: 0;
margin-bottom: 0;
padding:0;
z-index: 5000;
transition: all 500ms linear;
max-width: 25%;
overflow: hidden;
}
#pendingchangesbox {
position:relative;
}
#pending-bar {
height: 1.2em;
margin: 0;
z-index: 5005;
background-color: white;
border-radius:1.2em;
padding: 0;
transition: all 1s linear;
opacity: 0;
}
#pending-text {
margin: 0;
margin-top: -1.2em;
z-index: 5000;
border-radius:1.2em;
height: 1.2em;
text-align: center;
padding: 0;
opacity: 0;
transition: opacity 1s linear;
}
#zoomin {
}
#messagesbox {
padding: 2em;
padding-top: 1em;
padding-bottom: 1em;
z-index: 5000;
transition: all 500ms linear;
background-color: white;
border-radius: 2em;
}
#logo {
position: relative;
display: flex;
float: right;
margin: 1em;
margin-top: 0;
margin-right: -0.5em;
padding: 0;
right: 1em;
top: 1em;
}
#centermessage {
position: absolute;
display: block ruby;
margin: auto;
top: 30%;
left: 50%;
margin-left: -15%;
width: 30%;
font-size: large;
padding: 2em;
border-radius: 2em;
z-index: 5000;
pointer-events: none;
opacity: 1;
background-color: white;
transition: opacity 500ms linear;
text-align: center;
horiz-align: center;
font-weight: bold;
}
#bottomRight {
display: block ruby;
position: absolute;
margin: auto;
right: 1%;
bottom: 1.5em;
height: auto;
min-height: 1em;
width: auto;
font-size: large;
padding: 2em;
border-radius: 2em;
z-index: 5000;
opacity: 1;
background-color: white;
transition: all 500ms linear;
text-align: center;
horiz-align: center;
font-weight: bold;
}
.uielement{
width:100%;
height:100%;
margin:0;
padding:0;
}
.activate-osm-authentication {
cursor: pointer;
color: blue;
text-decoration: underline;
}
.popupImg {
max-height: 150px;
max-width: 300px;
}
.image-slidehow{
width: 500px;
height: 250px;
}
.slide {
}
.slide img{
max-height: 200px;
max-width: 500px;
}
.hidden {
display: none;
}

54
index.html Normal file
View File

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>MapComplete</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.6.0/dist/leaflet.css"
integrity="sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ=="
crossorigin=""/>
<link rel="stylesheet" href="./index.css"/>
<link rel="stylesheet" href="node_modules/@splidejs/splide/dist/css/splide.min.css">
</head>
<body>
<div id="authbox">
<div id="userbadge">
</div>
<br/>
<div id="pendingchangesbox"></div>
<br/>
<div id="messagesbox">
<div id="welcomeMessage"></div>
<div id="gettingStartedBox"></div>
</div>
</div>
<div id="centermessage"></div>
<div id="bottomRight">ADD</div>
<div id="leafletDiv"></div>
<script src="./index.ts"></script>
<!-- TODO -->
<!-- Fotos -->
<!-- KLeuren/icoontjes -->
<!-- Aanpassingen van tagmapping/tagmapping verwijderen -->
<!-- 3 dagen eerste protoype -->
<!-- 19 juni: eerste feedbackronde, foto's -->
<script data-goatcounter="https://pietervdvn.goatcounter.com/count"
async src="//gc.zgo.at/count.js"></script>
</body>
</html>

135
index.ts Normal file
View File

@ -0,0 +1,135 @@
import {OsmConnection} from "./Logic/OsmConnection";
import {Changes} from "./Logic/Changes";
import {ElementStorage} from "./Logic/ElementStorage";
import {UIEventSource} from "./UI/UIEventSource";
import {UserBadge} from "./UI/UserBadge";
import {Basemap} from "./Logic/Basemap";
import {PendingChanges} from "./UI/PendingChanges";
import {FixedUiElement} from "./UI/FixedUiElement";
import {CenterMessageBox} from "./UI/CenterMessageBox";
import {Helpers} from "./Helpers";
import {KnownSet} from "./Layers/KnownSet";
import {AddButton} from "./UI/AddButton";
import {Tag} from "./Logic/TagsFilter";
import {FilteredLayer} from "./Logic/FilteredLayer";
import {LayerUpdater} from "./Logic/LayerUpdater";
import {Overpass} from "./Logic/Overpass";
import {LoginDependendMessage} from "./UI/LoginDependendMessage";
// Set to true if testing and changes should NOT be saved
const dryRun = false;
// Overpass.testUrl = "http://127.0.0.1:8080/test.json";
// ----------------- SELECT THE RIGHT QUESTSET -----------------
let questSetToRender = KnownSet.groen;
if (window.location.search) {
const params = window.location.search.substr(1).split("&");
const paramDict: any = {};
for (const param of params) {
var kv = param.split("=");
paramDict[kv[0]] = kv[1];
}
if (paramDict.quests) {
questSetToRender = KnownSet.allSets[paramDict.quests];
console.log("Using quests: ", questSetToRender.name);
}
}
document.title = questSetToRender.title;
// ----------------- Setup a few event sources -------------
// The message that should be shown at the center of the screen
const centerMessage = new UIEventSource<string>("");
// The countdown: if set to e.g. ten, it'll start counting down. When reaching zero, changes will be saved. NB: this is implemented later, not in the eventSource
const secondsTillChangesAreSaved = new UIEventSource<number>(0);
var locationControl = new UIEventSource({
zoom: questSetToRender.startzoom,
lat: questSetToRender.startLat,
lon: questSetToRender.startLon
});
// ----------------- Prepare the important objects -----------------
const allElements = new ElementStorage();
const osmConnection = new OsmConnection(dryRun);
const changes = new Changes(osmConnection, allElements, centerMessage);
const bm = new Basemap("leafletDiv", locationControl);
// ------------- Setup the layers -------------------------------
const addButtons: {
name: string,
icon: string,
tags: Tag[],
layerToAddTo: FilteredLayer
}[]
= [];
const flayers: FilteredLayer[] = []
for (const layer of questSetToRender.layers) {
const flayer = layer.asLayer(bm, allElements, changes);
const addButton = {
name: layer.name,
icon: layer.icon,
tags: layer.newElementTags,
layerToAddTo: flayer
}
addButtons.push(addButton);
flayers.push(flayer);
}
const layerUpdater = new LayerUpdater(bm, questSetToRender.startzoom, flayers);
// ------------------ Setup various UI elements ------------
const addButton = new AddButton(bm, changes, addButtons);
addButton.AttachTo("bottomRight");
addButton.Update();
new UserBadge(osmConnection.userDetails)
.AttachTo('userbadge');
new FixedUiElement(questSetToRender.welcomeMessage)
.AttachTo("welcomeMessage");
new LoginDependendMessage(osmConnection.userDetails, questSetToRender.gettingStartedPlzLogin, questSetToRender.welcomeBackMessage)
.AttachTo("gettingStartedBox");
new PendingChanges(changes, secondsTillChangesAreSaved)
.AttachTo("pendingchangesbox");
new CenterMessageBox(
questSetToRender.startzoom,
centerMessage,
osmConnection,
locationControl,
layerUpdater.runningQuery)
.AttachTo("centermessage");
Helpers.SetupAutoSave(changes, secondsTillChangesAreSaved);
Helpers.LastEffortSave(changes);
Helpers.registerActivateOsmAUthenticationClass(osmConnection);
// --------------- Send a ping to start various action --------
locationControl.ping();

10
land.html Normal file
View File

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head><title>MapComplete Auth</title></head>
<body>
<script>
opener.authComplete(window.location.href);
window.close();
</script>
</body>
</html>

6571
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "mapcomplete",
"version": "0.0.1",
"description": "A small website to edit OSM easily",
"main": "index.js",
"scripts": {
"start": "parcel index.html land.html test.html assets/test.json assets/* UI/* Logic/*",
"build": "parcel build --public-url ./ index.html land.html assets/*",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"OpenStreetMap",
"Editor"
],
"author": "pietervdvn",
"license": "MIT",
"dependencies": {
"@splidejs/splide": "^2.4.0",
"jquery": "latest",
"leaflet": "^1.6.0",
"osm-auth": "^1.0.2",
"osmtogeojson": "^3.0.0-beta.4",
"parcel": "^1.12.4"
},
"devDependencies": {
"typescript": "^3.9.3"
}
}

11
test.html Normal file
View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<title>Small tests</title>
<link href="index.css" rel="stylesheet"/>
</head>
<body>
<div id="maindiv">Hello World</div>
<script src="./test.ts"></script>
</body>
</html>

63
test.ts Normal file
View File

@ -0,0 +1,63 @@
import {UIEventSource} from "./UI/UIEventSource";
import {WikimediaImage} from "./UI/Image/WikimediaImage";
import {ImagesInCategory, Wikidata, Wikimedia} from "./Logic/Wikimedia";
import {UIElement} from "./UI/UIElement";
import {SlideShow} from "./UI/SlideShow";
import {ImageSearcher} from "./Logic/ImageSearcher";
import {KnownSet} from "./Layers/KnownSet";
import {Park} from "./Layers/Park";
import {FixedUiElement} from "./UI/FixedUiElement";
let properties = {
image: "https://www.designindaba.com/sites/default/files/node/news/21663/buteparkcardiff.jpg",
wikimedia_commons: "File:Boekenkast Sint-Lodewijks.jpg",
wikidata: "Q2763812"};
let tagsES = new UIEventSource<any>(properties);
let searcher = new ImageSearcher(tagsES);
const uiElements = searcher.map((imageURLS : string[]) => {
const uiElements : UIElement[] = [];
for (const url of imageURLS) {
uiElements.push(ImageSearcher.CreateImageElement(url));
}
return uiElements;
});
new SlideShow(
new FixedUiElement("<b>Afbeeldingen</b>"),
uiElements,
new FixedUiElement("Geen afbeeldingen gevonden...")
).AttachTo("maindiv");
searcher.Activate();
/*
const imageSource = new UIEventSource<string>("https://commons.wikimedia.org/wiki/Special:FilePath/File:Pastoor van Haeckeplantsoen, Brugge (1).JPG?width=1000");
// new SimpleImageElement(imageSource).AttachTo("maindiv");
const wikimediaImageSource = new UIEventSource<string>("File:Deelboekenkast_rouppeplein.jpg");
// new WikimediaImage(wikimediaImageSource).AttachTo("maindiv");
const wdItem = 2763812;
Wikimedia.GetWikiData(wdItem, (wd : Wikidata) => {
const category = wd.commonsWiki;
Wikimedia.GetCategoryFiles(category, (images: ImagesInCategory) => {
const imageElements: UIElement[] = [];
for (const image of images.images) {
const wikimediaImageSource = new UIEventSource<string>(image.filename);
var uielem = new WikimediaImage(wikimediaImageSource);
imageElements.push(uielem);
}
var slides = new UIEventSource<UIElement[]>(imageElements);
new SlideShow(slides).AttachTo("maindiv");
})
})
*/