mapcomplete/Logic/SimpleMetaTagger.ts

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

661 lines
25 KiB
TypeScript
Raw Normal View History

import { GeoOperations } from "./GeoOperations"
import { Utils } from "../Utils"
import opening_hours from "opening_hours"
import Combine from "../UI/Base/Combine"
import BaseUIElement from "../UI/BaseUIElement"
import Title from "../UI/Base/Title"
import { FixedUiElement } from "../UI/Base/FixedUiElement"
import LayerConfig from "../Models/ThemeConfig/LayerConfig"
import { CountryCoder } from "latlon2country"
import Constants from "../Models/Constants"
import { TagUtils } from "./Tags/TagUtils"
2022-10-27 01:50:01 +02:00
import { Feature, LineString } from "geojson"
import { OsmObject } from "./Osm/OsmObject"
2022-01-26 20:47:08 +01:00
export class SimpleMetaTagger {
public readonly keys: string[]
public readonly doc: string
public readonly isLazy: boolean
public readonly includesDates: boolean
2021-12-12 17:35:08 +01:00
public readonly applyMetaTagsOnFeature: (
feature: any,
freshness: Date,
layer: LayerConfig,
state
) => boolean
/***
* A function that adds some extra data to a feature
* @param docs: what does this extra data do?
* @param f: apply the changes. Returns true if something changed
*/
constructor(
docs: {
keys: string[]
doc: string
2023-02-06 22:23:56 +01:00
/**
* Set this flag if the data is volatile or date-based.
* It'll _won't_ be cached in this case
*/
includesDates?: boolean
isLazy?: boolean
cleanupRetagger?: boolean
},
2021-12-12 17:35:08 +01:00
f: (feature: any, freshness: Date, layer: LayerConfig, state) => boolean
) {
this.keys = docs.keys
this.doc = docs.doc
this.isLazy = docs.isLazy
this.applyMetaTagsOnFeature = f
this.includesDates = docs.includesDates ?? false
if (!docs.cleanupRetagger) {
for (const key of docs.keys) {
if (!key.startsWith("_") && key.toLowerCase().indexOf("theme") < 0) {
throw `Incorrect key for a calculated meta value '${key}': it should start with underscore (_)`
}
}
}
}
}
2023-02-09 00:10:59 +01:00
export class ReferencingWaysMetaTagger extends SimpleMetaTagger {
/**
* Disable this metatagger, e.g. for caching or tests
* This is a bit a work-around
*/
public static enabled = true
constructor() {
super(
{
keys: ["_referencing_ways"],
isLazy: true,
doc: "_referencing_ways contains - for a node - which ways use this this node as point in their geometry. If the preset has 'snapToLayer' defined, the icon will be calculated based on the preset tags with `_referencing_ways=[\"way/-1\"]` added.",
2023-02-09 00:10:59 +01:00
},
(feature, _, __, state) => {
if (!ReferencingWaysMetaTagger.enabled) {
return false
}
//this function has some extra code to make it work in SimpleAddUI.ts to also work for newly added points
const id = feature.properties.id
if (!id.startsWith("node/")) {
return false
}
console.trace("Downloading referencing ways for", feature.properties.id)
OsmObject.DownloadReferencingWays(id).then((referencingWays) => {
const currentTagsSource = state.allElements?.getEventSourceById(id) ?? []
const wayIds = referencingWays.map((w) => "way/" + w.id)
wayIds.sort()
const wayIdsStr = wayIds.join(";")
if (
wayIdsStr !== "" &&
currentTagsSource.data["_referencing_ways"] !== wayIdsStr
) {
currentTagsSource.data["_referencing_ways"] = wayIdsStr
currentTagsSource.ping()
}
})
return true
}
)
}
}
export class CountryTagger extends SimpleMetaTagger {
private static readonly coder = new CountryCoder(
Constants.countryCoderEndpoint,
Utils.downloadJson
2022-09-08 21:40:48 +02:00
)
public runningTasks: Set<any>
2022-01-26 20:47:08 +01:00
constructor() {
2022-01-26 20:47:08 +01:00
const runningTasks = new Set<any>()
super(
{
keys: ["_country"],
doc: "The country code of the property (with latlon2country)",
includesDates: false,
},
2021-12-12 17:35:08 +01:00
(feature, _, __, state) => {
let centerPoint: any = GeoOperations.centerpoint(feature)
const lat = centerPoint.geometry.coordinates[1]
const lon = centerPoint.geometry.coordinates[0]
runningTasks.add(feature)
CountryTagger.coder
.GetCountryCodeAsync(lon, lat)
.then((countries) => {
runningTasks.delete(feature)
try {
const oldCountry = feature.properties["_country"]
feature.properties["_country"] = countries[0].trim().toLowerCase()
if (oldCountry !== feature.properties["_country"]) {
2021-12-12 17:35:08 +01:00
const tagsSource = state?.allElements?.getEventSourceById(
feature.properties.id
2022-09-08 21:40:48 +02:00
)
tagsSource?.ping()
}
} catch (e) {
console.warn(e)
}
})
.catch((_) => {
runningTasks.delete(feature)
})
return false
}
)
2022-01-26 20:47:08 +01:00
this.runningTasks = runningTasks
}
}
export default class SimpleMetaTaggers {
public static readonly objectMetaInfo = new SimpleMetaTagger(
{
keys: [
"_last_edit:contributor",
"_last_edit:contributor:uid",
"_last_edit:changeset",
"_last_edit:timestamp",
"_version_number",
"_backend",
],
doc: "Information about the last edit of this object.",
},
(feature) => {
/*Note: also called by 'UpdateTagsFromOsmAPI'*/
const tgs = feature.properties
2023-02-06 22:23:56 +01:00
let movedSomething = false
function move(src: string, target: string) {
if (tgs[src] === undefined) {
return
}
tgs[target] = tgs[src]
delete tgs[src]
movedSomething = true
}
move("user", "_last_edit:contributor")
move("uid", "_last_edit:contributor:uid")
move("changeset", "_last_edit:changeset")
move("timestamp", "_last_edit:timestamp")
move("version", "_version_number")
return movedSomething
}
)
2022-01-26 20:47:08 +01:00
public static country = new CountryTagger()
public static geometryType = new SimpleMetaTagger(
{
keys: ["_geometry:type"],
doc: "Adds the geometry type as property. This is identical to the GoeJson geometry type and is one of `Point`,`LineString`, `Polygon` and exceptionally `MultiPolygon` or `MultiLineString`",
},
(feature, _) => {
const changed = feature.properties["_geometry:type"] === feature.geometry.type
feature.properties["_geometry:type"] = feature.geometry.type
return changed
}
)
private static readonly cardinalDirections = {
N: 0,
NNE: 22.5,
NE: 45,
ENE: 67.5,
E: 90,
ESE: 112.5,
SE: 135,
SSE: 157.5,
S: 180,
SSW: 202.5,
SW: 225,
WSW: 247.5,
W: 270,
WNW: 292.5,
NW: 315,
NNW: 337.5,
}
private static latlon = new SimpleMetaTagger(
{
keys: ["_lat", "_lon"],
doc: "The latitude and longitude of the point (or centerpoint in the case of a way/area)",
},
(feature) => {
const centerPoint = GeoOperations.centerpoint(feature)
const lat = centerPoint.geometry.coordinates[1]
const lon = centerPoint.geometry.coordinates[0]
feature.properties["_lat"] = "" + lat
feature.properties["_lon"] = "" + lon
feature._lon = lon // This is dirty, I know
feature._lat = lat
return true
}
)
private static layerInfo = new SimpleMetaTagger(
{
doc: "The layer-id to which this feature belongs. Note that this might be return any applicable if `passAllFeatures` is defined.",
keys: ["_layer"],
includesDates: false,
},
(feature, freshness, layer) => {
if (feature.properties._layer === layer.id) {
return false
}
feature.properties._layer = layer.id
return true
}
)
private static noBothButLeftRight = new SimpleMetaTagger(
{
keys: [
"sidewalk:left",
"sidewalk:right",
"generic_key:left:property",
"generic_key:right:property",
],
doc: "Rewrites tags from 'generic_key:both:property' as 'generic_key:left:property' and 'generic_key:right:property' (and similar for sidewalk tagging). Note that this rewritten tags _will be reuploaded on a change_. To prevent to much unrelated retagging, this is only enabled if the layer has at least some lineRenderings with offset defined",
includesDates: false,
cleanupRetagger: true,
},
(feature, state, layer) => {
2021-11-07 16:34:51 +01:00
if (!layer.lineRendering.some((lr) => lr.leftRightSensitive)) {
return
}
2021-11-07 16:34:51 +01:00
return SimpleMetaTaggers.removeBothTagging(feature.properties)
}
)
private static surfaceArea = new SimpleMetaTagger(
{
keys: ["_surface", "_surface:ha"],
doc: "The surface area of the feature, in square meters and in hectare. Not set on points and ways",
isLazy: true,
},
(feature) => {
Object.defineProperty(feature.properties, "_surface", {
enumerable: false,
configurable: true,
get: () => {
const sqMeters = "" + GeoOperations.surfaceAreaInSqMeters(feature)
delete feature.properties["_surface"]
feature.properties["_surface"] = sqMeters
return sqMeters
},
})
Object.defineProperty(feature.properties, "_surface:ha", {
enumerable: false,
configurable: true,
get: () => {
const sqMeters = GeoOperations.surfaceAreaInSqMeters(feature)
const sqMetersHa = "" + Math.floor(sqMeters / 1000) / 10
delete feature.properties["_surface:ha"]
feature.properties["_surface:ha"] = sqMetersHa
return sqMetersHa
},
})
return true
}
)
private static levels = new SimpleMetaTagger(
{
doc: "Extract the 'level'-tag into a normalized, ';'-separated value",
keys: ["_level"],
},
(feature) => {
if (feature.properties["level"] === undefined) {
return false
}
2022-09-08 21:40:48 +02:00
const l = feature.properties["level"]
const newValue = TagUtils.LevelsParser(l).join(";")
if (l === newValue) {
return false
}
feature.properties["level"] = newValue
return true
}
)
private static canonicalize = new SimpleMetaTagger(
{
doc: "If 'units' is defined in the layoutConfig, then this metatagger will rewrite the specified keys to have the canonical form (e.g. `1meter` will be rewritten to `1m`; `1` will be rewritten to `1m` as well)",
2021-06-24 14:03:02 +02:00
keys: ["Theme-defined keys"],
},
2021-12-12 17:35:08 +01:00
(feature, _, __, state) => {
const units = Utils.NoNull(
[].concat(...(state?.layoutToUse?.layers?.map((layer) => layer.units) ?? []))
2022-09-08 21:40:48 +02:00
)
if (units.length == 0) {
return
}
let rewritten = false
for (const key in feature.properties) {
if (!feature.properties.hasOwnProperty(key)) {
continue
}
for (const unit of units) {
if (unit === undefined) {
continue
}
if (unit.appliesToKeys === undefined) {
console.error("The unit ", unit, "has no appliesToKey defined")
continue
}
if (!unit.appliesToKeys.has(key)) {
continue
}
const value = feature.properties[key]
const denom = unit.findDenomination(value, () => feature.properties["_country"])
if (denom === undefined) {
// no valid value found
break
}
const [, denomination] = denom
const defaultDenom = unit.getDefaultDenomination(
() => feature.properties["_country"]
)
let canonical =
denomination?.canonicalValue(value, defaultDenom == denomination) ??
undefined
if (canonical === value) {
break
}
console.log("Rewritten ", key, ` from '${value}' into '${canonical}'`)
2021-07-24 01:59:57 +02:00
if (canonical === undefined && !unit.eraseInvalid) {
2021-06-22 12:13:44 +02:00
break
}
2021-07-24 01:59:57 +02:00
feature.properties[key] = canonical
rewritten = true
2021-06-22 12:13:44 +02:00
break
}
}
return rewritten
}
)
2021-04-18 14:24:30 +02:00
private static lngth = new SimpleMetaTagger(
{
keys: ["_length", "_length:km"],
doc: "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",
},
2021-04-18 14:24:30 +02:00
(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
return true
2021-04-18 14:24:30 +02:00
}
)
private static isOpen = new SimpleMetaTagger(
{
2022-01-06 18:51:52 +01:00
keys: ["_isOpen"],
doc: "If 'opening_hours' is present, it will add the current state of the feature (being 'yes' or 'no')",
includesDates: true,
isLazy: true,
},
2022-01-26 20:47:08 +01:00
(feature, _, __, state) => {
if (Utils.runningFromConsole) {
// We are running from console, thus probably creating a cache
// isOpen is irrelevant
return false
}
2022-01-26 21:40:38 +01:00
if (feature.properties.opening_hours === "24/7") {
2022-01-26 20:47:08 +01:00
feature.properties._isOpen = "yes"
return true
}
2022-01-26 21:40:38 +01:00
2022-02-06 03:45:32 +01:00
// _isOpen is calculated dynamically on every call
Object.defineProperty(feature.properties, "_isOpen", {
enumerable: false,
configurable: true,
get: () => {
2022-02-06 03:45:32 +01:00
const tags = feature.properties
if (tags.opening_hours === undefined) {
return
}
if (tags._country === undefined) {
return
2022-01-26 20:47:08 +01:00
}
2022-02-06 03:45:32 +01:00
try {
const [lon, lat] = GeoOperations.centerpointCoordinates(feature)
const oh = new opening_hours(
tags["opening_hours"],
{
lat: lat,
lon: lon,
address: {
2022-07-07 22:35:28 +02:00
country_code: tags._country.toLowerCase(),
state: undefined,
},
},
<any>{ tag_key: "opening_hours" }
2022-09-08 21:40:48 +02:00
)
2022-02-06 03:45:32 +01:00
// Recalculate!
return oh.getState() ? "yes" : "no"
} catch (e) {
console.warn("Error while parsing opening hours of ", tags.id, e)
delete tags._isOpen
tags["_isOpen"] = "parse_error"
}
},
})
const tagsSource = state.allElements.getEventSourceById(feature.properties.id)
}
)
private static directionSimplified = new SimpleMetaTagger(
{
keys: ["_direction:numerical", "_direction:leftright"],
doc: "_direction:numerical is a normalized, numerical direction based on 'camera:direction' or on 'direction'; it is only present if a valid direction is found (e.g. 38.5 or NE). _direction:leftright is either 'left' or 'right', which is left-looking on the map or 'right-looking' on the map",
},
(feature) => {
const tags = feature.properties
const direction = tags["camera:direction"] ?? tags["direction"]
if (direction === undefined) {
return false
}
const n = SimpleMetaTaggers.cardinalDirections[direction] ?? Number(direction)
if (isNaN(n)) {
return false
}
// The % operator has range (-360, 360). We apply a trick to get [0, 360).
const normalized = ((n % 360) + 360) % 360
tags["_direction:numerical"] = normalized
tags["_direction:leftright"] = normalized <= 180 ? "right" : "left"
return true
}
)
private static directionCenterpoint = new SimpleMetaTagger(
{
2022-10-27 01:50:01 +02:00
keys: ["_direction:centerpoint"],
isLazy: true,
2022-10-27 01:50:01 +02:00
doc: "_direction:centerpoint is the direction of the linestring (in degrees) if one were standing at the projected centerpoint.",
},
(feature: Feature) => {
2022-10-27 01:50:01 +02:00
if (feature.geometry.type !== "LineString") {
return false
}
2022-10-27 01:50:01 +02:00
const ls = <Feature<LineString>>feature
Object.defineProperty(feature.properties, "_direction:centerpoint", {
enumerable: false,
configurable: true,
get: () => {
const centroid = GeoOperations.centerpoint(feature)
2022-10-27 01:50:01 +02:00
const projected = GeoOperations.nearestPoint(
ls,
<[number, number]>centroid.geometry.coordinates
)
const nextPoint = ls.geometry.coordinates[projected.properties.index + 1]
const bearing = GeoOperations.bearing(projected.geometry.coordinates, nextPoint)
delete feature.properties["_direction:centerpoint"]
feature.properties["_direction:centerpoint"] = bearing
return bearing
},
})
return true
}
)
private static currentTime = new SimpleMetaTagger(
{
keys: ["_now:date", "_now:datetime", "_loaded:date", "_loaded:_datetime"],
doc: "Adds the time that the data got loaded - pretty much the time of downloading from overpass. The format is YYYY-MM-DD hh:mm, aka 'sortable' aka ISO-8601-but-not-entirely",
includesDates: true,
},
(feature, freshness) => {
const now = new Date()
if (typeof freshness === "string") {
freshness = new Date(freshness)
}
function date(d: Date) {
return d.toISOString().slice(0, 10)
}
function datetime(d: Date) {
return d.toISOString().slice(0, -5).replace("T", " ")
}
feature.properties["_now:date"] = date(now)
feature.properties["_now:datetime"] = datetime(now)
feature.properties["_loaded:date"] = date(freshness)
feature.properties["_loaded:datetime"] = datetime(freshness)
return true
}
2021-12-30 22:01:23 +01:00
)
2023-02-09 00:10:59 +01:00
public static referencingWays = new ReferencingWaysMetaTagger()
public static metatags: SimpleMetaTagger[] = [
SimpleMetaTaggers.latlon,
SimpleMetaTaggers.layerInfo,
SimpleMetaTaggers.surfaceArea,
SimpleMetaTaggers.lngth,
SimpleMetaTaggers.canonicalize,
SimpleMetaTaggers.country,
SimpleMetaTaggers.isOpen,
SimpleMetaTaggers.directionSimplified,
SimpleMetaTaggers.directionCenterpoint,
SimpleMetaTaggers.currentTime,
SimpleMetaTaggers.objectMetaInfo,
2021-12-30 22:01:23 +01:00
SimpleMetaTaggers.noBothButLeftRight,
SimpleMetaTaggers.geometryType,
SimpleMetaTaggers.levels,
SimpleMetaTaggers.referencingWays,
]
2022-01-26 21:48:02 +01:00
public static readonly lazyTags: string[] = [].concat(
...SimpleMetaTaggers.metatags.filter((tagger) => tagger.isLazy).map((tagger) => tagger.keys)
2022-09-08 21:40:48 +02:00
)
2021-11-07 16:34:51 +01:00
/**
* Edits the given object to rewrite 'both'-tagging into a 'left-right' tagging scheme.
* These changes are performed in-place.
*
* Returns 'true' is at least one change has been made
* @param tags
*/
public static removeBothTagging(tags: any): boolean {
let somethingChanged = false
/**
* Sets the key onto the properties (but doesn't overwrite if already existing)
*/
function set(k, value) {
if (tags[k] === undefined || tags[k] === "") {
tags[k] = value
somethingChanged = true
}
}
if (tags["sidewalk"]) {
const v = tags["sidewalk"]
switch (v) {
case "none":
case "no":
set("sidewalk:left", "no")
set("sidewalk:right", "no")
break
case "both":
set("sidewalk:left", "yes")
set("sidewalk:right", "yes")
break
case "left":
set("sidewalk:left", "yes")
set("sidewalk:right", "no")
break
case "right":
set("sidewalk:left", "no")
set("sidewalk:right", "yes")
break
default:
set("sidewalk:left", v)
set("sidewalk:right", v)
break
}
delete tags["sidewalk"]
somethingChanged = true
}
const regex = /\([^:]*\):both:\(.*\)/
for (const key in tags) {
const v = tags[key]
if (key.endsWith(":both")) {
const strippedKey = key.substring(0, key.length - ":both".length)
set(strippedKey + ":left", v)
set(strippedKey + ":right", v)
delete tags[key]
continue
}
const match = key.match(regex)
if (match !== null) {
const strippedKey = match[1]
const property = match[1]
set(strippedKey + ":left:" + property, v)
set(strippedKey + ":right:" + property, v)
console.log("Left-right rewritten " + key)
delete tags[key]
}
}
return somethingChanged
}
public static HelpText(): BaseUIElement {
const subElements: (string | BaseUIElement)[] = [
new Combine([
"Metatags are extra tags available, in order to display more data or to give better questions.",
2022-06-07 19:48:09 +02:00
"They are calculated automatically on every feature when the data arrives in the webbrowser. This document gives an overview of the available metatags.",
"**Hint:** when using metatags, add the [query parameter](URL_Parameters.md) `debug=true` to the URL. This will include a box in the popup for features which shows all the properties of the object",
]).SetClass("flex-col"),
]
subElements.push(new Title("Metatags calculated by MapComplete", 2))
subElements.push(
new FixedUiElement(
"The following values are always calculated, by default, by MapComplete and are available automatically on all elements in every theme"
)
2022-09-08 21:40:48 +02:00
)
for (const metatag of SimpleMetaTaggers.metatags) {
subElements.push(
new Title(metatag.keys.join(", "), 3),
metatag.doc,
metatag.isLazy ? "This is a lazy metatag and is only calculated when needed" : ""
)
}
return new Combine(subElements).SetClass("flex-col")
}
}