Add better relation support

This commit is contained in:
pietervdvn 2021-04-18 14:24:30 +02:00
parent 7b47af8978
commit 12afdcab75
18 changed files with 2637 additions and 2386 deletions

View file

@ -130,8 +130,11 @@ export interface LayerConfigJson {
*/ */
rotation?: string | TagRenderingConfigJson; rotation?: string | TagRenderingConfigJson;
/** /**
* A HTML-fragment that is shown at the center of the icon, for example: * A HTML-fragment that is shown below the icon, for example:
* <div style="background: white; display: block">{name}</div> * <div style="background: white; display: block">{name}</div>
*
* If the icon is undefined, then the label is shown in the center of the feature.
* Note that, if the wayhandling hides the icon then no label is shown as well.
*/ */
label?: string | TagRenderingConfigJson ; label?: string | TagRenderingConfigJson ;

View file

@ -77,7 +77,8 @@ export default class TagRenderingConfig {
throw `Freeform.key is undefined or the empty string - this is not allowed; either fill out something or remove the freeform block alltogether. Error in ${context}` throw `Freeform.key is undefined or the empty string - this is not allowed; either fill out something or remove the freeform block alltogether. Error in ${context}`
} }
if (ValidatedTextField.AllTypes[this.freeform.type] === undefined) { if (ValidatedTextField.AllTypes[this.freeform.type] === undefined) {
throw `Freeform.key ${this.freeform.key} is an invalid type` const knownKeys = ValidatedTextField.tpList.map(tp => tp.name).join(", ");
throw `Freeform.key ${this.freeform.key} is an invalid type. Known keys are ${knownKeys}`
} }
if (this.freeform.addExtraTags) { if (this.freeform.addExtraTags) {
const usedKeys = new And(this.freeform.addExtraTags).usedKeys(); const usedKeys = new And(this.freeform.addExtraTags).usedKeys();
@ -204,7 +205,7 @@ export default class TagRenderingConfig {
return true; return true;
} }
if (this.multiAnswer) { if (this.multiAnswer) {
for (const m of this.mappings) { for (const m of this.mappings ?? []) {
if (TagUtils.MatchesMultiAnswer(m.if, tags)) { if (TagUtils.MatchesMultiAnswer(m.if, tags)) {
return true; return true;
} }

View file

@ -53,6 +53,7 @@ The above code will be executed for every feature in the layer. The feature is a
* distanceTo * distanceTo
* overlapWith * overlapWith
* closest * closest
* memberships
### distanceTo ### distanceTo
@ -72,3 +73,7 @@ Gives a list of features from the specified layer which this feature overlaps wi
Given either a list of geojson features or a single layer name, gives the single object which is nearest to the feature. In the case of ways/polygons, only the centerpoint is considered. Given either a list of geojson features or a single layer name, gives the single object which is nearest to the feature. In the case of ways/polygons, only the centerpoint is considered.
* list of features * list of features
### memberships
Gives a list of {role: string, relation: Relation}-objects, containing all the relations that this feature is part of. For example: \`\_part\_of\_walking\_routes=feat.memberships().map(r => r.relation.tags.name).join(';')\`

View file

@ -1,6 +1,7 @@
import {GeoOperations} from "./GeoOperations"; import {GeoOperations} from "./GeoOperations";
import {UIElement} from "../UI/UIElement"; import {UIElement} from "../UI/UIElement";
import Combine from "../UI/Base/Combine"; import Combine from "../UI/Base/Combine";
import State from "../State";
export class ExtraFunction { export class ExtraFunction {
@ -35,7 +36,7 @@ The above code will be executed for every feature in the layer. The feature is a
Some advanced functions are available on <b>feat</b> as well: Some advanced functions are available on <b>feat</b> as well:
` `
private static OverlapFunc = new ExtraFunction( private static readonly OverlapFunc = new ExtraFunction(
"overlapWith", "overlapWith",
"Gives a list of features from the specified layer which this feature overlaps with, the amount of overlap in m². The returned value is <b>{ feat: GeoJSONFeature, overlap: number}</b>", "Gives a list of features from the specified layer which this feature overlaps with, the amount of overlap in m². The returned value is <b>{ feat: GeoJSONFeature, overlap: number}</b>",
["...layerIds - one or more layer ids of the layer from which every feature is checked for overlap)"], ["...layerIds - one or more layer ids of the layer from which every feature is checked for overlap)"],
@ -56,26 +57,26 @@ Some advanced functions are available on <b>feat</b> as well:
} }
} }
) )
private static DistanceToFunc = new ExtraFunction( private static readonly DistanceToFunc = new ExtraFunction(
"distanceTo", "distanceTo",
"Calculates the distance between the feature and a specified point", "Calculates the distance between the feature and a specified point",
["longitude", "latitude"], ["longitude", "latitude"],
(featuresPerLayer, feature) => { (featuresPerLayer, feature) => {
return (arg0, lat) => { return (arg0, lat) => {
if(typeof arg0 === "number"){ if (typeof arg0 === "number") {
const lon = arg0 const lon = arg0
// Feature._lon and ._lat is conveniently place by one of the other metatags // Feature._lon and ._lat is conveniently place by one of the other metatags
return GeoOperations.distanceBetween([lon, lat], [feature._lon, feature._lat]); return GeoOperations.distanceBetween([lon, lat], [feature._lon, feature._lat]);
}else{ } else {
// arg0 is probably a feature // arg0 is probably a feature
return GeoOperations.distanceBetween(GeoOperations.centerpointCoordinates(arg0),[feature._lon, feature._lat]) return GeoOperations.distanceBetween(GeoOperations.centerpointCoordinates(arg0), [feature._lon, feature._lat])
} }
} }
} }
) )
private static ClosestObjectFunc = new ExtraFunction( private static readonly ClosestObjectFunc = new ExtraFunction(
"closest", "closest",
"Given either a list of geojson features or a single layer name, gives the single object which is nearest to the feature. In the case of ways/polygons, only the centerpoint is considered.", "Given either a list of geojson features or a single layer name, gives the single object which is nearest to the feature. In the case of ways/polygons, only the centerpoint is considered.",
["list of features"], ["list of features"],
@ -87,7 +88,7 @@ Some advanced functions are available on <b>feat</b> as well:
let closestFeature = undefined; let closestFeature = undefined;
let closestDistance = undefined; let closestDistance = undefined;
for (const otherFeature of features) { for (const otherFeature of features) {
if(otherFeature == feature){ if (otherFeature == feature) {
continue; // We ignore self continue; // We ignore self
} }
let distance = undefined; let distance = undefined;
@ -99,10 +100,10 @@ Some advanced functions are available on <b>feat</b> as well:
[feature._lon, feature._lat] [feature._lon, feature._lat]
) )
} }
if(distance === undefined){ if (distance === undefined) {
throw "Undefined distance!" throw "Undefined distance!"
} }
if(closestFeature === undefined || distance < closestDistance){ if (closestFeature === undefined || distance < closestDistance) {
closestFeature = otherFeature closestFeature = otherFeature
closestDistance = distance; closestDistance = distance;
} }
@ -113,7 +114,19 @@ Some advanced functions are available on <b>feat</b> as well:
) )
private static readonly allFuncs: ExtraFunction[] = [ExtraFunction.DistanceToFunc, ExtraFunction.OverlapFunc, ExtraFunction.ClosestObjectFunc]; private static readonly Memberships = new ExtraFunction(
"memberships",
"Gives a list of {role: string, relation: Relation}-objects, containing all the relations that this feature is part of. \n\nFor example: `_part_of_walking_routes=feat.memberships().map(r => r.relation.tags.name).join(';')`",
[],
(featuresPerLayer, feature) => {
return () => {
return State.state.knownRelations.data?.get(feature.id) ?? [];
}
}
)
private static readonly allFuncs: ExtraFunction[] = [ExtraFunction.DistanceToFunc, ExtraFunction.OverlapFunc, ExtraFunction.ClosestObjectFunc, ExtraFunction.Memberships];
private readonly _name: string; private readonly _name: string;
private readonly _args: string[]; private readonly _args: string[];
private readonly _doc: string; private readonly _doc: string;

View file

@ -12,6 +12,7 @@ import LayoutConfig from "../../Customizations/JSON/LayoutConfig";
import Loc from "../../Models/Loc"; import Loc from "../../Models/Loc";
import GeoJsonSource from "./GeoJsonSource"; import GeoJsonSource from "./GeoJsonSource";
import MetaTaggingFeatureSource from "./MetaTaggingFeatureSource"; import MetaTaggingFeatureSource from "./MetaTaggingFeatureSource";
import RegisteringFeatureSource from "./RegisteringFeatureSource";
export default class FeaturePipeline implements FeatureSource { export default class FeaturePipeline implements FeatureSource {
@ -24,33 +25,38 @@ export default class FeaturePipeline implements FeatureSource {
locationControl: UIEventSource<Loc>) { locationControl: UIEventSource<Loc>) {
const amendedOverpassSource = const amendedOverpassSource =
new RememberingSource(new FeatureDuplicatorPerLayer(flayers, new RememberingSource(
new LocalStorageSaver(updater, layout)) new LocalStorageSaver(
); new MetaTaggingFeatureSource( // first we metatag, then we save to get the metatags into storage too
new RegisteringFeatureSource(
new FeatureDuplicatorPerLayer(flayers,
updater)
)), layout));
const geojsonSources: GeoJsonSource [] = [] const geojsonSources: GeoJsonSource [] = []
for (const flayer of flayers.data) { for (const flayer of flayers.data) {
const sourceUrl = flayer.layerDef.source.geojsonSource const sourceUrl = flayer.layerDef.source.geojsonSource
if (sourceUrl !== undefined) { if (sourceUrl !== undefined) {
geojsonSources.push( geojsonSources.push(new RegisteringFeatureSource(new FeatureDuplicatorPerLayer(flayers,
new GeoJsonSource(flayer.layerDef.id, sourceUrl)) new GeoJsonSource(flayer.layerDef.id, sourceUrl))))
} }
} }
const amendedLocalStorageSource = const amendedLocalStorageSource =
new RememberingSource(new FeatureDuplicatorPerLayer(flayers, new LocalStorageSource(layout)) new RememberingSource(new RegisteringFeatureSource(new FeatureDuplicatorPerLayer(flayers, new LocalStorageSource(layout))
); ));
newPoints = new FeatureDuplicatorPerLayer(flayers, newPoints); newPoints = new MetaTaggingFeatureSource(new FeatureDuplicatorPerLayer(flayers,
new RegisteringFeatureSource(newPoints)));
const merged = const merged =
new MetaTaggingFeatureSource(
new FeatureSourceMerger([ new FeatureSourceMerger([
amendedOverpassSource, amendedOverpassSource,
amendedLocalStorageSource, amendedLocalStorageSource,
newPoints, newPoints,
...geojsonSources ...geojsonSources
])); ]);
const source = const source =
new WayHandlingApplyingFeatureSource(flayers, new WayHandlingApplyingFeatureSource(flayers,

View file

@ -16,10 +16,6 @@ export default class MetaTaggingFeatureSource implements FeatureSource {
featuresFreshness.forEach(featureFresh => { featuresFreshness.forEach(featureFresh => {
const feature = featureFresh.feature; const feature = featureFresh.feature;
if(!State.state.allElements.has(feature.properties.id)){
State.state.allElements.addOrGetElement(feature)
}
if (Hash.hash.data === feature.properties.id) { if (Hash.hash.data === feature.properties.id) {
State.state.selectedElement.setData(feature); State.state.selectedElement.setData(feature);
} }

View file

@ -0,0 +1,19 @@
import FeatureSource from "./FeatureSource";
import {UIEventSource} from "../UIEventSource";
import State from "../../State";
export default class RegisteringFeatureSource implements FeatureSource {
features: UIEventSource<{ feature: any; freshness: Date }[]>;
constructor(source: FeatureSource) {
this.features = source.features;
this.features.addCallbackAndRun(features => {
for (const feature of features ?? []) {
if (!State.state.allElements.has(feature.feature.properties.id)) {
State.state.allElements.addOrGetElement(feature.feature)
}
}
})
}
}

View file

@ -1,4 +1,4 @@
import * as turf from 'turf' import * as turf from '@turf/turf'
export class GeoOperations { export class GeoOperations {
@ -118,6 +118,9 @@ export class GeoOperations {
return inside; return inside;
}; };
static lengthInMeters(feature: any) {
return turf.length(feature) * 1000
}
} }

View file

@ -0,0 +1,58 @@
import State from "../../State";
export interface Relation {
id: number,
type: "relation"
members: {
type: ("way" | "node" | "relation"),
ref: number,
role: string
}[],
tags: any,
// Alias for tags; tags == properties
properties: any
}
export default class ExtractRelations {
public static RegisterRelations(overpassJson: any) : void{
const memberships = ExtractRelations.BuildMembershipTable(ExtractRelations.GetRelationElements(overpassJson))
console.log("Assigned memberships: ", memberships)
State.state.knownRelations.setData(memberships)
}
private static GetRelationElements(overpassJson: any): Relation[] {
const relations = overpassJson.elements.filter(element => element.type === "relation")
for (const relation of relations) {
relation.properties = relation.tags
}
return relations
}
/**
* Build a mapping of {memberId --> {role in relation, id of relation} }
* @param relations
* @constructor
*/
private static BuildMembershipTable(relations: Relation[]): Map<string, { role: string, relation: Relation, }[]> {
const memberships = new Map<string, { role: string, relation: Relation }[]>()
for (const relation of relations) {
for (const member of relation.members) {
const role = {
role: member.role,
relation: relation
}
const key = member.type + "/" + member.ref
if (!memberships.has(key)) {
memberships.set(key, [])
}
memberships.get(key).push(role)
}
}
return memberships
}
}

View file

@ -1,7 +1,8 @@
import * as $ from "jquery" import * as $ from "jquery"
import * as OsmToGeoJson from "osmtogeojson"; import * as OsmToGeoJson from "osmtogeojson";
import Bounds from "../../Models/Bounds"; import Bounds from "../../Models/Bounds";
import {TagsFilter} from "../TagsFilter"; import {TagsFilter} from "../Tags/TagsFilter";
import ExtractRelations from "./ExtractRelations";
/** /**
* Interfaces overpass to get all the latest data * Interfaces overpass to get all the latest data
@ -38,9 +39,9 @@ export class Overpass {
return; return;
} }
ExtractRelations.RegisterRelations(json)
// @ts-ignore // @ts-ignore
const geojson = OsmToGeoJson.default(json); const geojson = OsmToGeoJson.default(json);
console.log("Received geojson", geojson)
const osmTime = new Date(json.osm3s.timestamp_osm_base); const osmTime = new Date(json.osm3s.timestamp_osm_base);
continuation(geojson, osmTime); continuation(geojson, osmTime);

View file

@ -52,6 +52,18 @@ export default class SimpleMetaTagger {
feature.area = sqMeters; feature.area = sqMeters;
}) })
); );
private static lngth = new SimpleMetaTagger(
["_length", "_length:km"], "The total length of a feature in meters (and in kilometers, rounded to one decimal for '_length:km'). For a surface, the length of the perimeter",
(feature => {
const l = GeoOperations.lengthInMeters(feature)
feature.properties["_length"] = "" + l
const km = Math.floor(l / 1000)
const kmRest = Math.round((l - km * 1000) / 100)
feature.properties["_length:km"] = "" + km+ "." + kmRest
})
)
private static country = new SimpleMetaTagger( private static country = new SimpleMetaTagger(
["_country"], "The country code of the property (with latlon2country)", ["_country"], "The country code of the property (with latlon2country)",
feature => { feature => {
@ -294,6 +306,7 @@ export default class SimpleMetaTagger {
public static metatags = [ public static metatags = [
SimpleMetaTagger.latlon, SimpleMetaTagger.latlon,
SimpleMetaTagger.surfaceArea, SimpleMetaTagger.surfaceArea,
SimpleMetaTagger.lngth,
SimpleMetaTagger.country, SimpleMetaTagger.country,
SimpleMetaTagger.isOpen, SimpleMetaTagger.isOpen,
SimpleMetaTagger.carriageWayWidth, SimpleMetaTagger.carriageWayWidth,

View file

@ -2,7 +2,7 @@ import { Utils } from "../Utils";
export default class Constants { export default class Constants {
public static vNumber = "0.6.8c"; public static vNumber = "0.6.9";
// The user journey states thresholds when a new feature gets unlocked // The user journey states thresholds when a new feature gets unlocked
public static userJourney = { public static userJourney = {

View file

@ -17,6 +17,7 @@ import UpdateFromOverpass from "./Logic/Actors/UpdateFromOverpass";
import LayerConfig from "./Customizations/JSON/LayerConfig"; import LayerConfig from "./Customizations/JSON/LayerConfig";
import TitleHandler from "./Logic/Actors/TitleHandler"; import TitleHandler from "./Logic/Actors/TitleHandler";
import PendingChangesUploader from "./Logic/Actors/PendingChangesUploader"; import PendingChangesUploader from "./Logic/Actors/PendingChangesUploader";
import {Relation} from "./Logic/Osm/ExtractRelations";
/** /**
* Contains the global state: a bunch of UI-event sources * Contains the global state: a bunch of UI-event sources
@ -76,6 +77,11 @@ export default class State {
*/ */
public readonly selectedElement = new UIEventSource<any>(undefined, "Selected element") public readonly selectedElement = new UIEventSource<any>(undefined, "Selected element")
/**
* Keeps track of relations: which way is part of which other way?
* Set by the overpass-updater; used in the metatagging
*/
public readonly knownRelations = new UIEventSource<Map<string, {role: string, relation: Relation}[]>>(undefined, "Relation memberships")
public readonly featureSwitchUserbadge: UIEventSource<boolean>; public readonly featureSwitchUserbadge: UIEventSource<boolean>;
public readonly featureSwitchSearch: UIEventSource<boolean>; public readonly featureSwitchSearch: UIEventSource<boolean>;

View file

@ -46,7 +46,7 @@ export default class TagRenderingAnswer extends UIElement {
if (this._configuration.multiAnswer) { if (this._configuration.multiAnswer) {
let freeformKeyUsed = this._configuration.freeform?.key === undefined; // If it is undefined, it is "used" already, or at least we don't have to check for it anymore let freeformKeyUsed = this._configuration.freeform?.key === undefined; // If it is undefined, it is "used" already, or at least we don't have to check for it anymore
const applicableThens: Translation[] = Utils.NoNull(this._configuration.mappings.map(mapping => { const applicableThens: Translation[] = Utils.NoNull(this._configuration.mappings?.map(mapping => {
if (mapping.if === undefined) { if (mapping.if === undefined) {
return mapping.then; return mapping.then;
} }
@ -59,7 +59,7 @@ export default class TagRenderingAnswer extends UIElement {
return mapping.then; return mapping.then;
} }
return undefined; return undefined;
})) }) ?? [])
if (!freeformKeyUsed if (!freeformKeyUsed
&& tags[this._configuration.freeform.key] !== undefined) { && tags[this._configuration.freeform.key] !== undefined) {

View file

@ -27,10 +27,126 @@
"play_forest", "play_forest",
"playground", "playground",
"sport_pitch", "sport_pitch",
"slow_roads", { "builtin": "slow_roads",
"override": {
"calculatedTags": [
"_part_of_walking_routes=feat.memberships().map(r => \"<a href='#relation/\"+r.relation.id+\"'>\" + r.relation.tags.name + \"</a>\").join(', ')"
]
}
},
"grass_in_parks", "grass_in_parks",
"village_green" "village_green",
{
"id": "walking_routes",
"name": {
"nl": "Wandelroutes van provincie Antwerpen"
},
"description": "Walking routes by 'provincie Antwerpen'",
"source": {
"osmTags": {
"and": [
"type=route",
"route=foot",
"operator=provincie Antwerpen"
]
}
},
"title": {
"render": "Wandeling <i>{name}</i>",
"mappings": [
{
"if": "name~.*wandeling.*",
"then": "{name}"
}
]
},
"tagRenderings": [
{
"render": {
"nl": "Deze wandeling is <b>{_length:km}km</b> lang"
}
},
{
"mappings": [
{
"if": "route=iwn",
"then": {
"nl": "Dit is een internationale wandelroute"
}
},
{
"if": "route=nwn",
"then": {
"nl": "Dit is een nationale wandelroute"
}
},
{
"if": "route=rwn",
"then": {
"nl": "Dit is een regionale wandelroute"
}
},
{
"if": "route=lwn",
"then": {
"nl": "Dit is een lokale wandelroute"
}
}
]
},
{
"render": {
"nl": "<h3>Korte beschrijving:</h3>{description}"
},
"question": "Geef een korte beschrijving van de wandeling (max 255 tekens)",
"freeform": {
"key": "description",
"type": "text"
}
},
{
"question": {
"nl": "Wie beheert deze wandeling en plaatst dus de signalisatiebordjes?"
},
"render": "Signalisatie geplaatst door {operator}",
"freeform":{
"key": "operator"
}
},
{
"question": {
"nl": "Naar wie kan men emailen bij problemen rond signalisatie?"
},
"render": {
"nl": "Bij problemen met signalisatie kan men emailen naar <a href='mailto:{operator:email}'>{operator:email}</a>"
},
"freeform": {
"key": "operator:email",
"type": "email"
}
},
"questions",
"reviews"
],
"color": {
"render": "#6d6",
"mappings":[
{
"if": "color~*",
"then": "{color}"
}
]
},
"width": {
"render": "3"
}
}
], ],
"roamingRenderings": [] "roamingRenderings": [
{
"render": "Maakt deel uit van {_part_of_walking_routes}",
"condition": "_part_of_walking_routes~*"
}
]
} }

5101
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -38,7 +38,11 @@
"dependencies": { "dependencies": {
"@babel/preset-env": "7.13.8", "@babel/preset-env": "7.13.8",
"@tailwindcss/postcss7-compat": "^2.0.2", "@tailwindcss/postcss7-compat": "^2.0.2",
"@turf/buffer": "^6.3.0",
"@turf/collect": "^6.3.0",
"@turf/distance": "^6.3.0", "@turf/distance": "^6.3.0",
"@turf/length": "^6.3.0",
"@turf/turf": "^6.3.0",
"@types/jquery": "^3.5.5", "@types/jquery": "^3.5.5",
"@types/leaflet-markercluster": "^1.0.3", "@types/leaflet-markercluster": "^1.0.3",
"@types/leaflet-providers": "^1.2.0", "@types/leaflet-providers": "^1.2.0",

View file

@ -42,7 +42,7 @@ writeFileSync("./assets/generated/known_layers_and_themes.json", JSON.stringify(
})) }))
console.log("Discovered ", layerFiles.length, "layers and ", themeFiles.length, "themes\n") console.log("Discovered", layerFiles.length, "layers and", themeFiles.length, "themes\n")
console.log(" ---------- VALIDATING ---------") console.log(" ---------- VALIDATING ---------")
// ------------- VALIDATION -------------- // ------------- VALIDATION --------------
const licensePaths = [] const licensePaths = []