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

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