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

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 = "";
}