forked from MapComplete/MapComplete
Add MapComplete
This commit is contained in:
commit
6187122294
61 changed files with 107059 additions and 0 deletions
79
Logic/Basemap.ts
Normal file
79
Logic/Basemap.ts
Normal 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
257
Logic/Changes.ts
Normal 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
56
Logic/ElementStorage.ts
Normal 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
222
Logic/FilteredLayer.ts
Normal 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
171
Logic/GeoOperations.ts
Normal 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
119
Logic/ImageSearcher.ts
Normal 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
134
Logic/LayerUpdater.ts
Normal 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
185
Logic/OsmConnection.ts
Normal 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
172
Logic/OsmObject.ts
Normal 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
58
Logic/Overpass.ts
Normal 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
508
Logic/Question.ts
Normal 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
175
Logic/TagsFilter.ts
Normal 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
131
Logic/Wikimedia.ts
Normal 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 = "";
|
||||
|
||||
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue