Merge bike-bumps

This commit is contained in:
Pieter Fiers 2020-07-08 17:12:23 +02:00
commit 27a7b03d55
25 changed files with 667 additions and 119 deletions

View file

@ -3,8 +3,7 @@ import {Toilets} from "./Layouts/Toilets";
import {GRB} from "./Layouts/GRB"; import {GRB} from "./Layouts/GRB";
import {Statues} from "./Layouts/Statues"; import {Statues} from "./Layouts/Statues";
import {Bookcases} from "./Layouts/Bookcases"; import {Bookcases} from "./Layouts/Bookcases";
import { BikePumps } from "./Layers/BikePumps"; import Cyclofix from "./Layouts/Cyclofix";
import { BikePumpsLayout } from "./Layouts/BikePumps";
export class AllKnownLayouts { export class AllKnownLayouts {
public static allSets: any = AllKnownLayouts.AllLayouts(); public static allSets: any = AllKnownLayouts.AllLayouts();
@ -13,10 +12,11 @@ export class AllKnownLayouts {
const layouts = [ const layouts = [
new Groen(), new Groen(),
new GRB(), new GRB(),
new BikePumpsLayout(), new Cyclofix(),
new Bookcases()
/*new Toilets(), /*new Toilets(),
new Statues(), new Statues(),
new Bookcases()*/ */
]; ];
const allSets = {}; const allSets = {};
for (const layout of layouts) { for (const layout of layouts) {

View file

@ -11,13 +11,22 @@ import {TagRenderingOptions} from "./TagRendering";
export class LayerDefinition { export class LayerDefinition {
/**
* This name is shown in the 'add XXX button'
*/
name: string; name: string;
newElementTags: Tag[] newElementTags: Tag[]
icon: string; icon: string;
minzoom: number; minzoom: number;
overpassFilter: TagsFilter; overpassFilter: TagsFilter;
/**
* This UIElement is rendered as title element in the popup
*/
title: TagRenderingOptions; title: TagRenderingOptions;
/**
* These are the questions/shown attributes in the popup
*/
elementsToShow: TagRenderingOptions[]; elementsToShow: TagRenderingOptions[];
style: (tags: any) => { color: string, icon: any }; style: (tags: any) => { color: string, icon: any };

View file

@ -3,6 +3,7 @@ import L from "leaflet";
import {Tag} from "../../Logic/TagsFilter"; import {Tag} from "../../Logic/TagsFilter";
import {QuestionDefinition} from "../../Logic/Question"; import {QuestionDefinition} from "../../Logic/Question";
import {TagRenderingOptions} from "../TagRendering"; import {TagRenderingOptions} from "../TagRendering";
import {NameInline} from "../Questions/NameInline";
export class Bookcases extends LayerDefinition { export class Bookcases extends LayerDefinition {
@ -10,21 +11,38 @@ export class Bookcases extends LayerDefinition {
super(); super();
this.name = "boekenkast"; this.name = "boekenkast";
this.newElementTags = [new Tag( "amenity", "public_bookcase")]; this.newElementTags = [new Tag("amenity", "public_bookcase")];
this.icon = "./assets/bookcase.svg"; this.icon = "./assets/bookcase.svg";
this.overpassFilter = new Tag("amenity","public_bookcase"); this.overpassFilter = new Tag("amenity", "public_bookcase");
this.minzoom = 13; this.minzoom = 13;
this.title = new NameInline("ruilboekenkastje");
this.elementsToShow = [
this.questions = [ new TagRenderingOptions(
{
question: "Hoeveel boeken passen in dit boekenruilkastje?",
freeform: {
renderTemplate: "Er passen {capacity} boeken in dit boekenruilkastje",
template: "Er passen $$$ boeken in dit boekenruilkastje",
key: "capacity",
placeholder: "aantal"
},
priority: 15
}
)
];
/* this.questions = [
QuestionDefinition.noNameOrNameQuestion("Wat is de naam van dit boekenruilkastje?", "Dit boekenruilkastje heeft niet echt een naam", 20), 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("Heeft dit boekenkastje een peter, meter of voogd?", "operator", 10),
// QuestionDefinition.textQuestion("Wie kunnen we (per email) contacteren voor dit boekenruilkastje?", "email", 5), // QuestionDefinition.textQuestion("Wie kunnen we (per email) contacteren voor dit boekenruilkastje?", "email", 5),
] ]
; ;
*/
this.style = function (tags) { this.style = function (tags) {
return { return {
@ -36,6 +54,7 @@ export class Bookcases extends LayerDefinition {
}; };
} }
/*
this.elementsToShow = [ this.elementsToShow = [
@ -58,7 +77,7 @@ export class Bookcases extends LayerDefinition {
new TagMappingOptions({key: "description", template: "Extra beschrijving: <br /> <p>{description}</p>"}), new TagMappingOptions({key: "description", template: "Extra beschrijving: <br /> <p>{description}</p>"}),
] ]
; ;*/
} }

View file

@ -9,6 +9,32 @@ import {NameInline} from "../Questions/NameInline";
export class Park extends LayerDefinition { export class Park extends LayerDefinition {
private accessByDefault = new TagRenderingOptions({
question: "Is dit park publiek toegankelijk?",
mappings: [
{k: new Tag("access", "yes"), txt: "Publiek toegankelijk"},
{k: new Tag("access", ""), txt: "Publiek toegankelijk"},
{k: new Tag("access", "no"), txt: "Niet publiek toegankelijk"},
{k: new Tag("access", "guided"), txt: "Enkel toegankelijk met een gids of op een activiteit"}
]
})
private operatorByDefault = new
TagRenderingOptions({
question: "Wie beheert dit park?",
freeform: {
key: "operator",
renderTemplate: "Dit park wordt beheerd door {operator}",
template: "$$$",
},
mappings: [{
k: null, txt: "De gemeente beheert dit park"
}]
});
constructor() { constructor() {
super(); super();
this.name = "park"; this.name = "park";
@ -22,7 +48,11 @@ export class Park extends LayerDefinition {
this.minzoom = 13; this.minzoom = 13;
this.style = this.generateStyleFunction(); this.style = this.generateStyleFunction();
this.title = new NameInline("park"); this.title = new NameInline("park");
this.elementsToShow = [new NameQuestion()]; this.elementsToShow = [new NameQuestion(),
this.accessByDefault,
this.operatorByDefault
];
} }

View file

@ -3,7 +3,7 @@ import {GrbToFix} from "../Layers/GrbToFix";
import { BikePumps } from "../Layers/BikePumps"; import { BikePumps } from "../Layers/BikePumps";
import { BikeParkings } from "../Layers/BikeParkings"; import { BikeParkings } from "../Layers/BikeParkings";
export class BikePumpsLayout extends Layout { export default class Cyclofix extends Layout {
constructor() { constructor() {
super( super(
"pomp", "pomp",
@ -14,10 +14,14 @@ export class BikePumpsLayout extends Layout {
3.2279, 3.2279,
"<h3>GRB Fix tool</h3>\n" + "<h3>Cyclofix bicycle infrastructure</h3>\n" +
"\n" + "\n" +
"Expert use only" "<p><b>EN&gt;</b> On this map we want to collect data about the whereabouts of bicycle pumps and public racks in Brussels." +
"As a result, cyclists will be able to quickly find the nearest infrastructure for their needs.</p>" +
"<p><b>NL&gt;</b> Op deze kaart willen we gegevens verzamelen over de locatie van fietspompen en openbare stelplaatsen in Brussel." +
"Hierdoor kunnen fietsers snel de dichtstbijzijnde infrastructuur vinden die voldoet aan hun behoeften.</p>" +
"<p><b>FR&gt;</b> Sur cette carte, nous voulons collecter des données sur la localisation des pompes à vélo et des supports publics à Bruxelles." +
"Les cyclistes pourront ainsi trouver rapidement l'infrastructure la plus proche de leurs besoins.</p>"
, ,
"", ""); "", "");
} }

View file

@ -6,7 +6,7 @@ import {Layout} from "../Layout";
export class Groen extends Layout { export class Groen extends Layout {
constructor() { constructor() {
super("groen", super("buurtnatuur",
"Buurtnatuur", "Buurtnatuur",
[new NatureReserves(), new Park(), new Bos()], [new NatureReserves(), new Park(), new Bos()],
10, 10,

View file

@ -23,9 +23,25 @@ export class TagRenderingOptions {
constructor(options: { constructor(options: {
/**
* What is the priority of the question.
* By default, in the popup of a feature, only one question is shown at the same time. If multiple questions are unanswered, the question with the highest priority is asked first
*/
priority?: number priority?: number
/**
* This is the string that is shown in the popup if this tag is missing.
*
* If 'question' is undefined, then the question is never asked at all
* If the question is "" (empty string) then the question is
*/
question?: string, question?: string,
/**
* Optional:
* if defined, this a common piece of tag that is shown in front of every mapping (except freeform)
*/
primer?: string, primer?: string,
tagsPreprocessor?: ((tags: any) => any), tagsPreprocessor?: ((tags: any) => any),
freeform?: { freeform?: {
@ -34,10 +50,21 @@ export class TagRenderingOptions {
placeholder?: string, placeholder?: string,
extraTags?: TagsFilter, extraTags?: TagsFilter,
}, },
/**
* Mappings convert a well-known tag combination into a user friendly text.
* It converts e.g. 'access=yes' into 'this area can be accessed'
*
* If there are multiple tags that should be matched, And can be used. All tags in AND will be added when the question is picked (and the corresponding text will only be shown if all tags are present).
* If AND is used, it is best practice to make sure every used tag is in every option (with empty string) to erase extra tags.
*
* If a 'k' is null, then this one is shown by default. It can be used to force a default value, e.g. to show that the name of a POI is not (yet) known .
* A mapping where 'k' is null will not be shown as option in the radio buttons.
*
*
*/
mappings?: { k: TagsFilter, txt: string, priority?: number, substitute?: boolean }[] mappings?: { k: TagsFilter, txt: string, priority?: number, substitute?: boolean }[]
}) { }) {
this.options = options; this.options = options;
} }
@ -45,7 +72,7 @@ export class TagRenderingOptions {
const tagsKV = TagUtils.proprtiesToKV(tags); const tagsKV = TagUtils.proprtiesToKV(tags);
for (const oneOnOneElement of this.options.mappings) { for (const oneOnOneElement of this.options.mappings) {
if (oneOnOneElement.k.matches(tagsKV)) { if (oneOnOneElement.k === null || oneOnOneElement.k.matches(tagsKV)) {
return false; return false;
} }
} }
@ -71,6 +98,8 @@ export class TagRendering extends UIElement {
private _question: string; private _question: string;
private _primer: string; private _primer: string;
private _mapping: { k: TagsFilter, txt: string, priority?: number, substitute?: boolean }[]; private _mapping: { k: TagsFilter, txt: string, priority?: number, substitute?: boolean }[];
private _renderMapping: { k: TagsFilter, txt: string, priority?: number, substitute?: boolean }[];
private _tagsPreprocessor?: ((tags: any) => any); private _tagsPreprocessor?: ((tags: any) => any);
private _freeform: { private _freeform: {
key: string, template: string, key: string, template: string,
@ -116,12 +145,13 @@ export class TagRendering extends UIElement {
this._primer = options.primer ?? ""; this._primer = options.primer ?? "";
this._tagsPreprocessor = options.tagsPreprocessor; this._tagsPreprocessor = options.tagsPreprocessor;
this._mapping = []; this._mapping = [];
this._renderMapping = [];
this._freeform = options.freeform; this._freeform = options.freeform;
this.elementPriority = options.priority ?? 0; this.elementPriority = options.priority ?? 0;
// Prepare the choices for the Radio buttons // Prepare the choices for the Radio buttons
let i = 0;
const choices: UIElement[] = []; const choices: UIElement[] = [];
const usedChoices: string [] = [];
for (const choice of options.mappings ?? []) { for (const choice of options.mappings ?? []) {
if (choice.k === null) { if (choice.k === null) {
@ -131,18 +161,26 @@ export class TagRendering extends UIElement {
let choiceSubbed = choice; let choiceSubbed = choice;
if (choice.substitute) { if (choice.substitute) {
choiceSubbed = { choiceSubbed = {
k : choice.k.substituteValues( k: choice.k.substituteValues(
options.tagsPreprocessor(this._source.data)), options.tagsPreprocessor(this._source.data)),
txt : this.ApplyTemplate(choice.txt), txt: this.ApplyTemplate(choice.txt),
substitute: false, substitute: false,
priority: choice.priority priority: choice.priority
} }
} }
choices.push(new FixedUiElement(choiceSubbed.txt)); const txt = choiceSubbed.txt
// Choices is what is shown in the radio buttons
if (usedChoices.indexOf(txt) < 0) {
choices.push(new FixedUiElement(txt));
usedChoices.push(txt);
// This is used to convert the radio button index into tags needed to add
this._mapping.push(choiceSubbed); this._mapping.push(choiceSubbed);
i++; } else {
this._renderMapping.push(choiceSubbed); // only used while rendering
}
} }
// Map radiobutton choice and textfield answer onto tagfilter. That tagfilter will be pushed into the changes later on // Map radiobutton choice and textfield answer onto tagfilter. That tagfilter will be pushed into the changes later on
@ -172,6 +210,7 @@ export class TagRendering extends UIElement {
// Prepare the actual input element -> pick an appropriate implementation // Prepare the actual input element -> pick an appropriate implementation
let inputElement: UIInputElement<TagsFilter>; let inputElement: UIInputElement<TagsFilter>;
if (this._freeform !== undefined && this._mapping !== undefined) { if (this._freeform !== undefined && this._mapping !== undefined) {
// Radio buttons with 'other' // Radio buttons with 'other'
inputElement = new UIRadioButtonWithOther( inputElement = new UIRadioButtonWithOther(
@ -182,14 +221,15 @@ export class TagRendering extends UIElement {
pickString pickString
); );
this._questionElement = inputElement; this._questionElement = inputElement;
} else if (this._mapping !== undefined) { } else if (this._mapping !== [] && this._mapping.length > 0) {
// This is a classic radio selection element // This is a classic radio selection element
inputElement = new UIRadioButton(new UIEventSource(choices), pickChoice) inputElement = new UIRadioButton(new UIEventSource(choices), pickChoice, false)
this._questionElement = inputElement; this._questionElement = inputElement;
} else if (this._freeform !== undefined) { } else if (this._freeform !== undefined) {
this._textField = new TextField(new UIEventSource<string>(this._freeform.placeholder), pickString); this._textField = new TextField(new UIEventSource<string>(this._freeform.placeholder), pickString);
inputElement = this._textField; inputElement = this._textField;
this._questionElement = new FixedUiElement(this._freeform.template.replace("$$$", inputElement.Render())) this._questionElement = new FixedUiElement(
"<div>" + this._freeform.template.replace("$$$", inputElement.Render()) + "</div>")
} else { } else {
throw "Invalid questionRendering, expected at least choices or a freeform" throw "Invalid questionRendering, expected at least choices or a freeform"
} }
@ -206,6 +246,7 @@ export class TagRendering extends UIElement {
const cancel = () => { const cancel = () => {
self._questionSkipped.setData(true); self._questionSkipped.setData(true);
self._editMode.setData(false); self._editMode.setData(false);
self._source.ping(); // Send a ping upstream to render the next question
} }
// Setup the save button and it's action // Setup the save button and it's action
@ -253,11 +294,12 @@ export class TagRendering extends UIElement {
IsKnown(): boolean { IsKnown(): boolean {
const tags = TagUtils.proprtiesToKV(this._source.data); const tags = TagUtils.proprtiesToKV(this._source.data);
for (const oneOnOneElement of this._mapping) { for (const oneOnOneElement of this._mapping.concat(this._renderMapping)) {
if (oneOnOneElement.k === null || oneOnOneElement.k.matches(tags)) { if (oneOnOneElement.k === null || oneOnOneElement.k.matches(tags)) {
return true; return true;
} }
} }
return this._freeform !== undefined && this._source.data[this._freeform.key] !== undefined; return this._freeform !== undefined && this._source.data[this._freeform.key] !== undefined;
} }
@ -286,11 +328,10 @@ export class TagRendering extends UIElement {
freeformScore = 0; freeformScore = 0;
} }
if (this._mapping !== undefined) {
let highestScore = -100; let highestScore = -100;
let highestTemplate = undefined; let highestTemplate = undefined;
for (const oneOnOneElement of this._mapping) { for (const oneOnOneElement of this._mapping.concat(this._renderMapping)) {
if (oneOnOneElement.k == null || if (oneOnOneElement.k == null ||
oneOnOneElement.k.matches(tags)) { oneOnOneElement.k.matches(tags)) {
// We have found a matching key -> we use the template, but only if it scores better // We have found a matching key -> we use the template, but only if it scores better
@ -311,9 +352,7 @@ export class TagRendering extends UIElement {
// we render the found template // we render the found template
return this._primer + this.ApplyTemplate(highestTemplate); return this._primer + this.ApplyTemplate(highestTemplate);
} }
} else {
return freeform;
}
} }
@ -324,7 +363,7 @@ export class TagRendering extends UIElement {
return "<div class='question'>" + return "<div class='question'>" +
this._question + "<span class='question-text'>" + this._question + "</span>" +
(this._question !== "" ? "<br/>" : "") + (this._question !== "" ? "<br/>" : "") +
this._questionElement.Render() + this._questionElement.Render() +
this._skipButton.Render() + this._skipButton.Render() +

View file

@ -190,15 +190,16 @@ export class FilteredLayer {
}); });
const uiElement = self._showOnPopup(eventSource);
layer.bindPopup(uiElement.Render());
layer.on("click", function (e) { layer.on("click", function (e) {
console.log("Selected ", feature) console.log("Selected ", feature)
self._selectedElement.setData(feature.properties); self._selectedElement.setData(feature.properties);
const uiElement = self._showOnPopup(eventSource);
const popup = L.popup()
.setContent(uiElement.Render())
.setLatLng(e.latlng)
.openOn(self._map.map);
uiElement.Update(); uiElement.Update();
uiElement.Activate(); uiElement.Activate();
L.DomEvent.stop(e); // Marks the event as consumed L.DomEvent.stop(e); // Marks the event as consumed
}); });

View file

@ -3,8 +3,20 @@ import {ImagesInCategory, Wikidata, Wikimedia} from "./Wikimedia";
import {WikimediaImage} from "../UI/Image/WikimediaImage"; import {WikimediaImage} from "../UI/Image/WikimediaImage";
import {SimpleImageElement} from "../UI/Image/SimpleImageElement"; import {SimpleImageElement} from "../UI/Image/SimpleImageElement";
import {UIElement} from "../UI/UIElement"; import {UIElement} from "../UI/UIElement";
import {Changes} from "./Changes";
import {ImgurImage} from "../UI/Image/ImgurImage";
/**
* 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
*/
/** /**
* Class which search for all the possible locations for images and which builds a list of UI-elements for it. * 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 * Note that this list is embedded into an UIEVentSource, ready to put it into a carousel
@ -15,12 +27,16 @@ export class ImageSearcher extends UIEventSource<string[]> {
private readonly _wdItem = new UIEventSource<string>(""); private readonly _wdItem = new UIEventSource<string>("");
private readonly _commons = new UIEventSource<string>(""); private readonly _commons = new UIEventSource<string>("");
private _activated: boolean = false; private _activated: boolean = false;
private _changes: Changes;
public _deletedImages = new UIEventSource<string[]>([]);
constructor(tags: UIEventSource<any>) {
constructor(tags: UIEventSource<any>,
changes: Changes) {
super([]); super([]);
this._tags = tags; this._tags = tags;
this._changes = changes;
const self = this; const self = this;
this._wdItem.addCallback(() => { this._wdItem.addCallback(() => {
@ -34,6 +50,7 @@ export class ImageSearcher extends UIEventSource<string[]> {
self.AddImage(wd.image); self.AddImage(wd.image);
Wikimedia.GetCategoryFiles(wd.commonsWiki, (images: ImagesInCategory) => { Wikimedia.GetCategoryFiles(wd.commonsWiki, (images: ImagesInCategory) => {
for (const image of images.images) { for (const image of images.images) {
// @ts-ignore
if (image.startsWith("File:")) { if (image.startsWith("File:")) {
self.AddImage(image); self.AddImage(image);
} }
@ -67,17 +84,48 @@ export class ImageSearcher extends UIEventSource<string[]> {
} }
private AddImage(url: string) { private AddImage(url: string) {
if(url === undefined || url === null){ if (url === undefined || url === null || url === "") {
return; return;
} }
if (this.data.indexOf(url) < 0) {
this.data.push(url); for (const el of this.data) {
this.ping(); if (el === url) {
return;
} }
} }
this.data.push(url);
this.ping();
}
private ImageKey(url: string): string {
const tgs = this._tags.data;
for (const key in tgs) {
if (tgs[key] === url) {
return key;
}
}
return undefined;
}
public IsDeletable(url: string): boolean {
return this.ImageKey(url) !== undefined;
}
public Delete(url: string): void {
const key = this.ImageKey(url);
if (key === undefined) {
return;
}
console.log("Deleting image...", key, " --> ", url);
this._changes.addChange(this._tags.data.id, key, "");
this._deletedImages.data.push(url);
this._deletedImages.ping();
}
public Activate() { public Activate() {
if(this._activated){ if (this._activated) {
return; return;
} }
this._activated = true; this._activated = true;
@ -98,16 +146,12 @@ export class ImageSearcher extends UIEventSource<string[]> {
} }
} }
const image0 = this._tags.data["image:0"]; for (const key in this._tags.data) {
if (image0 !== undefined) { // @ts-ignore
this.AddImage(image0); if (key.startsWith("image:")) {
const url = this._tags.data[key]
this.AddImage(url);
} }
let imageIndex = 1;
let imagei = this._tags.data["image:" + imageIndex];
while (imagei !== undefined) {
this.AddImage(imagei);
imageIndex++;
imagei = this._tags.data["image:" + imageIndex];
} }
const wdItem = this._tags.data.wikidata; const wdItem = this._tags.data.wikidata;
@ -127,12 +171,13 @@ export class ImageSearcher extends UIEventSource<string[]> {
* @constructor * @constructor
*/ */
static CreateImageElement(url: string): UIElement { static CreateImageElement(url: string): UIElement {
const urlSource = new UIEventSource<string>(url);
// @ts-ignore // @ts-ignore
if (url.startsWith("File:")) { if (url.startsWith("File:")) {
return new WikimediaImage(urlSource.data); return new WikimediaImage(url);
}else if(url.startsWith("https://i.imgur.com/")){
return new ImgurImage(url);
} else { } else {
return new SimpleImageElement(urlSource); return new SimpleImageElement(new UIEventSource<string>(url));
} }
} }

View file

@ -1,4 +1,5 @@
import $ from "jquery" import $ from "jquery"
import {LicenseInfo} from "./Wikimedia";
export class Imgur { export class Imgur {
@ -27,6 +28,50 @@ export class Imgur {
); );
}
static getDescriptionOfImage(url: string,
handleDescription: ((license: LicenseInfo) => void)) {
const hash = url.substr("https://i.imgur.com/".length).split(".jpg")[0];
const apiUrl = 'https://api.imgur.com/3/image/'+hash;
const apiKey = '7070e7167f0a25a';
var settings = {
async: true,
crossDomain: true,
processData: false,
contentType: false,
type: 'GET',
url: apiUrl,
headers: {
Authorization: 'Client-ID ' + apiKey,
Accept: 'application/json',
},
};
$.ajax(settings).done(function (response) {
const descr : string= response.data.description;
const data : any = {};
for (const tag of descr.split("\n")) {
const kv = tag.split(":");
const k = kv[0];
const v = kv[1].replace("\r", "");
data[k] = v;
}
console.log(data);
const licenseInfo = new LicenseInfo();
licenseInfo.licenseShortName = data.license;
licenseInfo.artist = data.author;
handleDescription(licenseInfo);
}).fail((reason) => {
console.log("Getting metadata from to IMGUR failed", reason)
});
} }
static uploadImage(title: string, description: string, blob, static uploadImage(title: string, description: string, blob,

View file

@ -78,7 +78,8 @@ Camera Icon, Dave Gandy, CC-BY-SA 3.0
https://commons.wikimedia.org/wiki/File:OOjs_UI_indicator_search-rtl.svg https://commons.wikimedia.org/wiki/File:OOjs_UI_indicator_search-rtl.svg
Search Icon, MIT Search Icon, MIT
https://commons.wikimedia.org/wiki/File:Trash_font_awesome.svg
Trash icon by Dave Gandy, CC-BY-SA
https://commons.wikimedia.org/wiki/File:Home-icon.svg https://commons.wikimedia.org/wiki/File:Home-icon.svg
Home icon by Timothy Miller, CC-BY-SA 3.0 Home icon by Timothy Miller, CC-BY-SA 3.0

View file

@ -74,10 +74,12 @@ export class UIRadioButton<T> extends UIInputElement<T> {
if (this.SelectedElementIndex.data == null) { if (this.SelectedElementIndex.data == null) {
if (this._selectFirstAsDefault) { if (this._selectFirstAsDefault) {
const el = document.getElementById(this.IdFor(0)); const el = document.getElementById(this.IdFor(0));
if (el) {
// @ts-ignore // @ts-ignore
el.checked = true; el.checked = true;
checkButtons(); checkButtons();
} }
}
} else { } else {
// We check that what is selected matches the previous rendering // We check that what is selected matches the previous rendering

69
UI/ConfirmDialog.ts Normal file
View file

@ -0,0 +1,69 @@
import {UIElement} from "./UIElement";
import {UIEventSource} from "./UIEventSource";
import {FixedUiElement} from "./Base/FixedUiElement";
import {VariableUiElement} from "./Base/VariableUIElement";
export class ConfirmDialog extends UIElement {
private _showOptions: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private _question: UIElement;
private _optionA: UIElement;
private _optionB: UIElement;
constructor(
show: UIEventSource<boolean>,
question: string,
optionA: string, optionB: string,
executeA: () => void,
executeB: () => void,
classA: string = "",
classB: string = "") {
super(show);
this.ListenTo(this._showOptions);
const self = this;
show.addCallback(() => {
self._showOptions.setData(false);
})
this._question = new FixedUiElement("<span class='ui-question'>" + question + "</span>")
.onClick(() => {
self._showOptions.setData(!self._showOptions.data);
});
this._optionA = new VariableUiElement(
this._showOptions.map(
(show) => show ? "<div class='" + classA + "'>" + optionA + "</div>" : ""))
.onClick(() => {
self._showOptions.setData(false);
executeA();
}
);
this._optionB = new VariableUiElement(
this._showOptions.map((show) =>
show ? "<div class='" + classB + "'>" + optionB + "</div>" : "") )
.onClick(() => {
self._showOptions.setData(false);
executeB();
});
}
protected InnerRender(): string {
if (!this._source.data) {
return "";
}
return this._question.Render() +
this._optionA.Render() +
this._optionB.Render();
}
Update() {
super.Update();
this._question.Update();
this._optionA.Update();
this._optionB.Update();
}
}

View file

@ -44,7 +44,7 @@ export class FeatureInfoBox extends UIElement {
this._userDetails = userDetails; this._userDetails = userDetails;
this.ListenTo(userDetails); this.ListenTo(userDetails);
this._imageElement = new ImageCarousel(this._tagsES); this._imageElement = new ImageCarousel(this._tagsES, changes);
this._infoboxes = []; this._infoboxes = [];
for (const tagRenderingOption of elementsToShow) { for (const tagRenderingOption of elementsToShow) {

View file

@ -3,43 +3,100 @@ import {ImageSearcher} from "../../Logic/ImageSearcher";
import {UIEventSource} from "../UIEventSource"; import {UIEventSource} from "../UIEventSource";
import {SlideShow} from "../SlideShow"; import {SlideShow} from "../SlideShow";
import {FixedUiElement} from "../Base/FixedUiElement"; import {FixedUiElement} from "../Base/FixedUiElement";
import {VerticalCombine} from "../Base/VerticalCombine";
import {Changes} from "../../Logic/Changes";
import {VariableUiElement} from "../Base/VariableUIElement";
import {ConfirmDialog} from "../ConfirmDialog";
export class ImageCarousel extends UIElement { 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 searcher: ImageSearcher;
public readonly slideshow: SlideShow; public readonly slideshow: SlideShow;
constructor(tags: UIEventSource<any>) { private readonly _uiElements: UIEventSource<UIElement[]>;
private readonly _deleteButton: UIElement;
private readonly _isDeleted: UIElement;
constructor(tags: UIEventSource<any>, changes: Changes) {
super(tags); super(tags);
this.searcher = new ImageSearcher(tags); const self = this;
this.searcher = new ImageSearcher(tags, changes);
let uiElements = this.searcher.map((imageURLS: string[]) => { this._uiElements = this.searcher.map((imageURLS: string[]) => {
const uiElements: UIElement[] = []; const uiElements: UIElement[] = [];
for (const url of imageURLS) { for (const url of imageURLS) {
uiElements.push(ImageSearcher.CreateImageElement(url)); const image = ImageSearcher.CreateImageElement(url);
uiElements.push(image);
} }
return uiElements; return uiElements;
}); });
this.slideshow = new SlideShow( this.slideshow = new SlideShow(
uiElements, this._uiElements,
new FixedUiElement("")).HideOnEmpty(true); new FixedUiElement("")).HideOnEmpty(true);
const showDeleteButton = this.slideshow._currentSlide.map((i) => {
return self.searcher.IsDeletable(self.searcher.data[i]);
}, [this.searcher]);
this.slideshow._currentSlide.addCallback(() => {
showDeleteButton.ping(); // This pings the showDeleteButton, which indicates that it has to hide it's subbuttons
})
const deleteCurrent = () => {
self.searcher.Delete(self.searcher.data[self.slideshow._currentSlide.data]);
}
this._deleteButton = new ConfirmDialog(showDeleteButton,
"<img src='assets/delete.svg' alt='Afbeelding verwijderen' class='delete-image'>",
"<span>Afbeelding verwijderen</span>",
"<span>Terug</span>",
deleteCurrent,
() => {
},
'delete-image-confirm',
'delete-image-cancel');
const mapping = this.slideshow._currentSlide.map((i) => {
if (this.searcher._deletedImages.data.indexOf(
this.searcher.data[i]
) >= 0) {
return "<div class='image-is-removed'>Deze afbeelding is verwijderd</div>"
}
return "";
});
this._isDeleted = new VariableUiElement(
mapping
)
// .HideOnEmpty(true);
this.searcher._deletedImages.addCallback(() => {
this.slideshow._currentSlide.ping();
})
} }
InnerRender(): string { InnerRender(): string {
return this.slideshow.Render(); return "<span class='image-carousel-container'>" +
"<div class='image-delete-container'>" +
this._deleteButton.Render() +
this._isDeleted.Render() +
"</div>" +
this.slideshow.Render() +
"</span>";
}
InnerUpdate(htmlElement: HTMLElement) {
super.InnerUpdate(htmlElement);
this._deleteButton.Update();
this._isDeleted.Update();
} }

54
UI/Image/ImgurImage.ts Normal file
View file

@ -0,0 +1,54 @@
import {UIEventSource} from "../UIEventSource";
import {UIElement} from "../UIElement";
import {LicenseInfo} from "../../Logic/Wikimedia";
import {Imgur} from "../../Logic/Imgur";
export class ImgurImage extends UIElement {
/***
* Dictionary from url to alreayd known license info
*/
static allLicenseInfos: any = {};
private _imageMeta: UIEventSource<LicenseInfo>;
private _imageLocation: string;
constructor(source: string) {
super(undefined)
this._imageLocation = source;
if (ImgurImage.allLicenseInfos[source] !== undefined) {
this._imageMeta = ImgurImage.allLicenseInfos[source];
} else {
this._imageMeta = new UIEventSource<LicenseInfo>(null);
ImgurImage.allLicenseInfos[source] = this._imageMeta;
const self = this;
Imgur.getDescriptionOfImage(source, (license) => {
self._imageMeta.setData(license)
})
}
this.ListenTo(this._imageMeta);
}
protected InnerRender(): string {
const image = "<img src='" + this._imageLocation + "' " + "alt='' >";
if(this._imageMeta.data === null){
return image;
}
const attribution =
"<span class='attribution-author'><b>" + (this._imageMeta.data.artist ?? "") + "</b></span>" + " <span class='license'>" + (this._imageMeta.data.licenseShortName ?? "") + "</span>";
return "<div class='imgWithAttr'>" +
image +
"<div class='attribution'>" +
attribution +
"</div>" +
"</div>";
}
}

View file

@ -18,16 +18,17 @@ export class WikimediaImage extends UIElement {
} else { } else {
this._imageMeta = new UIEventSource<LicenseInfo>(new LicenseInfo()); this._imageMeta = new UIEventSource<LicenseInfo>(new LicenseInfo());
WikimediaImage.allLicenseInfos[source] = this._imageMeta; WikimediaImage.allLicenseInfos[source] = this._imageMeta;
}
this.ListenTo(this._imageMeta);
const self = this; const self = this;
Wikimedia.LicenseData(source, (info) => { Wikimedia.LicenseData(source, (info) => {
self._imageMeta.setData(info); self._imageMeta.setData(info);
}) })
} }
this.ListenTo(this._imageMeta);
}
protected InnerRender(): string { protected InnerRender(): string {
let url = Wikimedia.ImageNameToUrl(this._imageLocation, 500, 400); let url = Wikimedia.ImageNameToUrl(this._imageLocation, 500, 400);
url = url.replace(/'/g, '%27'); url = url.replace(/'/g, '%27');

View file

@ -6,7 +6,7 @@ export class SlideShow extends UIElement {
private readonly _embeddedElements: UIEventSource<UIElement[]> private readonly _embeddedElements: UIEventSource<UIElement[]>
private readonly _currentSlide: UIEventSource<number> = new UIEventSource<number>(0); public readonly _currentSlide: UIEventSource<number> = new UIEventSource<number>(0);
private readonly _noimages: UIElement; private readonly _noimages: UIElement;
private _prev: FixedUiElement; private _prev: FixedUiElement;
private _next: FixedUiElement; private _next: FixedUiElement;
@ -84,6 +84,8 @@ export class SlideShow extends UIElement {
for (const embeddedElement of this._embeddedElements.data) { for (const embeddedElement of this._embeddedElements.data) {
embeddedElement.Activate(); embeddedElement.Activate();
} }
this._next.Update();
this._prev.Update();
} }
} }

View file

@ -1,5 +1,4 @@
import {UIEventSource} from "./UIEventSource"; import {UIEventSource} from "./UIEventSource";
import {Playground} from "../Layers/Playground";
export abstract class UIElement { export abstract class UIElement {
@ -18,7 +17,7 @@ export abstract class UIElement {
} }
protected ListenTo(source: UIEventSource<any>) { public ListenTo(source: UIEventSource<any>) {
if (source === undefined) { if (source === undefined) {
return; return;
} }
@ -59,6 +58,17 @@ export abstract class UIElement {
} }
element.style.pointerEvents = "all"; element.style.pointerEvents = "all";
element.style.cursor = "pointer"; element.style.cursor = "pointer";
/*
const childs = element.children;
for (let i = 0; i < childs.length; i++) {
const ch = childs[i];
console.log(ch);
ch.style.cursor = "pointer";
ch.onclick = () => {
self._onClick();
}
ch.style.pointerEvents = "all";
}*/
} }
this.InnerUpdate(element); this.InnerUpdate(element);

View file

@ -27,16 +27,24 @@ export class UIEventSource<T>{
} }
} }
public map<J>(f: ((T) => J)): UIEventSource<J> { public map<J>(f: ((T) => J),
extraSources : UIEventSource<any>[] = []): UIEventSource<J> {
const self = this; const self = this;
this.addCallback(function () {
const update = function () {
newSource.setData(f(self.data)); newSource.setData(f(self.data));
newSource.ping(); newSource.ping();
}); }
this.addCallback(update);
for (const extraSource of extraSources) {
extraSource.addCallback(update);
}
const newSource = new UIEventSource<J>( const newSource = new UIEventSource<J>(
f(this.data) f(this.data)
); );
return newSource; return newSource;
} }

55
assets/delete.svg Normal file
View file

@ -0,0 +1,55 @@
<?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 -256 1792 1792"
id="svg3741"
version="1.1"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
width="100%"
height="100%"
sodipodi:docname="delete.svg">
<metadata
id="metadata3751">
<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="defs3749" />
<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="namedview3747"
showgrid="false"
inkscape:zoom="0.18624688"
inkscape:cx="795.91988"
inkscape:cy="822.60792"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg3741" />
<path
style="fill:#ff0000;fill-opacity:1"
inkscape:connector-curvature="0"
id="path3745"
d="m 709.42373,455.0508 v 576 q 0,14 -9,23 -9,9 -23,9 h -64 q -14,0 -23,-9 -9,-9 -9,-23 v -576 q 0,-14 9,-23 9,-9 23,-9 h 64 q 14,0 23,9 9,9 9,23 z m 256,0 v 576 q 0,14 -9,23 -9,9 -23,9 h -64 q -14,0 -23,-9 -9,-9 -9,-23 v -576 q 0,-14 9,-23 9,-9 23,-9 h 64 q 14,0 23,9 9,9 9,23 z m 255.99997,0 v 576 q 0,14 -9,23 -9,9 -23,9 h -64 q -14,0 -23,-9 -9,-9 -9,-23 v -576 q 0,-14 9,-23 9,-9 23,-9 h 64 q 14,0 23,9 9,9 9,23 z m 128,724 v -948 H 453.42373 v 948 q 0,22 7,40.5 7,18.5 14.5,27 7.5,8.5 10.5,8.5 h 831.99997 q 3,0 10.5,-8.5 7.5,-8.5 14.5,-27 7,-18.5 7,-40.5 z m -671.99997,-1076 h 447.99997 l -48,-117 q -7,-9 -17,-11 H 743.42373 q -10,2 -17,11 z m 927.99997,32 v 64 q 0,14 -9,23 -9,9 -23,9 h -96 v 948 q 0,83 -47,143.5 -47,60.5 -113,60.5 H 485.42373 q -66,0 -113,-58.5 -47,-58.5 -47,-141.5 v -952 h -96 q -14,0 -23,-9 -9,-9 -9,-23 v -64 q 0,-14 9,-23 9,-9 23,-9 h 309 l 70,-167 q 15,-37 54,-63 39,-26 79,-26 h 319.99997 q 40,0 79,26 39,26 54,63 l 70,167 h 309 q 14,0 23,9 9,9 9,23 z" />
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

115
index.css
View file

@ -8,6 +8,10 @@ body {
font-family: 'Helvetica Neue', Arial, sans-serif; font-family: 'Helvetica Neue', Arial, sans-serif;
} }
form {
display: inline;
}
#leafletDiv { #leafletDiv {
height: 100%; height: 100%;
} }
@ -457,14 +461,15 @@ body {
text-align: center; text-align: center;
} }
.slide > span {
max-height: 40vh;
}
.slide > span img { .slide > span img {
height: auto; height: auto;
width: auto; width: auto;
max-width: 100%; max-width: 100%;
max-height: 50vh; max-height: 30vh;
border-radius: 1em; border-radius: 1em;
} }
@ -497,7 +502,7 @@ body {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: 5em; /* Offset for the go left button*/ left: 6em; /* Offset for the go left button*/
padding: 0.25em; padding: 0.25em;
margin-bottom: 0.25em; margin-bottom: 0.25em;
border-radius: 0.5em; border-radius: 0.5em;
@ -669,6 +674,11 @@ body {
} }
.question-text{
font-size: larger;
font-weight: bold;
}
.answer { .answer {
display: inline-block; display: inline-block;
margin: 0.1em; margin: 0.1em;
@ -681,6 +691,103 @@ body {
display: inline-block display: inline-block
} }
/******* THe remove image buttons ****/
.image-carousel-container {
position: relative;
}
.image-is-removed{
display: inline-block;
left: 0;
top: 2.5em;
padding: 0.5em;
padding-left: 0.75em;
height: 3em;
width: 14em;
border-radius: 1em;
background-color: black;
color: white;
font-weight: bold;
height: 1.5em; /* same as .delete-image */
z-index: 7000;
}
.image-delete-container {
position: absolute;
left: 6em;
top: 1.5em;
display: inline-block;
z-index: 7000;
}
.delete-image {
width: 1.5em;
height: 1.5em;
padding: 0.5em;
border-radius: 3em;
background-color: black;
}
.delete-image-confirm {
position: absolute;
display: inline-block;
left: 0;
top: 2.5em;
padding: 0.5em;
padding-left: 0.75em;
height: 3em;
width: 14em;
border-radius: 1em;
border-top-left-radius: 0;
border-top-right-radius: 0;
background-color: #ff8c8c;
color: white;
height: 1.5em; /* same as .delete-image */
z-index: 7000;
}
.delete-image-confirm span {
font-size: larger;
font-weight: bold;
}
.delete-image-cancel {
display: inline-block;
position: absolute;
left: 0em;
padding: 0.5em;
padding-left: 0.75em;
border-radius: 1em;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
height: 1.5em; /* same as .delete-image */
width: 14em; /* Same as delete-image-confirm */
background-color: black;
color: white;
z-index: 7000;
}
.delete-image-cancel span {
font-size: larger;
font-weight: bold;
}
/**** The save button *****/ /**** The save button *****/

View file

@ -38,7 +38,7 @@ if (location.hostname === "localhost" || location.hostname === "127.0.0.1") {
// ----------------- SELECT THE RIGHT QUESTSET ----------------- // ----------------- SELECT THE RIGHT QUESTSET -----------------
let defaultQuest = "groen" let defaultQuest = "buurtnatuur"
if (window.location.search) { if (window.location.search) {
const params = window.location.search.substr(1).split("&"); const params = window.location.search.substr(1).split("&");
const paramDict: any = {}; const paramDict: any = {};
@ -88,7 +88,7 @@ const saveTimeout = 30000; // After this many milliseconds without changes, save
const allElements = new ElementStorage(); const allElements = new ElementStorage();
const osmConnection = new OsmConnection(dryRun); const osmConnection = new OsmConnection(dryRun);
const changes = new Changes( const changes = new Changes(
"Beantwoorden van vragen met MapComplete voor vragenset #" + questSetToRender.name, "Beantwoorden van vragen met #MapComplete voor vragenset #" + questSetToRender.name,
osmConnection, allElements); osmConnection, allElements);
const bm = new Basemap("leafletDiv", locationControl, new VariableUiElement( const bm = new Basemap("leafletDiv", locationControl, new VariableUiElement(
locationControl.map((location) => { locationControl.map((location) => {

View file

@ -5,7 +5,9 @@
<link href="index.css" rel="stylesheet"/> <link href="index.css" rel="stylesheet"/>
</head> </head>
<body> <body>
<span class="image-delete-container">
<div id="maindiv">'maindiv' not attached</div> <div id="maindiv">'maindiv' not attached</div>
</span>
<div id="extradiv">'extradiv' not attached</div> <div id="extradiv">'extradiv' not attached</div>
<script src="./test.ts"></script> <script src="./test.ts"></script>
</body> </body>

16
test.ts
View file

@ -4,18 +4,6 @@ import {OsmConnection} from "./Logic/OsmConnection";
import {ElementStorage} from "./Logic/ElementStorage"; import {ElementStorage} from "./Logic/ElementStorage";
import {WikipediaLink} from "./Customizations/Questions/WikipediaLink"; import {WikipediaLink} from "./Customizations/Questions/WikipediaLink";
import {OsmLink} from "./Customizations/Questions/OsmLink"; import {OsmLink} from "./Customizations/Questions/OsmLink";
import {ConfirmDialog} from "./UI/ConfirmDialog";
import {Imgur} from "./Logic/Imgur";
const tags = {name: "Test",
wikipedia: "nl:Pieter",
id: "node/-1"};
const tagsES = new UIEventSource(tags);
const login = new OsmConnection(true);
const allElements = new ElementStorage();
allElements.addElementById(tags.id, tagsES);
const changes = new Changes("Test", login, allElements)
new OsmLink(tagsES, changes).AttachTo("maindiv");