Merge pull request #1731 from pietervdvn/feature/favourites

Feature/favourites
This commit is contained in:
Pieter Vander Vennet 2023-12-05 00:12:29 +01:00 committed by GitHub
commit 4197ec0055
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
80 changed files with 2715 additions and 1059 deletions

View file

@ -6,13 +6,21 @@ import { Changes } from "../Osm/Changes"
import { OsmConnection } from "../Osm/OsmConnection"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import SimpleMetaTagger from "../SimpleMetaTagger"
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
import { Feature } from "geojson"
import { OsmTags } from "../../Models/OsmFeature"
import OsmObjectDownloader from "../Osm/OsmObjectDownloader"
import { IndexedFeatureSource } from "../FeatureSource/FeatureSource"
import { Utils } from "../../Utils"
interface TagsUpdaterState {
selectedElement: UIEventSource<Feature>
featureProperties: { getStore: (id: string) => UIEventSource<Record<string, string>> }
changes: Changes
osmConnection: OsmConnection
layout: LayoutConfig
osmObjectDownloader: OsmObjectDownloader
indexedFeatures: IndexedFeatureSource
}
export default class SelectedElementTagsUpdater {
private static readonly metatags = new Set([
"timestamp",
@ -23,38 +31,18 @@ export default class SelectedElementTagsUpdater {
"id",
])
private readonly state: {
selectedElement: UIEventSource<Feature>
featureProperties: FeaturePropertiesStore
changes: Changes
osmConnection: OsmConnection
layout: LayoutConfig
osmObjectDownloader: OsmObjectDownloader
indexedFeatures: IndexedFeatureSource
}
constructor(state: {
selectedElement: UIEventSource<Feature>
featureProperties: FeaturePropertiesStore
indexedFeatures: IndexedFeatureSource
changes: Changes
osmConnection: OsmConnection
layout: LayoutConfig
osmObjectDownloader: OsmObjectDownloader
}) {
this.state = state
constructor(state: TagsUpdaterState) {
state.osmConnection.isLoggedIn.addCallbackAndRun((isLoggedIn) => {
if (!isLoggedIn && !Utils.runningFromConsole) {
return
}
this.installCallback()
this.installCallback(state)
// We only have to do this once...
return true
})
}
private installCallback() {
const state = this.state
private installCallback(state: TagsUpdaterState) {
state.selectedElement.addCallbackAndRunD(async (s) => {
let id = s.properties?.id
if (!id) {
@ -94,7 +82,7 @@ export default class SelectedElementTagsUpdater {
oldFeature.geometry = newGeometry
state.featureProperties.getStore(id)?.ping()
}
this.applyUpdate(latestTags, id)
SelectedElementTagsUpdater.applyUpdate(latestTags, id, state)
console.log("Updated", id)
} catch (e) {
@ -102,8 +90,7 @@ export default class SelectedElementTagsUpdater {
}
})
}
private applyUpdate(latestTags: OsmTags, id: string) {
const state = this.state
public static applyUpdate(latestTags: OsmTags, id: string, state: TagsUpdaterState) {
try {
const leftRightSensitive = state.layout.isLeftRightSensitive()
@ -162,11 +149,16 @@ export default class SelectedElementTagsUpdater {
}
if (somethingChanged) {
console.log("Detected upstream changes to the object when opening it, updating...")
console.log(
"Detected upstream changes to the object " +
id +
" when opening it, updating..."
)
currentTagsSource.ping()
} else {
console.debug("Fetched latest tags for ", id, "but detected no changes")
}
return currentTags
} catch (e) {
console.error("Updating the tags of selected element ", id, "failed due to", e)
}

View file

@ -0,0 +1,220 @@
import StaticFeatureSource from "./StaticFeatureSource"
import { Feature } from "geojson"
import { Store, Stores, UIEventSource } from "../../UIEventSource"
import { OsmConnection } from "../../Osm/OsmConnection"
import { OsmId } from "../../../Models/OsmFeature"
import { GeoOperations } from "../../GeoOperations"
import { IndexedFeatureSource } from "../FeatureSource"
import OsmObjectDownloader from "../../Osm/OsmObjectDownloader"
import { SpecialVisualizationState } from "../../../UI/SpecialVisualization"
import SelectedElementTagsUpdater from "../../Actors/SelectedElementTagsUpdater"
/**
* Generates the favourites from the preferences and marks them as favourite
*/
export default class FavouritesFeatureSource extends StaticFeatureSource {
public static readonly prefix = "mapcomplete-favourite-"
private readonly _osmConnection: OsmConnection
private readonly _detectedIds: Store<string[]>
/**
* All favourites, including the ones which are filtered away because they are already displayed
*/
public readonly allFavourites: Store<Feature[]>
constructor(state: SpecialVisualizationState) {
const features: Store<Feature[]> = Stores.ListStabilized(
state.osmConnection.preferencesHandler.preferences.map((prefs) => {
const feats: Feature[] = []
const allIds = new Set<string>()
for (const key in prefs) {
if (!key.startsWith(FavouritesFeatureSource.prefix)) {
continue
}
try {
const feat = FavouritesFeatureSource.ExtractFavourite(key, prefs)
if (!feat) {
continue
}
feats.push(feat)
allIds.add(feat.properties.id)
} catch (e) {
console.error("Could not create favourite from", key, "due to", e)
}
}
return feats
})
)
const featuresWithoutAlreadyPresent = features.map((features) =>
features.filter(
(feat) => !state.layout.layers.some((l) => l.id === feat.properties._orig_layer)
)
)
super(featuresWithoutAlreadyPresent)
this.allFavourites = features
this._osmConnection = state.osmConnection
this._detectedIds = Stores.ListStabilized(
features.map((feats) => feats.map((f) => f.properties.id))
)
let allFeatures = state.indexedFeatures
this._detectedIds.addCallbackAndRunD((detected) =>
this.markFeatures(detected, state.featureProperties, allFeatures)
)
// We use the indexedFeatureSource as signal to update
allFeatures.features.map((_) =>
this.markFeatures(this._detectedIds.data, state.featureProperties, allFeatures)
)
this.allFavourites.addCallbackD((features) => {
for (const feature of features) {
this.updateFeature(feature, state.osmObjectDownloader, state)
}
return true
})
}
private async updateFeature(
feature: Feature,
osmObjectDownloader: OsmObjectDownloader,
state: SpecialVisualizationState
) {
const id = feature.properties.id
const upstream = await osmObjectDownloader.DownloadObjectAsync(id)
if (upstream === "deleted") {
this.removeFavourite(feature)
return
}
console.log("Updating metadata due to favourite of", id)
const latestTags = SelectedElementTagsUpdater.applyUpdate(upstream.tags, id, state)
this.updatePropertiesOfFavourite(latestTags)
}
private static ExtractFavourite(key: string, prefs: Record<string, string>): Feature {
const id = key.substring(FavouritesFeatureSource.prefix.length)
const osmId = id.replace("-", "/")
if (id.indexOf("-property-") > 0 || id.endsWith("-layer") || id.endsWith("-theme")) {
return undefined
}
const geometry = <[number, number]>JSON.parse(prefs[key])
const properties = FavouritesFeatureSource.getPropertiesFor(prefs, id)
properties._orig_layer = prefs[FavouritesFeatureSource.prefix + id + "-layer"]
properties._orig_theme = prefs[FavouritesFeatureSource.prefix + id + "-theme"]
properties.id = osmId
properties._favourite = "yes"
return {
type: "Feature",
properties,
geometry: {
type: "Point",
coordinates: geometry,
},
}
}
private static getPropertiesFor(
prefs: Record<string, string>,
id: string
): Record<string, string> {
const properties: Record<string, string> = {}
const minLength = FavouritesFeatureSource.prefix.length + id.length + "-property-".length
for (const key in prefs) {
if (key.length < minLength) {
continue
}
if (!key.startsWith(FavouritesFeatureSource.prefix + id)) {
continue
}
const propertyName = key.substring(minLength).replaceAll("__", ":")
properties[propertyName] = prefs[key]
}
return properties
}
/**
* Sets all the (normal) properties as the feature is updated
*/
private updatePropertiesOfFavourite(properties: Record<string, string>) {
const id = properties?.id?.replace("/", "-")
if (!id) {
return
}
console.log("Updating store for", id)
for (const key in properties) {
const pref = this._osmConnection.GetPreference(
"favourite-" + id + "-property-" + key.replaceAll(":", "__")
)
const v = properties[key]
if (v === "" || !v) {
continue
}
pref.setData("" + v)
}
}
public removeFavourite(feature: Feature, tags?: UIEventSource<Record<string, string>>) {
const id = feature.properties.id.replace("/", "-")
const pref = this._osmConnection.GetPreference("favourite-" + id)
this._osmConnection.preferencesHandler.removeAllWithPrefix("mapcomplete-favourite-" + id)
if (tags) {
delete tags.data._favourite
tags.ping()
}
}
public markAsFavourite(
feature: Feature,
layer: string,
theme: string,
tags: UIEventSource<Record<string, string> & { id: OsmId }>,
isFavourite: boolean = true
) {
{
if (!isFavourite) {
this.removeFavourite(feature, tags)
return
}
const id = tags.data.id.replace("/", "-")
const pref = this._osmConnection.GetPreference("favourite-" + id)
const center = GeoOperations.centerpointCoordinates(feature)
pref.setData(JSON.stringify(center))
this._osmConnection.GetPreference("favourite-" + id + "-layer").setData(layer)
this._osmConnection.GetPreference("favourite-" + id + "-theme").setData(theme)
this.updatePropertiesOfFavourite(tags.data)
}
tags.data._favourite = "yes"
tags.ping()
}
private markFeatures(
detected: string[],
featureProperties: { getStore(id: string): UIEventSource<Record<string, string>> },
allFeatures: IndexedFeatureSource
) {
const feature = allFeatures.features.data
for (const f of feature) {
const id = f.properties.id
if (!id) {
continue
}
const store = featureProperties.getStore(id)
const origValue = store.data._favourite
if (detected.indexOf(id) >= 0) {
if (origValue !== "yes") {
store.data._favourite = "yes"
store.ping()
}
} else {
if (origValue) {
store.data._favourite = ""
store.ping()
}
}
}
}
}

View file

@ -6,10 +6,14 @@ import FilteringFeatureSource from "./FilteringFeatureSource"
import LayerState from "../../State/LayerState"
export default class NearbyFeatureSource implements FeatureSource {
private readonly _result = new UIEventSource<Feature[]>(undefined)
public readonly features: Store<Feature[]>
private readonly _targetPoint: Store<{ lon: number; lat: number }>
private readonly _numberOfNeededFeatures: number
private readonly _layerState?: LayerState
private readonly _currentZoom: Store<number>
private readonly _allSources: Store<{ feat: Feature; d: number }[]>[] = []
constructor(
targetPoint: Store<{ lon: number; lat: number }>,
@ -18,43 +22,46 @@ export default class NearbyFeatureSource implements FeatureSource {
layerState?: LayerState,
currentZoom?: Store<number>
) {
this._layerState = layerState
this._targetPoint = targetPoint.stabilized(100)
this._numberOfNeededFeatures = numberOfNeededFeatures
this._currentZoom = currentZoom.stabilized(500)
const allSources: Store<{ feat: Feature; d: number }[]>[] = []
let minzoom = 999
const result = new UIEventSource<Feature[]>(undefined)
this.features = Stores.ListStabilized(result)
function update() {
let features: { feat: Feature; d: number }[] = []
for (const src of allSources) {
features.push(...src.data)
}
features.sort((a, b) => a.d - b.d)
if (numberOfNeededFeatures !== undefined) {
features = features.slice(0, numberOfNeededFeatures)
}
result.setData(features.map((f) => f.feat))
}
this.features = Stores.ListStabilized(this._result)
sources.forEach((source, layer) => {
const flayer = layerState?.filteredLayers.get(layer)
minzoom = Math.min(minzoom, flayer.layerDef.minzoom)
const calcSource = this.createSource(
source.features,
flayer.layerDef.minzoom,
flayer.isDisplayed
)
calcSource.addCallbackAndRunD((features) => {
update()
})
allSources.push(calcSource)
this.registerSource(source, layer)
})
}
public registerSource(source: FeatureSource, layerId: string) {
const flayer = this._layerState?.filteredLayers.get(layerId)
if (!flayer) {
return
}
const calcSource = this.createSource(
source.features,
flayer.layerDef.minzoom,
flayer.isDisplayed
)
calcSource.addCallbackAndRunD((features) => {
this.update()
})
this._allSources.push(calcSource)
}
private update() {
let features: { feat: Feature; d: number }[] = []
for (const src of this._allSources) {
features.push(...src.data)
}
features.sort((a, b) => a.d - b.d)
if (this._numberOfNeededFeatures !== undefined) {
features = features.slice(0, this._numberOfNeededFeatures)
}
this._result.setData(features.map((f) => f.feat))
}
/**
* Sorts the given source by distance, slices down to the required number
*/

View file

@ -501,147 +501,43 @@ export class GeoOperations {
)
}
public static IdentifieCommonSegments(coordinatess: [number, number][][]): {
originalIndex: number
segmentShardWith: number[]
coordinates: []
}[] {
// An edge. Note that the edge might be reversed to fix the sorting condition: start[0] < end[0] && (start[0] != end[0] || start[0] < end[1])
type edge = {
start: [number, number]
end: [number, number]
intermediate: [number, number][]
members: { index: number; isReversed: boolean }[]
/**
* Given a list of points, convert into a GPX-list, e.g. for favourites
* @param locations
* @param title
*/
public static toGpxPoints(
locations: Feature<Point, { date?: string; altitude?: number | string }>[],
title?: string
) {
title = title?.trim()
if (title === undefined || title === "") {
title = "Created with MapComplete"
}
// The strategy:
// 1. Index _all_ edges from _every_ linestring. Index them by starting key, gather which relations run over them
// 2. Join these edges back together - as long as their membership groups are the same
// 3. Convert to results
const allEdgesByKey = new Map<string, edge>()
for (let index = 0; index < coordinatess.length; index++) {
const coordinates = coordinatess[index]
for (let i = 0; i < coordinates.length - 1; i++) {
const c0 = coordinates[i]
const c1 = coordinates[i + 1]
const isReversed = c0[0] > c1[0] || (c0[0] == c1[0] && c0[1] > c1[1])
let key: string
if (isReversed) {
key = "" + c1 + ";" + c0
} else {
key = "" + c0 + ";" + c1
title = Utils.EncodeXmlValue(title)
const trackPoints: string[] = []
for (const l of locations) {
let trkpt = ` <wpt lat="${l.geometry.coordinates[1]}" lon="${l.geometry.coordinates[0]}">`
for (const key in l.properties) {
const keyCleaned = key.replaceAll(":", "__")
trkpt += ` <${keyCleaned}>${l.properties[key]}</${keyCleaned}>\n`
if (key === "website") {
trkpt += ` <link>${l.properties[key]}</link>\n`
}
const member = { index, isReversed }
if (allEdgesByKey.has(key)) {
allEdgesByKey.get(key).members.push(member)
continue
}
let edge: edge
if (!isReversed) {
edge = {
start: c0,
end: c1,
members: [member],
intermediate: [],
}
} else {
edge = {
start: c1,
end: c0,
members: [member],
intermediate: [],
}
}
allEdgesByKey.set(key, edge)
}
trkpt += " </wpt>\n"
trackPoints.push(trkpt)
}
// Lets merge them back together!
let didMergeSomething = false
let allMergedEdges = Array.from(allEdgesByKey.values())
const allEdgesByStartPoint = new Map<string, edge[]>()
for (const edge of allMergedEdges) {
edge.members.sort((m0, m1) => m0.index - m1.index)
const kstart = edge.start + ""
if (!allEdgesByStartPoint.has(kstart)) {
allEdgesByStartPoint.set(kstart, [])
}
allEdgesByStartPoint.get(kstart).push(edge)
}
function membersAreCompatible(first: edge, second: edge): boolean {
// There must be an exact match between the members
if (first.members === second.members) {
return true
}
if (first.members.length !== second.members.length) {
return false
}
// Members are sorted and have the same length, so we can check quickly
for (let i = 0; i < first.members.length; i++) {
const m0 = first.members[i]
const m1 = second.members[i]
if (m0.index !== m1.index || m0.isReversed !== m1.isReversed) {
return false
}
}
// Allrigth, they are the same, lets mark this permanently
second.members = first.members
return true
}
do {
didMergeSomething = false
// We use 'allMergedEdges' as our running list
const consumed = new Set<edge>()
for (const edge of allMergedEdges) {
// Can we make this edge longer at the end?
if (consumed.has(edge)) {
continue
}
console.log("Considering edge", edge)
const matchingEndEdges = allEdgesByStartPoint.get(edge.end + "")
console.log("Matchign endpoints:", matchingEndEdges)
if (matchingEndEdges === undefined) {
continue
}
for (let i = 0; i < matchingEndEdges.length; i++) {
const endEdge = matchingEndEdges[i]
if (consumed.has(endEdge)) {
continue
}
if (!membersAreCompatible(edge, endEdge)) {
continue
}
// We can make the segment longer!
didMergeSomething = true
console.log("Merging ", edge, "with ", endEdge)
edge.intermediate.push(edge.end)
edge.end = endEdge.end
consumed.add(endEdge)
matchingEndEdges.splice(i, 1)
break
}
}
allMergedEdges = allMergedEdges.filter((edge) => !consumed.has(edge))
} while (didMergeSomething)
return []
const header =
'<gpx version="1.1" creator="mapcomplete.org" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">'
return (
header +
"\n<name>" +
title +
"</name>\n<trk><trkseg>\n" +
trackPoints.join("\n") +
"\n</trkseg></trk></gpx>"
)
}
/**

View file

@ -107,7 +107,8 @@ export class ImageUploadManager {
title,
description,
file,
targetKey
targetKey,
tags.data["_orig_theme"]
)
if (!isNaN(Number(featureId))) {
// This is a map note
@ -126,7 +127,8 @@ export class ImageUploadManager {
title: string,
description: string,
blob: File,
targetKey: string | undefined
targetKey: string | undefined,
theme?: string
): Promise<LinkImageAction> {
this.increaseCountFor(this._uploadStarted, featureId)
const properties = this._featureProperties.getStore(featureId)
@ -148,7 +150,7 @@ export class ImageUploadManager {
console.log("Uploading done, creating action for", featureId)
key = targetKey ?? key
const action = new LinkImageAction(featureId, key, value, properties, {
theme: this._layout.id,
theme: theme ?? this._layout.id,
changeType: "add-image",
})
this.increaseCountFor(this._uploadFinished, featureId)

View file

@ -12,6 +12,10 @@ export class OsmPreferences {
"all-osm-preferences",
{}
)
/**
* A map containing the individual preference sources
* @private
*/
private readonly preferenceSources = new Map<string, UIEventSource<string>>()
private auth: any
private userDetails: UIEventSource<UserDetails>
@ -21,7 +25,10 @@ export class OsmPreferences {
this.auth = auth
this.userDetails = osmConnection.userDetails
const self = this
osmConnection.OnLoggedIn(() => self.UpdatePreferences())
osmConnection.OnLoggedIn(() => {
self.UpdatePreferences(true)
return true
})
}
/**
@ -72,11 +79,19 @@ export class OsmPreferences {
let i = 0
while (str !== "") {
if (str === undefined || str === "undefined") {
source.setData(undefined)
throw (
"Got 'undefined' or a literal string containing 'undefined' for a long preference with name " +
key
)
}
if (str === "undefined") {
source.setData(undefined)
throw (
"Got a literal string containing 'undefined' for a long preference with name " +
key
)
}
if (i > 100) {
throw "This long preference is getting very long... "
}
@ -197,7 +212,7 @@ export class OsmPreferences {
})
}
private UpdatePreferences() {
private UpdatePreferences(forceUpdate?: boolean) {
const self = this
this.auth.xhr(
{
@ -210,11 +225,22 @@ export class OsmPreferences {
return
}
const prefs = value.getElementsByTagName("preference")
const seenKeys = new Set<string>()
for (let i = 0; i < prefs.length; i++) {
const pref = prefs[i]
const k = pref.getAttribute("k")
const v = pref.getAttribute("v")
self.preferences.data[k] = v
seenKeys.add(k)
}
if (forceUpdate) {
for (let key in self.preferences.data) {
if (seenKeys.has(key)) {
continue
}
console.log("Deleting key", key, "as we didn't find it upstream")
delete self.preferences.data[key]
}
}
// We merge all the preferences: new keys are uploaded
@ -285,4 +311,14 @@ export class OsmPreferences {
}
)
}
removeAllWithPrefix(prefix: string) {
for (const key in this.preferences.data) {
if (key.startsWith(prefix)) {
this.GetPreference(key, "", { prefix: "" }).setData(undefined)
console.log("Clearing preference", key)
}
}
this.preferences.ping()
}
}

View file

@ -294,6 +294,9 @@ export default class UserRelatedState {
osmConnection.preferencesHandler.preferences.addCallback((newPrefs) => {
for (const k in newPrefs) {
const v = newPrefs[k]
if (v === "undefined" || !v) {
continue
}
if (k.endsWith("-combined-length")) {
const l = Number(v)
const key = k.substring(0, k.length - "length".length)
@ -308,7 +311,6 @@ export default class UserRelatedState {
}
amendedPrefs.ping()
console.log("Amended prefs are:", amendedPrefs.data)
})
const translationMode = osmConnection.GetPreference("translation-mode")

View file

@ -3,6 +3,7 @@ import { Or } from "./Or"
import { TagUtils } from "./TagUtils"
import { Tag } from "./Tag"
import { RegexTag } from "./RegexTag"
import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
export class And extends TagsFilter {
public and: TagsFilter[]
@ -72,6 +73,10 @@ export class And extends TagsFilter {
return allChoices
}
asJson(): TagConfigJson {
return { and: this.and.map((a) => a.asJson()) }
}
asHumanString(linkToWiki: boolean, shorten: boolean, properties: Record<string, string>) {
return this.and
.map((t) => {
@ -228,6 +233,15 @@ export class And extends TagsFilter {
return And.construct(newAnds)
}
/**
* const raw = {"and": [{"or":["leisure=playground","playground!=forest"]},{"or":["leisure=playground","playground!=forest"]}]}
* const parsed = TagUtils.Tag(raw)
* parsed.optimize().asJson() // => {"or":["leisure=playground","playground!=forest"]}
*
* const raw = {"and": [{"and":["advertising=screen"]}, {"and":["advertising~*"]}]}]
* const parsed = TagUtils.Tag(raw)
* parsed.optimize().asJson() // => "advertising=screen"
*/
optimize(): TagsFilter | boolean {
if (this.and.length === 0) {
return true
@ -289,9 +303,17 @@ export class And extends TagsFilter {
optimized.splice(i, 1)
i--
}
} else if (v !== opt.value) {
// detected an internal conflict
return false
} else {
if (!v.match(opt.value)) {
// We _know_ that for the key of the RegexTag `opt`, the value will be `v`.
// As such, if `opt.value` cannot match `v`, we detected an internal conflict and can fail
return false
} else {
// Another tag already provided a _stricter_ value then this regex, so we can remove this one!
optimized.splice(i, 1)
i--
}
}
}
}
@ -369,10 +391,13 @@ export class And extends TagsFilter {
const elements = containedOr.or.filter(
(candidate) => !commonValues.some((cv) => cv.shadows(candidate))
)
newOrs.push(Or.construct(elements))
if (elements.length > 0) {
newOrs.push(Or.construct(elements))
}
}
if (newOrs.length > 0) {
commonValues.push(And.construct(newOrs))
}
commonValues.push(And.construct(newOrs))
const result = new Or(commonValues).optimize()
if (result === false) {
return false

View file

@ -1,18 +1,23 @@
import { TagsFilter } from "./TagsFilter"
import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
import { Tag } from "./Tag"
export default class ComparingTag implements TagsFilter {
private readonly _key: string
private readonly _predicate: (value: string) => boolean
private readonly _representation: string
private readonly _representation: "<" | ">" | "<=" | ">="
private readonly _boundary: string
constructor(
key: string,
predicate: (value: string | undefined) => boolean,
representation: string = ""
representation: "<" | ">" | "<=" | ">=",
boundary: string
) {
this._key = key
this._predicate = predicate
this._representation = representation
this._boundary = boundary
}
asChange(properties: Record<string, string>): { k: string; v: string }[] {
@ -20,15 +25,64 @@ export default class ComparingTag implements TagsFilter {
}
asHumanString(linkToWiki: boolean, shorten: boolean, properties: Record<string, string>) {
return this._key + this._representation
return this._key + this._representation + this._boundary
}
asOverpass(): string[] {
throw "A comparable tag can not be used as overpass filter"
}
/**
* const tg = new ComparingTag("key", value => (Number(value) < 42), "<", "42")
* const tg0 = new ComparingTag("key", value => (Number(value) < 42), "<", "42")
* const tg1 = new ComparingTag("key", value => (Number(value) <= 42), "<=", "42")
* const against = new ComparingTag("key", value => (Number(value) > 0), ">", "0")
* tg.shadows(new Tag("key", "41")) // => true
* tg.shadows(new Tag("key", "0")) // => true
* tg.shadows(new Tag("key", "43")) // => false
* tg.shadows(new Tag("key", "0")) // => true
* tg.shadows(tg) // => true
* tg.shadows(tg0) // => true
* tg.shadows(against) // => false
* tg1.shadows(tg0) // => true
* tg0.shadows(tg1) // => false
*
*/
shadows(other: TagsFilter): boolean {
return other === this
if (other === this) {
return true
}
if (other instanceof ComparingTag) {
if (other._key !== this._key) {
return false
}
const selfDesc = this._representation === "<" || this._representation === "<="
const otherDesc = other._representation === "<" || other._representation === "<="
if (selfDesc !== otherDesc) {
return false
}
if (
this._boundary === other._boundary &&
this._representation === other._representation
) {
return true
}
if (this._predicate(other._boundary)) {
return true
}
return false
}
if (other instanceof Tag) {
if (other.key !== this._key) {
return false
}
if (this.matchesProperties({ [other.key]: other.value })) {
return true
}
}
return false
}
isUsableAsAnswer(): boolean {
@ -38,7 +92,7 @@ export default class ComparingTag implements TagsFilter {
/**
* Checks if the properties match
*
* const t = new ComparingTag("key", (x => Number(x) < 42))
* const t = new ComparingTag("key", (x => Number(x) < 42), "<", "42")
* t.matchesProperties({key: 42}) // => false
* t.matchesProperties({key: 41}) // => true
* t.matchesProperties({key: 0}) // => true
@ -56,6 +110,10 @@ export default class ComparingTag implements TagsFilter {
return []
}
asJson(): TagConfigJson {
return this._key + this._representation
}
optimize(): TagsFilter | boolean {
return this
}

View file

@ -1,6 +1,7 @@
import { TagsFilter } from "./TagsFilter"
import { TagUtils } from "./TagUtils"
import { And } from "./And"
import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
export class Or extends TagsFilter {
public or: TagsFilter[]
@ -27,6 +28,10 @@ export class Or extends TagsFilter {
return false
}
asJson(): TagConfigJson {
return { or: this.or.map((o) => o.asJson()) }
}
/**
*
* import {Tag} from "./Tag";
@ -157,6 +162,12 @@ export class Or extends TagsFilter {
return Or.construct(newOrs)
}
/**
* const raw = {"or": [{"and":["leisure=playground","playground!=forest"]},{"and":["leisure=playground","playground!=forest"]}]}
* const parsed = TagUtils.Tag(raw)
* parsed.optimize().asJson() // => {"and":["leisure=playground","playground!=forest"]}
*
*/
optimize(): TagsFilter | boolean {
if (this.or.length === 0) {
return false
@ -174,9 +185,9 @@ export class Or extends TagsFilter {
const newOrs: TagsFilter[] = []
let containedAnds: And[] = []
for (const tf of optimized) {
if (tf instanceof Or) {
if (tf["or"]) {
// expand all the nested ors...
newOrs.push(...tf.or)
newOrs.push(...tf["or"])
} else if (tf instanceof And) {
// partition of all the ands
containedAnds.push(tf)
@ -191,7 +202,7 @@ export class Or extends TagsFilter {
const cleanedContainedANds: And[] = []
outer: for (let containedAnd of containedAnds) {
for (const known of newOrs) {
// input for optimazation: (K=V | (X=Y & K=V))
// input for optimization: (K=V | (X=Y & K=V))
// containedAnd: (X=Y & K=V)
// newOrs (and thus known): (K=V) --> false
const cleaned = containedAnd.removePhraseConsideredKnown(known, false)
@ -236,16 +247,21 @@ export class Or extends TagsFilter {
const elements = containedAnd.and.filter(
(candidate) => !commonValues.some((cv) => cv.shadows(candidate))
)
if (elements.length == 0) {
continue
}
newAnds.push(And.construct(elements))
}
if (newAnds.length > 0) {
commonValues.push(Or.construct(newAnds))
}
commonValues.push(Or.construct(newAnds))
const result = new And(commonValues).optimize()
if (result === true) {
return true
} else if (result === false) {
// neutral element: skip
} else {
} else if (commonValues.length > 0) {
newOrs.push(And.construct(commonValues))
}
}

View file

@ -1,5 +1,6 @@
import { Tag } from "./Tag"
import { TagsFilter } from "./TagsFilter"
import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
export class RegexTag extends TagsFilter {
public readonly key: RegExp | string
@ -11,6 +12,9 @@ export class RegexTag extends TagsFilter {
super()
this.key = key
this.value = value
if (this.value instanceof RegExp && ("" + this.value).startsWith("^(^(")) {
throw "Detected a duplicate start marker ^(^( in a regextag:" + this.value
}
this.invert = invert
this.matchesEmpty = RegexTag.doesMatch("", this.value)
}
@ -41,11 +45,21 @@ export class RegexTag extends TagsFilter {
return possibleRegex.test(fromTag)
}
private static source(r: string | RegExp) {
private static source(r: string | RegExp, includeStartMarker: boolean = true) {
if (typeof r === "string") {
return r
}
return r.source
if (r === undefined) {
return undefined
}
const src = r.source
if (includeStartMarker) {
return src
}
if (src.startsWith("^(") && src.endsWith(")$")) {
return src.substring(2, src.length - 2)
}
return src
}
/**
@ -82,6 +96,24 @@ export class RegexTag extends TagsFilter {
}
}
/**
* import { TagUtils } from "./TagUtils";
*
* const t = TagUtils.Tag("a~b")
* t.asJson() // => "a~b"
*
* const t = TagUtils.Tag("a=")
* t.asJson() // => "a="
*/
asJson(): TagConfigJson {
const v = RegexTag.source(this.value, false)
if (typeof this.key === "string") {
const oper = typeof this.value === "string" ? "=" : "~"
return `${this.key}${this.invert ? "!" : ""}${oper}${v}`
}
return `${this.key.source}${this.invert ? "!" : ""}~~${v}`
}
isUsableAsAnswer(): boolean {
return false
}
@ -293,7 +325,7 @@ export class RegexTag extends TagsFilter {
if (typeof this.key === "string") {
return [this.key]
}
throw "Key cannot be determined as it is a regex"
return []
}
usedTags(): { key: string; value: string }[] {

View file

@ -1,6 +1,7 @@
import { TagsFilter } from "./TagsFilter"
import { Tag } from "./Tag"
import { Utils } from "../../Utils"
import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
/**
* The substituting-tag uses the tags of a feature a variables and replaces them.
@ -45,6 +46,10 @@ export default class SubstitutingTag implements TagsFilter {
)
}
asJson(): TagConfigJson {
return this._key + (this._invert ? "!" : "") + ":=" + this._value
}
asOverpass(): string[] {
throw "A variable with substitution can not be used to query overpass"
}

View file

@ -1,5 +1,6 @@
import { Utils } from "../../Utils"
import { TagsFilter } from "./TagsFilter"
import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
export class Tag extends TagsFilter {
public key: string
@ -67,6 +68,10 @@ export class Tag extends TagsFilter {
return [`["${this.key}"="${this.value}"]`]
}
asJson(): TagConfigJson {
return this.key + "=" + this.value
}
/**
const t = new Tag("key", "value")

View file

@ -15,13 +15,14 @@ type Tags = Record<string, string>
export type UploadableTag = Tag | SubstitutingTag | And
export class TagUtils {
public static readonly comparators: ReadonlyArray<[string, (a: number, b: number) => boolean]> =
[
["<=", (a, b) => a <= b],
[">=", (a, b) => a >= b],
["<", (a, b) => a < b],
[">", (a, b) => a > b],
]
public static readonly comparators: ReadonlyArray<
["<" | ">" | "<=" | ">=", (a: number, b: number) => boolean]
> = [
["<=", (a, b) => a <= b],
[">=", (a, b) => a >= b],
["<", (a, b) => a < b],
[">", (a, b) => a > b],
]
public static modeDocumentation: Record<
string,
{ name: string; docs: string; uploadable?: boolean; overpassSupport: boolean }
@ -324,6 +325,14 @@ export class TagUtils {
return tags
}
static optimzeJson(json: TagConfigJson): TagConfigJson | boolean {
const optimized = TagUtils.Tag(json).optimize()
if (optimized === true || optimized === false) {
return optimized
}
return optimized.asJson()
}
/**
* Given multiple tagsfilters which can be used as answer, will take the tags with the same keys together as set.
*
@ -735,11 +744,10 @@ export class TagUtils {
const tag = json as string
for (const [operator, comparator] of TagUtils.comparators) {
if (tag.indexOf(operator) >= 0) {
const split = Utils.SplitFirst(tag, operator)
let val = Number(split[1].trim())
const split = Utils.SplitFirst(tag, operator).map((v) => v.trim())
let val = Number(split[1])
if (isNaN(val)) {
val = new Date(split[1].trim()).getTime()
val = new Date(split[1]).getTime()
}
const f = (value: string | number | undefined) => {
@ -762,7 +770,7 @@ export class TagUtils {
}
return comparator(b, val)
}
return new ComparingTag(split[0], f, operator + val)
return new ComparingTag(split[0], f, operator, "" + val)
}
}
@ -861,6 +869,27 @@ export class TagUtils {
return TagUtils.keyCounts.keys[key]
}
public static GetPopularity(tag: TagsFilter): number | undefined {
if (tag instanceof And) {
return Math.min(...Utils.NoNull(tag.and.map((t) => TagUtils.GetPopularity(t)))) - 1
}
if (tag instanceof Or) {
return Math.max(...Utils.NoNull(tag.or.map((t) => TagUtils.GetPopularity(t)))) + 1
}
if (tag instanceof Tag) {
return TagUtils.GetCount(tag.key, tag.value)
}
if (tag instanceof RegexTag) {
const key = tag.key
if (key instanceof RegExp || tag.invert || tag.isNegative()) {
return undefined
}
return TagUtils.GetCount(key)
}
return undefined
}
private static order(a: TagsFilter, b: TagsFilter, usePopularity: boolean): number {
const rta = a instanceof RegexTag
const rtb = b instanceof RegexTag

View file

@ -1,3 +1,5 @@
import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
export abstract class TagsFilter {
abstract asOverpass(): string[]
@ -17,6 +19,8 @@ export abstract class TagsFilter {
properties: Record<string, string>
): string
abstract asJson(): TagConfigJson
abstract usedKeys(): string[]
/**

View file

@ -14,7 +14,7 @@ export class MangroveIdentity {
const keypairEventSource = new UIEventSource<CryptoKeyPair>(undefined)
this.keypair = keypairEventSource
mangroveIdentity.addCallbackAndRunD(async (data) => {
if (data === "") {
if (!data) {
return
}
const keypair = await MangroveReviews.jwkToKeypair(JSON.parse(data))