Merge branch 'develop'

This commit is contained in:
pietervdvn 2022-09-10 13:17:07 +02:00
commit f7002ce315
411 changed files with 54602 additions and 38696 deletions

10
.editorconfig Normal file
View file

@ -0,0 +1,10 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
[*.ts]
indent_style = space
indent_size = 4
trim_trailing_whitespace = true

3
.git-blame-ignore-revs Normal file
View file

@ -0,0 +1,3 @@
# to be filled once final sha is known
# Prettier init
b541d3eab49761faf710893386e9bee2801ff533

View file

@ -4,9 +4,11 @@ runs:
using: "composite"
steps:
- name: Set up Node.js
uses: actions/setup-node@v1.4.6
uses: actions/setup-node@v3
with:
node-version: '16'
node-version: "16"
cache: "npm"
cache-dependency-path: package-lock.json
- name: install deps
run: npm ci

View file

@ -1,6 +1,5 @@
name: Deployment on pietervdvn
on:
push
on: push
jobs:
build:
@ -28,13 +27,15 @@ jobs:
cd pietervdvn.github.io
git pull
- name: get branch name
run: echo TARGET_BRANCH=${GITHUB_REF:11} >> $GITHUB_ENV
- name: "Copying files"
run: |
echo "Deploying"
TARGET=${GITHUB_REF:11}
rm -rf pietervdvn.github.io/mc/$TARGET/*
mkdir -p pietervdvn.github.io/mc/$TARGET/
cp -r dist/* pietervdvn.github.io/mc/$TARGET/
rm -rf pietervdvn.github.io/mc/${{ env.TARGET_BRANCH }}/*
mkdir -p pietervdvn.github.io/mc/${{ env.TARGET_BRANCH }}/
cp -r dist/* pietervdvn.github.io/mc/${{ env.TARGET_BRANCH }}/
cd pietervdvn.github.io/
git add *
if git status | grep -q "Changes to be committed"
@ -44,3 +45,13 @@ jobs:
else
echo "No changes to commit"
fi
env:
TARGET_BRANCH: ${{ env.TARGET_BRANCH }}
- uses: mshick/add-pr-comment@v1
name: Comment the PR with the review URL
if: ${{ success() && github.ref != 'refs/heads/develop' && github.ref != 'refs/heads/master' }}
with:
message: |
[🚀 Preview Branch](https://pietervdvn.github.io/mc/${{ env.TARGET_BRANCH }})
repo-token: ${{ secrets.GITHUB_TOKEN }}

View file

@ -6,7 +6,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Setup and validate themes
uses: ./.github/actions/setup-and-validate

13
.prettierignore Normal file
View file

@ -0,0 +1,13 @@
node_modules
.git
langs/
vendor/
dist/
.cache
assets/generated/
assets/themes/
assets/layers/
Docs/Tools/stats/
Docs/Layers/
Docs/Schemas/
Docs/TagInfo/

4
.prettierrc.json Normal file
View file

@ -0,0 +1,4 @@
{
"semi": false,
"printWidth": 100
}

View file

@ -1,26 +1,28 @@
import * as known_themes from "../assets/generated/known_layers_and_themes.json"
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig";
import LayerConfig from "../Models/ThemeConfig/LayerConfig";
import BaseUIElement from "../UI/BaseUIElement";
import Combine from "../UI/Base/Combine";
import Title from "../UI/Base/Title";
import List from "../UI/Base/List";
import DependencyCalculator from "../Models/ThemeConfig/DependencyCalculator";
import Constants from "../Models/Constants";
import {Utils} from "../Utils";
import Link from "../UI/Base/Link";
import {LayoutConfigJson} from "../Models/ThemeConfig/Json/LayoutConfigJson";
import {LayerConfigJson} from "../Models/ThemeConfig/Json/LayerConfigJson";
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"
import LayerConfig from "../Models/ThemeConfig/LayerConfig"
import BaseUIElement from "../UI/BaseUIElement"
import Combine from "../UI/Base/Combine"
import Title from "../UI/Base/Title"
import List from "../UI/Base/List"
import DependencyCalculator from "../Models/ThemeConfig/DependencyCalculator"
import Constants from "../Models/Constants"
import { Utils } from "../Utils"
import Link from "../UI/Base/Link"
import { LayoutConfigJson } from "../Models/ThemeConfig/Json/LayoutConfigJson"
import { LayerConfigJson } from "../Models/ThemeConfig/Json/LayerConfigJson"
export class AllKnownLayouts {
public static allKnownLayouts: Map<string, LayoutConfig> = AllKnownLayouts.AllLayouts();
public static layoutsList: LayoutConfig[] = AllKnownLayouts.GenerateOrderedList(AllKnownLayouts.allKnownLayouts);
public static allKnownLayouts: Map<string, LayoutConfig> = AllKnownLayouts.AllLayouts()
public static layoutsList: LayoutConfig[] = AllKnownLayouts.GenerateOrderedList(
AllKnownLayouts.allKnownLayouts
)
// Must be below the list...
private static sharedLayers: Map<string, LayerConfig> = AllKnownLayouts.getSharedLayers();
private static sharedLayers: Map<string, LayerConfig> = AllKnownLayouts.getSharedLayers()
public static AllPublicLayers(options?: {
includeInlineLayers:true | boolean
}) : LayerConfig[] {
includeInlineLayers: true | boolean
}): LayerConfig[] {
const allLayers: LayerConfig[] = []
const seendIds = new Set<string>()
AllKnownLayouts.sharedLayers.forEach((layer, key) => {
@ -28,7 +30,7 @@ export class AllKnownLayouts {
allLayers.push(layer)
})
if (options?.includeInlineLayers ?? true) {
const publicLayouts = AllKnownLayouts.layoutsList.filter(l => !l.hideFromOverview)
const publicLayouts = AllKnownLayouts.layoutsList.filter((l) => !l.hideFromOverview)
for (const layout of publicLayouts) {
if (layout.hideFromOverview) {
continue
@ -40,7 +42,6 @@ export class AllKnownLayouts {
seendIds.add(layer.id)
allLayers.push(layer)
}
}
}
@ -52,11 +53,14 @@ export class AllKnownLayouts {
*/
public static themesUsingLayer(id: string, publicOnly = true): LayoutConfig[] {
const themes = AllKnownLayouts.layoutsList
.filter(l => !(publicOnly && l.hideFromOverview) && l.id !== "personal")
.map(theme => ({theme, minzoom: theme.layers.find(layer => layer.id === id)?.minzoom}))
.filter(obj => obj.minzoom !== undefined)
.filter((l) => !(publicOnly && l.hideFromOverview) && l.id !== "personal")
.map((theme) => ({
theme,
minzoom: theme.layers.find((layer) => layer.id === id)?.minzoom,
}))
.filter((obj) => obj.minzoom !== undefined)
themes.sort((th0, th1) => th1.minzoom - th0.minzoom)
return themes.map(th => th.theme);
return themes.map((th) => th.theme)
}
/**
@ -65,12 +69,15 @@ export class AllKnownLayouts {
* @param callback
* @constructor
*/
public static GenOverviewsForSingleLayer(callback: (layer: LayerConfig, element: BaseUIElement, inlineSource: string) => void): void {
const allLayers: LayerConfig[] = Array.from(AllKnownLayouts.sharedLayers.values())
.filter(layer => Constants.priviliged_layers.indexOf(layer.id) < 0)
public static GenOverviewsForSingleLayer(
callback: (layer: LayerConfig, element: BaseUIElement, inlineSource: string) => void
): void {
const allLayers: LayerConfig[] = Array.from(AllKnownLayouts.sharedLayers.values()).filter(
(layer) => Constants.priviliged_layers.indexOf(layer.id) < 0
)
const builtinLayerIds: Set<string> = new Set<string>()
allLayers.forEach(l => builtinLayerIds.add(l.id))
const inlineLayers = new Map<string, string>();
allLayers.forEach((l) => builtinLayerIds.add(l.id))
const inlineLayers = new Map<string, string>()
for (const layout of Array.from(AllKnownLayouts.allKnownLayouts.values())) {
if (layout.hideFromOverview) {
@ -78,7 +85,6 @@ export class AllKnownLayouts {
}
for (const layer of layout.layers) {
if (Constants.priviliged_layers.indexOf(layer.id) >= 0) {
continue
}
@ -113,7 +119,6 @@ export class AllKnownLayouts {
}
}
// Determine the cross-dependencies
const layerIsNeededBy: Map<string, string[]> = new Map<string, string[]>()
@ -125,12 +130,14 @@ export class AllKnownLayouts {
}
layerIsNeededBy.get(dependency).push(layer.id)
}
}
allLayers.forEach((layer) => {
const element = layer.GenerateDocumentation(themesPerLayer.get(layer.id), layerIsNeededBy, DependencyCalculator.getLayerDependencies(layer))
const element = layer.GenerateDocumentation(
themesPerLayer.get(layer.id),
layerIsNeededBy,
DependencyCalculator.getLayerDependencies(layer)
)
callback(layer, element, inlineLayers.get(layer.id))
})
}
@ -146,11 +153,12 @@ export class AllKnownLayouts {
}
}
const allLayers: LayerConfig[] = Array.from(AllKnownLayouts.sharedLayers.values())
.filter(layer => Constants.priviliged_layers.indexOf(layer.id) < 0)
const allLayers: LayerConfig[] = Array.from(AllKnownLayouts.sharedLayers.values()).filter(
(layer) => Constants.priviliged_layers.indexOf(layer.id) < 0
)
const builtinLayerIds: Set<string> = new Set<string>()
allLayers.forEach(l => builtinLayerIds.add(l.id))
allLayers.forEach((l) => builtinLayerIds.add(l.id))
const themesPerLayer = new Map<string, string[]>()
@ -166,7 +174,6 @@ export class AllKnownLayouts {
}
}
// Determine the cross-dependencies
const layerIsNeededBy: Map<string, string[]> = new Map<string, string[]>()
@ -178,25 +185,32 @@ export class AllKnownLayouts {
}
layerIsNeededBy.get(dependency).push(layer.id)
}
}
return new Combine([
new Title("Special and other useful layers", 1),
"MapComplete has a few data layers available in the theme which have special properties through builtin-hooks. Furthermore, there are some normal layers (which are built from normal Theme-config files) but are so general that they get a mention here.",
new Title("Priviliged layers", 1),
new List(Constants.priviliged_layers.map(id => "[" + id + "](#" + id + ")")),
new List(Constants.priviliged_layers.map((id) => "[" + id + "](#" + id + ")")),
...Constants.priviliged_layers
.map(id => AllKnownLayouts.sharedLayers.get(id))
.map((l) => l.GenerateDocumentation(themesPerLayer.get(l.id), layerIsNeededBy, DependencyCalculator.getLayerDependencies(l), Constants.added_by_default.indexOf(l.id) >= 0, Constants.no_include.indexOf(l.id) < 0)),
.map((id) => AllKnownLayouts.sharedLayers.get(id))
.map((l) =>
l.GenerateDocumentation(
themesPerLayer.get(l.id),
layerIsNeededBy,
DependencyCalculator.getLayerDependencies(l),
Constants.added_by_default.indexOf(l.id) >= 0,
Constants.no_include.indexOf(l.id) < 0
)
),
new Title("Normal layers", 1),
"The following layers are included in MapComplete:",
new List(Array.from(AllKnownLayouts.sharedLayers.keys()).map(id => new Link(id, "./Layers/" + id + ".md")))
new List(
Array.from(AllKnownLayouts.sharedLayers.keys()).map(
(id) => new Link(id, "./Layers/" + id + ".md")
)
),
])
}
public static GenerateDocumentationForTheme(theme: LayoutConfig): BaseUIElement {
@ -204,37 +218,42 @@ export class AllKnownLayouts {
new Title(new Combine([theme.title, "(", theme.id + ")"]), 2),
theme.description,
"This theme contains the following layers:",
new List(theme.layers.map(l => l.id)),
new List(theme.layers.map((l) => l.id)),
"Available languages:",
new List(theme.language)
new List(theme.language),
])
}
public static getSharedLayers(): Map<string, LayerConfig> {
const sharedLayers = new Map<string, LayerConfig>();
const sharedLayers = new Map<string, LayerConfig>()
for (const layer of known_themes["layers"]) {
try {
// @ts-ignore
const parsed = new LayerConfig(layer, "shared_layers")
sharedLayers.set(layer.id, parsed);
sharedLayers.set(layer.id, parsed)
} catch (e) {
if (!Utils.runningFromConsole) {
console.error("CRITICAL: Could not parse a layer configuration!", layer.id, " due to", e)
console.error(
"CRITICAL: Could not parse a layer configuration!",
layer.id,
" due to",
e
)
}
}
}
return sharedLayers;
return sharedLayers
}
public static getSharedLayersConfigs(): Map<string, LayerConfigJson> {
const sharedLayers = new Map<string, LayerConfigJson>();
const sharedLayers = new Map<string, LayerConfigJson>()
for (const layer of known_themes["layers"]) {
// @ts-ignore
sharedLayers.set(layer.id, layer);
// @ts-ignore
sharedLayers.set(layer.id, layer)
}
return sharedLayers;
return sharedLayers
}
private static GenerateOrderedList(allKnownLayouts: Map<string, LayoutConfig>): LayoutConfig[] {
@ -242,28 +261,26 @@ export class AllKnownLayouts {
allKnownLayouts.forEach((layout) => {
list.push(layout)
})
return list;
return list
}
private static AllLayouts(): Map<string, LayoutConfig> {
const dict: Map<string, LayoutConfig> = new Map();
const dict: Map<string, LayoutConfig> = new Map()
for (const layoutConfigJson of known_themes["themes"]) {
const layout = new LayoutConfig(<LayoutConfigJson>layoutConfigJson, true)
dict.set(layout.id, layout)
for (let i = 0; i < layout.layers.length; i++) {
let layer = layout.layers[i];
if (typeof (layer) === "string") {
layer = AllKnownLayouts.sharedLayers.get(layer);
let layer = layout.layers[i]
if (typeof layer === "string") {
layer = AllKnownLayouts.sharedLayers.get(layer)
layout.layers[i] = layer
if (layer === undefined) {
console.log("Defined layers are ", AllKnownLayouts.sharedLayers.keys())
throw `Layer ${layer} was not found or defined - probably a type was made`
}
}
}
}
return dict;
return dict
}
}

View file

@ -1,38 +1,49 @@
import * as questions from "../assets/tagRenderings/questions.json";
import * as icons from "../assets/tagRenderings/icons.json";
import {Utils} from "../Utils";
import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig";
import {TagRenderingConfigJson} from "../Models/ThemeConfig/Json/TagRenderingConfigJson";
import BaseUIElement from "../UI/BaseUIElement";
import Combine from "../UI/Base/Combine";
import Title from "../UI/Base/Title";
import {FixedUiElement} from "../UI/Base/FixedUiElement";
import List from "../UI/Base/List";
import * as questions from "../assets/tagRenderings/questions.json"
import * as icons from "../assets/tagRenderings/icons.json"
import { Utils } from "../Utils"
import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig"
import { TagRenderingConfigJson } from "../Models/ThemeConfig/Json/TagRenderingConfigJson"
import BaseUIElement from "../UI/BaseUIElement"
import Combine from "../UI/Base/Combine"
import Title from "../UI/Base/Title"
import { FixedUiElement } from "../UI/Base/FixedUiElement"
import List from "../UI/Base/List"
export default class SharedTagRenderings {
public static SharedTagRendering: Map<string, TagRenderingConfig> = SharedTagRenderings.generatedSharedFields();
public static SharedTagRenderingJson: Map<string, TagRenderingConfigJson> = SharedTagRenderings.generatedSharedFieldsJsons();
public static SharedIcons: Map<string, TagRenderingConfig> = SharedTagRenderings.generatedSharedFields(true);
public static SharedTagRendering: Map<string, TagRenderingConfig> =
SharedTagRenderings.generatedSharedFields()
public static SharedTagRenderingJson: Map<string, TagRenderingConfigJson> =
SharedTagRenderings.generatedSharedFieldsJsons()
public static SharedIcons: Map<string, TagRenderingConfig> =
SharedTagRenderings.generatedSharedFields(true)
private static generatedSharedFields(iconsOnly = false): Map<string, TagRenderingConfig> {
const configJsons = SharedTagRenderings.generatedSharedFieldsJsons(iconsOnly)
const d = new Map<string, TagRenderingConfig>()
for (const key of Array.from(configJsons.keys())) {
try {
d.set(key, new TagRenderingConfig(configJsons.get(key), `SharedTagRenderings.${key}`))
d.set(
key,
new TagRenderingConfig(configJsons.get(key), `SharedTagRenderings.${key}`)
)
} catch (e) {
if (!Utils.runningFromConsole) {
console.error("BUG: could not parse", key, " from questions.json or icons.json - this error happened during the build step of the SharedTagRenderings", e)
console.error(
"BUG: could not parse",
key,
" from questions.json or icons.json - this error happened during the build step of the SharedTagRenderings",
e
)
}
}
}
return d
}
private static generatedSharedFieldsJsons(iconsOnly = false): Map<string, TagRenderingConfigJson> {
const dict = new Map<string, TagRenderingConfigJson>();
private static generatedSharedFieldsJsons(
iconsOnly = false
): Map<string, TagRenderingConfigJson> {
const dict = new Map<string, TagRenderingConfigJson>()
if (!iconsOnly) {
for (const key in questions) {
@ -53,13 +64,16 @@ export default class SharedTagRenderings {
if (key === "id") {
return
}
value.id = value.id ?? key;
if(value["builtin"] !== undefined){
if(value["override"] == undefined){
throw "HUH? Why whould you want to reuse a builtin if one doesn't override? In questions.json/"+key
value.id = value.id ?? key
if (value["builtin"] !== undefined) {
if (value["override"] == undefined) {
throw (
"HUH? Why whould you want to reuse a builtin if one doesn't override? In questions.json/" +
key
)
}
if(typeof value["builtin"] !== "string"){
return;
if (typeof value["builtin"] !== "string") {
return
}
// This is a really funny situation: we extend another tagRendering!
const parent = Utils.Clone(dict.get(value["builtin"]))
@ -73,36 +87,31 @@ export default class SharedTagRenderings {
}
})
return dict;
return dict
}
public static HelpText(): BaseUIElement {
return new Combine([
new Combine([
new Title("Builtin questions", 1),
new Title("Builtin questions",1),
"The following items can be easily reused in your layers"
"The following items can be easily reused in your layers",
]).SetClass("flex flex-col"),
... Array.from( SharedTagRenderings.SharedTagRendering.keys()).map(key => {
...Array.from(SharedTagRenderings.SharedTagRendering.keys()).map((key) => {
const tr = SharedTagRenderings.SharedTagRendering.get(key)
let mappings: BaseUIElement = undefined
if(tr.mappings?.length > 0){
mappings = new List(tr.mappings.map(m => m.then.textFor("en")))
if (tr.mappings?.length > 0) {
mappings = new List(tr.mappings.map((m) => m.then.textFor("en")))
}
return new Combine([
new Title(key),
tr.render?.textFor("en"),
tr.question?.textFor("en") ?? new FixedUiElement("Read-only tagrendering").SetClass("font-bold"),
mappings
tr.question?.textFor("en") ??
new FixedUiElement("Read-only tagrendering").SetClass("font-bold"),
mappings,
]).SetClass("flex flex-col")
})
}),
]).SetClass("flex flex-col")
}
}

View file

@ -1,29 +1,27 @@
import {existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync} from "fs";
import ScriptUtils from "../../scripts/ScriptUtils";
import {Utils} from "../../Utils";
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "fs"
import ScriptUtils from "../../scripts/ScriptUtils"
import { Utils } from "../../Utils"
ScriptUtils.fixUtils()
class StatsDownloader {
private readonly urlTemplate =
"https://osmcha.org/api/v1/changesets/?date__gte={start_date}&date__lte={end_date}&page={page}&comment=%23mapcomplete&page_size=100"
private readonly urlTemplate = "https://osmcha.org/api/v1/changesets/?date__gte={start_date}&date__lte={end_date}&page={page}&comment=%23mapcomplete&page_size=100"
private readonly _targetDirectory: string;
private readonly _targetDirectory: string
constructor(targetDirectory = ".") {
this._targetDirectory = targetDirectory;
this._targetDirectory = targetDirectory
}
public async DownloadStats(startYear = 2020, startMonth = 5) {
const today = new Date();
public async DownloadStats(startYear = 2020, startMonth = 5, startDay = 1) {
const today = new Date()
const currentYear = today.getFullYear()
const currentMonth = today.getMonth() + 1
for (let year = startYear; year <= currentYear; year++) {
for (let month = 1; month <= 12; month++) {
if (year === startYear && month < startMonth) {
continue;
continue
}
if (year === currentYear && month > currentMonth) {
@ -32,26 +30,40 @@ class StatsDownloader {
const pathM = `${this._targetDirectory}/stats.${year}-${month}.json`
if (existsSync(pathM)) {
continue;
continue
}
const features = []
for (let day = 1; day <= 31; day++) {
let monthIsFinished = true
const writtenFiles = []
for (let day = startDay; day <= 31; day++) {
if (year === currentYear && month === currentMonth && day === today.getDate()) {
break;
monthIsFinished = false
break
}
{
const date = new Date(year, month - 1, day)
if(date.getMonth() != month -1){
if (date.getMonth() != month - 1) {
// We did roll over
continue
}
}
const path = `${this._targetDirectory}/stats.${year}-${month}-${(day < 10 ? "0" : "") + day}.day.json`
const path = `${this._targetDirectory}/stats.${year}-${month}-${
(day < 10 ? "0" : "") + day
}.day.json`
writtenFiles.push(path)
if (existsSync(path)) {
features.push(...JSON.parse(readFileSync(path, "UTF-8")))
console.log("Loaded ", path, "from disk, got", features.length, "features now")
let features = JSON.parse(readFileSync(path, "UTF-8"))
features = features?.features ?? features
console.log(features)
features.push(...features.features) // day-stats are generally a list already, but in some ad-hoc cases might be a geojson-collection too
console.log(
"Loaded ",
path,
"from disk, got",
features.length,
"features now"
)
continue
}
let dayFeatures: any[] = undefined
@ -59,47 +71,72 @@ class StatsDownloader {
dayFeatures = await this.DownloadStatsForDay(year, month, day, path)
} catch (e) {
console.error(e)
console.error("Could not download " + year + "-" + month + "-" + day + "... Trying again")
console.error(
"Could not download " +
year +
"-" +
month +
"-" +
day +
"... Trying again"
)
dayFeatures = await this.DownloadStatsForDay(year, month, day, path)
}
writeFileSync(path, JSON.stringify(dayFeatures))
features.push(...dayFeatures)
}
writeFileSync(pathM, JSON.stringify({features}))
if (monthIsFinished) {
writeFileSync(pathM, JSON.stringify({ features }))
for (const writtenFile of writtenFiles) {
unlinkSync(writtenFile)
}
}
}
startDay = 1
}
}
public async DownloadStatsForDay(year: number, month: number, day: number, path: string): Promise<any[]> {
let page = 1;
public async DownloadStatsForDay(
year: number,
month: number,
day: number,
path: string
): Promise<any[]> {
let page = 1
let allFeatures = []
let endDay = new Date(year, month - 1 /* Zero-indexed: 0 = january*/, day + 1);
let endDate = `${endDay.getFullYear()}-${Utils.TwoDigits(endDay.getMonth() + 1)}-${Utils.TwoDigits(endDay.getDate())}`
let url = this.urlTemplate.replace("{start_date}", year + "-" + Utils.TwoDigits(month) + "-" + Utils.TwoDigits(day))
let endDay = new Date(year, month - 1 /* Zero-indexed: 0 = january*/, day + 1)
let endDate = `${endDay.getFullYear()}-${Utils.TwoDigits(
endDay.getMonth() + 1
)}-${Utils.TwoDigits(endDay.getDate())}`
let url = this.urlTemplate
.replace(
"{start_date}",
year + "-" + Utils.TwoDigits(month) + "-" + Utils.TwoDigits(day)
)
.replace("{end_date}", endDate)
.replace("{page}", "" + page)
let headers = {
'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0',
'Accept-Language': 'en-US,en;q=0.5',
'Referer': 'https://osmcha.org/?filters=%7B%22date__gte%22%3A%5B%7B%22label%22%3A%222020-07-05%22%2C%22value%22%3A%222020-07-05%22%7D%5D%2C%22editor%22%3A%5B%7B%22label%22%3A%22mapcomplete%22%2C%22value%22%3A%22mapcomplete%22%7D%5D%7D',
'Content-Type': 'application/json',
'Authorization': 'Token 6e422e2afedb79ef66573982012000281f03dc91',
'DNT': '1',
'Connection': 'keep-alive',
'TE': 'Trailers',
'Pragma': 'no-cache',
'Cache-Control': 'no-cache'
"User-Agent":
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0",
"Accept-Language": "en-US,en;q=0.5",
Referer:
"https://osmcha.org/?filters=%7B%22date__gte%22%3A%5B%7B%22label%22%3A%222020-07-05%22%2C%22value%22%3A%222020-07-05%22%7D%5D%2C%22editor%22%3A%5B%7B%22label%22%3A%22mapcomplete%22%2C%22value%22%3A%22mapcomplete%22%7D%5D%7D",
"Content-Type": "application/json",
Authorization: "Token 6e422e2afedb79ef66573982012000281f03dc91",
DNT: "1",
Connection: "keep-alive",
TE: "Trailers",
Pragma: "no-cache",
"Cache-Control": "no-cache",
}
while (url) {
ScriptUtils.erasableLog(`Downloading stats for ${year}-${month}-${day}, page ${page} ${url}`)
ScriptUtils.erasableLog(
`Downloading stats for ${year}-${month}-${day}, page ${page} ${url}`
)
const result = await Utils.downloadJson(url, headers)
page++;
page++
allFeatures.push(...result.features)
if (result.features === undefined) {
console.log("ERROR", result)
@ -107,58 +144,59 @@ class StatsDownloader {
}
url = result.next
}
console.log(`Writing ${allFeatures.length} features to `, path, Utils.Times(_ => " ", 80))
console.log(
`Writing ${allFeatures.length} features to `,
path,
Utils.Times((_) => " ", 80)
)
allFeatures = Utils.NoNull(allFeatures)
allFeatures.forEach(f => {
f.properties = {...f.properties, ...f.properties.metadata}
allFeatures.forEach((f) => {
f.properties = { ...f.properties, ...f.properties.metadata }
delete f.properties.metadata
f.properties.id = f.id
})
return allFeatures
}
}
interface ChangeSetData {
"id": number,
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [number, number][][]
},
"properties": {
"check_user": null,
"reasons": [],
"tags": [],
"features": [],
"user": string,
"uid": string,
"editor": string,
"comment": string,
"comments_count": number,
"source": string,
"imagery_used": string,
"date": string,
"reviewed_features": [],
"create": number,
"modify": number,
"delete": number,
"area": number,
"is_suspect": boolean,
"harmful": any,
"checked": boolean,
"check_date": any,
"metadata": {
"host": string,
"theme": string,
"imagery": string,
"language": string
id: number
type: "Feature"
geometry: {
type: "Polygon"
coordinates: [number, number][][]
}
properties: {
check_user: null
reasons: []
tags: []
features: []
user: string
uid: string
editor: string
comment: string
comments_count: number
source: string
imagery_used: string
date: string
reviewed_features: []
create: number
modify: number
delete: number
area: number
is_suspect: boolean
harmful: any
checked: boolean
check_date: any
metadata: {
host: string
theme: string
imagery: string
language: string
}
}
}
async function main(): Promise<void> {
if (!existsSync("graphs")) {
mkdirSync("graphs")
@ -167,38 +205,48 @@ async function main(): Promise<void> {
const targetDir = "Docs/Tools/stats"
let year = 2020
let month = 5
if(!isNaN(Number(process.argv[2]))){
let day = 1
if (!isNaN(Number(process.argv[2]))) {
year = Number(process.argv[2])
}
if(!isNaN(Number(process.argv[3]))){
if (!isNaN(Number(process.argv[3]))) {
month = Number(process.argv[3])
}
if (!isNaN(Number(process.argv[4]))) {
day = Number(process.argv[4])
}
do {
try {
await new StatsDownloader(targetDir).DownloadStats(year, month)
await new StatsDownloader(targetDir).DownloadStats(year, month, day)
break
} catch (e) {
console.log(e)
}
} while (true)
const allPaths = readdirSync(targetDir)
.filter(p => p.startsWith("stats.") && p.endsWith(".json"));
let allFeatures: ChangeSetData[] = [].concat(...allPaths
.map(path => JSON.parse(readFileSync("Docs/Tools/stats/" + path, "utf-8")).features));
allFeatures = allFeatures.filter(f => f?.properties !== undefined && (f.properties.editor === null || f.properties.editor.toLowerCase().startsWith("mapcomplete")))
const allPaths = readdirSync(targetDir).filter(
(p) => p.startsWith("stats.") && p.endsWith(".json")
)
let allFeatures: ChangeSetData[] = [].concat(
...allPaths.map(
(path) => JSON.parse(readFileSync("Docs/Tools/stats/" + path, "utf-8")).features
)
)
allFeatures = allFeatures.filter(
(f) =>
f?.properties !== undefined &&
(f.properties.editor === null ||
f.properties.editor.toLowerCase().startsWith("mapcomplete"))
)
allFeatures = allFeatures.filter(f => f.properties.metadata?.theme !== "EMPTY CS")
allFeatures = allFeatures.filter((f) => f.properties.metadata?.theme !== "EMPTY CS")
if (process.argv.indexOf("--no-graphs") >= 0) {
return
}
const allFiles = readdirSync("Docs/Tools/stats").filter(p => p.endsWith(".json"))
const allFiles = readdirSync("Docs/Tools/stats").filter((p) => p.endsWith(".json"))
writeFileSync("Docs/Tools/stats/file-overview.json", JSON.stringify(allFiles))
}
main().then(_ => console.log("All done!"))
main().then((_) => console.log("All done!"))

View file

@ -1,32 +1 @@
[
"file-overview.json",
"missing_editor.json",
"stats.2020-10.json",
"stats.2020-11.json",
"stats.2020-12.json",
"stats.2020-5.json",
"stats.2020-6.json",
"stats.2020-7.json",
"stats.2020-8.json",
"stats.2020-9.json",
"stats.2021-1.json",
"stats.2021-10.json",
"stats.2021-11.json",
"stats.2021-12.json",
"stats.2021-2.json",
"stats.2021-3.json",
"stats.2021-4.json",
"stats.2021-5.json",
"stats.2021-6.json",
"stats.2021-7.json",
"stats.2021-8.json",
"stats.2021-9.json",
"stats.2022-1.json",
"stats.2022-2.json",
"stats.2022-3.json",
"stats.2022-4.json",
"stats.2022-5.json",
"stats.2022-6.json",
"stats.2022-7.json",
"stats.2022-8.json"
]
["file-overview.json","missing_editor.json","stats.2020-10.json","stats.2020-11.json","stats.2020-12.json","stats.2020-5.json","stats.2020-6.json","stats.2020-7.json","stats.2020-8.json","stats.2020-9.json","stats.2021-1.json","stats.2021-10.json","stats.2021-11.json","stats.2021-12.json","stats.2021-2.json","stats.2021-3.json","stats.2021-4.json","stats.2021-5.json","stats.2021-6.json","stats.2021-7.json","stats.2021-8.json","stats.2021-9.json","stats.2022-1.json","stats.2022-2.json","stats.2022-3.json","stats.2022-4.json","stats.2022-5.json","stats.2022-6.json","stats.2022-7.json","stats.2022-8.json","stats.2022-9-01.day.json","stats.2022-9-02.day.json"]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,15 +1,17 @@
import BaseLayer from "../../Models/BaseLayer";
import {ImmutableStore, Store, UIEventSource} from "../UIEventSource";
import Loc from "../../Models/Loc";
import BaseLayer from "../../Models/BaseLayer"
import { ImmutableStore, Store, UIEventSource } from "../UIEventSource"
import Loc from "../../Models/Loc"
export interface AvailableBaseLayersObj {
readonly osmCarto: BaseLayer;
layerOverview: BaseLayer[];
readonly osmCarto: BaseLayer
layerOverview: BaseLayer[]
AvailableLayersAt(location: Store<Loc>): Store<BaseLayer[]>
SelectBestLayerAccordingTo(location: Store<Loc>, preferedCategory: Store<string | string[]>): Store<BaseLayer>;
SelectBestLayerAccordingTo(
location: Store<Loc>,
preferedCategory: Store<string | string[]>
): Store<BaseLayer>
}
/**
@ -17,20 +19,28 @@ export interface AvailableBaseLayersObj {
* Changes the basemap
*/
export default class AvailableBaseLayers {
public static layerOverview: BaseLayer[];
public static osmCarto: BaseLayer;
public static layerOverview: BaseLayer[]
public static osmCarto: BaseLayer
private static implementation: AvailableBaseLayersObj
static AvailableLayersAt(location: Store<Loc>): Store<BaseLayer[]> {
return AvailableBaseLayers.implementation?.AvailableLayersAt(location) ?? new ImmutableStore<BaseLayer[]>([]);
return (
AvailableBaseLayers.implementation?.AvailableLayersAt(location) ??
new ImmutableStore<BaseLayer[]>([])
)
}
static SelectBestLayerAccordingTo(location: Store<Loc>, preferedCategory: UIEventSource<string | string[]>): Store<BaseLayer> {
return AvailableBaseLayers.implementation?.SelectBestLayerAccordingTo(location, preferedCategory) ?? new ImmutableStore<BaseLayer>(undefined);
static SelectBestLayerAccordingTo(
location: Store<Loc>,
preferedCategory: UIEventSource<string | string[]>
): Store<BaseLayer> {
return (
AvailableBaseLayers.implementation?.SelectBestLayerAccordingTo(
location,
preferedCategory
) ?? new ImmutableStore<BaseLayer>(undefined)
)
}
public static implement(backend: AvailableBaseLayersObj) {
@ -38,5 +48,4 @@ export default class AvailableBaseLayers {
AvailableBaseLayers.osmCarto = backend.osmCarto
AvailableBaseLayers.implementation = backend
}
}
}

View file

@ -1,66 +1,77 @@
import BaseLayer from "../../Models/BaseLayer";
import {Store, Stores} from "../UIEventSource";
import Loc from "../../Models/Loc";
import {GeoOperations} from "../GeoOperations";
import * as editorlayerindex from "../../assets/editor-layer-index.json";
import * as L from "leaflet";
import {TileLayer} from "leaflet";
import * as X from "leaflet-providers";
import {Utils} from "../../Utils";
import {AvailableBaseLayersObj} from "./AvailableBaseLayers";
import {BBox} from "../BBox";
import BaseLayer from "../../Models/BaseLayer"
import { Store, Stores } from "../UIEventSource"
import Loc from "../../Models/Loc"
import { GeoOperations } from "../GeoOperations"
import * as editorlayerindex from "../../assets/editor-layer-index.json"
import * as L from "leaflet"
import { TileLayer } from "leaflet"
import * as X from "leaflet-providers"
import { Utils } from "../../Utils"
import { AvailableBaseLayersObj } from "./AvailableBaseLayers"
import { BBox } from "../BBox"
export default class AvailableBaseLayersImplementation implements AvailableBaseLayersObj {
public readonly osmCarto: BaseLayer =
{
id: "osm",
name: "OpenStreetMap",
layer: () => AvailableBaseLayersImplementation.CreateBackgroundLayer("osm", "OpenStreetMap",
"https://tile.openstreetmap.org/{z}/{x}/{y}.png", "OpenStreetMap", "https://openStreetMap.org/copyright",
public readonly osmCarto: BaseLayer = {
id: "osm",
name: "OpenStreetMap",
layer: () =>
AvailableBaseLayersImplementation.CreateBackgroundLayer(
"osm",
"OpenStreetMap",
"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"OpenStreetMap",
"https://openStreetMap.org/copyright",
19,
false, false),
feature: null,
max_zoom: 19,
min_zoom: 0,
isBest: true, // Of course, OpenStreetMap is the best map!
category: "osmbasedmap"
}
false,
false
),
feature: null,
max_zoom: 19,
min_zoom: 0,
isBest: true, // Of course, OpenStreetMap is the best map!
category: "osmbasedmap",
}
public readonly layerOverview = AvailableBaseLayersImplementation.LoadRasterIndex().concat(AvailableBaseLayersImplementation.LoadProviderIndex());
public readonly globalLayers = this.layerOverview.filter(layer => layer.feature?.geometry === undefined || layer.feature?.geometry === null)
public readonly localLayers = this.layerOverview.filter(layer => layer.feature?.geometry !== undefined && layer.feature?.geometry !== null)
public readonly layerOverview = AvailableBaseLayersImplementation.LoadRasterIndex().concat(
AvailableBaseLayersImplementation.LoadProviderIndex()
)
public readonly globalLayers = this.layerOverview.filter(
(layer) => layer.feature?.geometry === undefined || layer.feature?.geometry === null
)
public readonly localLayers = this.layerOverview.filter(
(layer) => layer.feature?.geometry !== undefined && layer.feature?.geometry !== null
)
private static LoadRasterIndex(): BaseLayer[] {
const layers: BaseLayer[] = []
// @ts-ignore
const features = editorlayerindex.features;
const features = editorlayerindex.features
for (const i in features) {
const layer = features[i];
const props = layer.properties;
const layer = features[i]
const props = layer.properties
if (props.type === "bing") {
// A lot of work to implement - see https://github.com/pietervdvn/MapComplete/issues/648
continue;
continue
}
if (props.id === "MAPNIK") {
// Already added by default
continue;
continue
}
if (props.overlay) {
continue;
continue
}
if (props.url.toLowerCase().indexOf("apikey") > 0) {
continue;
continue
}
if (props.max_zoom < 19) {
// We want users to zoom to level 19 when adding a point
// If they are on a layer which hasn't enough precision, they can not zoom far enough. This is confusing, so we don't use this layer
continue;
continue
}
if (props.name === undefined) {
@ -68,17 +79,17 @@ export default class AvailableBaseLayersImplementation implements AvailableBaseL
continue
}
const leafletLayer: () => TileLayer = () => AvailableBaseLayersImplementation.CreateBackgroundLayer(
props.id,
props.name,
props.url,
props.name,
props.license_url,
props.max_zoom,
props.type.toLowerCase() === "wms",
props.type.toLowerCase() === "wmts"
)
const leafletLayer: () => TileLayer = () =>
AvailableBaseLayersImplementation.CreateBackgroundLayer(
props.id,
props.name,
props.url,
props.name,
props.license_url,
props.max_zoom,
props.type.toLowerCase() === "wms",
props.type.toLowerCase() === "wmts"
)
// Note: if layer.geometry is null, there is global coverage for this layer
layers.push({
@ -89,34 +100,35 @@ export default class AvailableBaseLayersImplementation implements AvailableBaseL
layer: leafletLayer,
feature: layer.geometry !== null ? layer : null,
isBest: props.best ?? false,
category: props.category
});
category: props.category,
})
}
return layers;
return layers
}
private static LoadProviderIndex(): BaseLayer[] {
// @ts-ignore
X; // Import X to make sure the namespace is not optimized away
X // Import X to make sure the namespace is not optimized away
function l(id: string, name: string): BaseLayer {
try {
const layer: any = L.tileLayer.provider(id, undefined);
const layer: any = L.tileLayer.provider(id, undefined)
return {
feature: null,
id: id,
name: name,
layer: () => L.tileLayer.provider(id, {
maxNativeZoom: layer.options?.maxZoom,
maxZoom: Math.max(layer.options?.maxZoom ?? 19, 21)
}),
layer: () =>
L.tileLayer.provider(id, {
maxNativeZoom: layer.options?.maxZoom,
maxZoom: Math.max(layer.options?.maxZoom ?? 19, 21),
}),
min_zoom: 1,
max_zoom: layer.options.maxZoom,
category: "osmbasedmap",
isBest: false
isBest: false,
}
} catch (e) {
console.error("Could not find provided layer", name, e);
return null;
console.error("Could not find provided layer", name, e)
return null
}
}
@ -129,38 +141,50 @@ export default class AvailableBaseLayersImplementation implements AvailableBaseL
l("CartoDB.PositronNoLabels", "Positron - no labels (by CartoDB)"),
l("CartoDB.Voyager", "Voyager (by CartoDB)"),
l("CartoDB.VoyagerNoLabels", "Voyager - no labels (by CartoDB)"),
];
return Utils.NoNull(layers);
]
return Utils.NoNull(layers)
}
/**
* Converts a layer from the editor-layer-index into a tilelayer usable by leaflet
*/
private static CreateBackgroundLayer(id: string, name: string, url: string, attribution: string, attributionUrl: string,
maxZoom: number, isWms: boolean, isWMTS?: boolean): TileLayer {
url = url.replace("{zoom}", "{z}")
.replace("&BBOX={bbox}", "")
.replace("&bbox={bbox}", "");
private static CreateBackgroundLayer(
id: string,
name: string,
url: string,
attribution: string,
attributionUrl: string,
maxZoom: number,
isWms: boolean,
isWMTS?: boolean
): TileLayer {
url = url.replace("{zoom}", "{z}").replace("&BBOX={bbox}", "").replace("&bbox={bbox}", "")
const subdomainsMatch = url.match(/{switch:[^}]*}/)
let domains: string[] = [];
let domains: string[] = []
if (subdomainsMatch !== null) {
let domainsStr = subdomainsMatch[0].substr("{switch:".length);
domainsStr = domainsStr.substr(0, domainsStr.length - 1);
domains = domainsStr.split(",");
let domainsStr = subdomainsMatch[0].substr("{switch:".length)
domainsStr = domainsStr.substr(0, domainsStr.length - 1)
domains = domainsStr.split(",")
url = url.replace(/{switch:[^}]*}/, "{s}")
}
if (isWms) {
url = url.replace("&SRS={proj}", "");
url = url.replace("&srs={proj}", "");
const paramaters = ["format", "layers", "version", "service", "request", "styles", "transparent", "version"];
const urlObj = new URL(url);
url = url.replace("&SRS={proj}", "")
url = url.replace("&srs={proj}", "")
const paramaters = [
"format",
"layers",
"version",
"service",
"request",
"styles",
"transparent",
"version",
]
const urlObj = new URL(url)
const isUpper = urlObj.searchParams["LAYERS"] !== null;
const isUpper = urlObj.searchParams["LAYERS"] !== null
const options = {
maxZoom: Math.max(maxZoom ?? 19, 21),
maxNativeZoom: maxZoom ?? 19,
@ -168,116 +192,117 @@ export default class AvailableBaseLayersImplementation implements AvailableBaseL
subdomains: domains,
uppercase: isUpper,
transparent: false,
};
}
for (const paramater of paramaters) {
let p = paramater;
let p = paramater
if (isUpper) {
p = paramater.toUpperCase();
p = paramater.toUpperCase()
}
options[paramater] = urlObj.searchParams.get(p);
options[paramater] = urlObj.searchParams.get(p)
}
if (options.transparent === null) {
options.transparent = false;
options.transparent = false
}
return L.tileLayer.wms(urlObj.protocol + "//" + urlObj.host + urlObj.pathname, options);
return L.tileLayer.wms(urlObj.protocol + "//" + urlObj.host + urlObj.pathname, options)
}
if (attributionUrl) {
attribution = `<a href='${attributionUrl}' target='_blank'>${attribution}</a>`;
attribution = `<a href='${attributionUrl}' target='_blank'>${attribution}</a>`
}
return L.tileLayer(url,
{
attribution: attribution,
maxZoom: Math.max(21, maxZoom ?? 19),
maxNativeZoom: maxZoom ?? 19,
minZoom: 1,
// @ts-ignore
wmts: isWMTS ?? false,
subdomains: domains
});
return L.tileLayer(url, {
attribution: attribution,
maxZoom: Math.max(21, maxZoom ?? 19),
maxNativeZoom: maxZoom ?? 19,
minZoom: 1,
// @ts-ignore
wmts: isWMTS ?? false,
subdomains: domains,
})
}
public AvailableLayersAt(location: Store<Loc>): Store<BaseLayer[]> {
return Stores.ListStabilized(location.map(
(currentLocation) => {
return Stores.ListStabilized(
location.map((currentLocation) => {
if (currentLocation === undefined) {
return this.layerOverview;
return this.layerOverview
}
return this.CalculateAvailableLayersAt(currentLocation?.lon, currentLocation?.lat);
}));
return this.CalculateAvailableLayersAt(currentLocation?.lon, currentLocation?.lat)
})
)
}
public SelectBestLayerAccordingTo(location: Store<Loc>, preferedCategory: Store<string | string[]>): Store<BaseLayer> {
return this.AvailableLayersAt(location)
.map(available => {
public SelectBestLayerAccordingTo(
location: Store<Loc>,
preferedCategory: Store<string | string[]>
): Store<BaseLayer> {
return this.AvailableLayersAt(location).map(
(available) => {
// First float all 'best layers' to the top
available.sort((a, b) => {
if (a.isBest && b.isBest) {
return 0;
}
if (!a.isBest) {
return 1
}
return -1;
if (a.isBest && b.isBest) {
return 0
}
)
if (!a.isBest) {
return 1
}
return -1
})
if (preferedCategory.data === undefined) {
return available[0]
}
let prefered: string []
let prefered: string[]
if (typeof preferedCategory.data === "string") {
prefered = [preferedCategory.data]
} else {
prefered = preferedCategory.data;
prefered = preferedCategory.data
}
prefered.reverse(/*New list, inplace reverse is fine*/);
prefered.reverse(/*New list, inplace reverse is fine*/)
for (const category of prefered) {
//Then sort all 'photo'-layers to the top. Stability of the sorting will force a 'best' photo layer on top
available.sort((a, b) => {
if (a.category === category && b.category === category) {
return 0;
}
if (a.category !== category) {
return 1
}
return -1;
if (a.category === category && b.category === category) {
return 0
}
)
if (a.category !== category) {
return 1
}
return -1
})
}
return available[0]
}, [preferedCategory])
},
[preferedCategory]
)
}
private CalculateAvailableLayersAt(lon: number, lat: number): BaseLayer[] {
const availableLayers = [this.osmCarto]
if (lon === undefined || lat === undefined) {
return availableLayers.concat(this.globalLayers);
return availableLayers.concat(this.globalLayers)
}
const lonlat : [number, number] = [lon, lat];
const lonlat: [number, number] = [lon, lat]
for (const layerOverviewItem of this.localLayers) {
const layer = layerOverviewItem;
const layer = layerOverviewItem
const bbox = BBox.get(layer.feature)
if(!bbox.contains(lonlat)){
if (!bbox.contains(lonlat)) {
continue
}
if (GeoOperations.inside(lonlat, layer.feature)) {
availableLayers.push(layer);
availableLayers.push(layer)
}
}
return availableLayers.concat(this.globalLayers);
return availableLayers.concat(this.globalLayers)
}
}
}

View file

@ -1,50 +1,49 @@
import {UIEventSource} from "../UIEventSource";
import BaseLayer from "../../Models/BaseLayer";
import AvailableBaseLayers from "./AvailableBaseLayers";
import Loc from "../../Models/Loc";
import {Utils} from "../../Utils";
import { UIEventSource } from "../UIEventSource"
import BaseLayer from "../../Models/BaseLayer"
import AvailableBaseLayers from "./AvailableBaseLayers"
import Loc from "../../Models/Loc"
import { Utils } from "../../Utils"
/**
* Sets the current background layer to a layer that is actually available
*/
export default class BackgroundLayerResetter {
constructor(currentBackgroundLayer: UIEventSource<BaseLayer>,
location: UIEventSource<Loc>,
availableLayers: UIEventSource<BaseLayer[]>,
defaultLayerId: string = undefined) {
constructor(
currentBackgroundLayer: UIEventSource<BaseLayer>,
location: UIEventSource<Loc>,
availableLayers: UIEventSource<BaseLayer[]>,
defaultLayerId: string = undefined
) {
if (Utils.runningFromConsole) {
return
}
defaultLayerId = defaultLayerId ?? AvailableBaseLayers.osmCarto.id;
defaultLayerId = defaultLayerId ?? AvailableBaseLayers.osmCarto.id
// Change the baselayer back to OSM if we go out of the current range of the layer
availableLayers.addCallbackAndRun(availableLayers => {
let defaultLayer = undefined;
const currentLayer = currentBackgroundLayer.data.id;
availableLayers.addCallbackAndRun((availableLayers) => {
let defaultLayer = undefined
const currentLayer = currentBackgroundLayer.data.id
for (const availableLayer of availableLayers) {
if (availableLayer.id === currentLayer) {
if (availableLayer.max_zoom < location.data.zoom) {
break;
break
}
if (availableLayer.min_zoom > location.data.zoom) {
break;
break
}
if (availableLayer.id === defaultLayerId) {
defaultLayer = availableLayer;
defaultLayer = availableLayer
}
return; // All good - the current layer still works!
return // All good - the current layer still works!
}
}
// Oops, we panned out of range for this layer!
console.log("AvailableBaseLayers-actor: detected that the current bounds aren't sufficient anymore - reverting to OSM standard")
currentBackgroundLayer.setData(defaultLayer ?? AvailableBaseLayers.osmCarto);
});
console.log(
"AvailableBaseLayers-actor: detected that the current bounds aren't sufficient anymore - reverting to OSM standard"
)
currentBackgroundLayer.setData(defaultLayer ?? AvailableBaseLayers.osmCarto)
})
}
}
}

View file

@ -1,36 +1,34 @@
import {ElementStorage} from "../ElementStorage";
import {Changes} from "../Osm/Changes";
import { ElementStorage } from "../ElementStorage"
import { Changes } from "../Osm/Changes"
export default class ChangeToElementsActor {
constructor(changes: Changes, allElements: ElementStorage) {
changes.pendingChanges.addCallbackAndRun(changes => {
changes.pendingChanges.addCallbackAndRun((changes) => {
for (const change of changes) {
const id = change.type + "/" + change.id;
const id = change.type + "/" + change.id
if (!allElements.has(id)) {
continue; // Ignored as the geometryFixer will introduce this
continue // Ignored as the geometryFixer will introduce this
}
const src = allElements.getEventSourceById(id)
let changed = false;
let changed = false
for (const kv of change.tags ?? []) {
// Apply tag changes and ping the consumers
const k = kv.k
let v = kv.v
if (v === "") {
v = undefined;
v = undefined
}
if (src.data[k] === v) {
continue
}
changed = true;
src.data[k] = v;
changed = true
src.data[k] = v
}
if (changed) {
src.ping()
}
}
})
}
}
}

View file

@ -1,60 +1,59 @@
import {Store, UIEventSource} from "../UIEventSource";
import Svg from "../../Svg";
import {LocalStorageSource} from "../Web/LocalStorageSource";
import {VariableUiElement} from "../../UI/Base/VariableUIElement";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {QueryParameters} from "../Web/QueryParameters";
import {BBox} from "../BBox";
import Constants from "../../Models/Constants";
import SimpleFeatureSource from "../FeatureSource/Sources/SimpleFeatureSource";
import { Store, UIEventSource } from "../UIEventSource"
import Svg from "../../Svg"
import { LocalStorageSource } from "../Web/LocalStorageSource"
import { VariableUiElement } from "../../UI/Base/VariableUIElement"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { QueryParameters } from "../Web/QueryParameters"
import { BBox } from "../BBox"
import Constants from "../../Models/Constants"
import SimpleFeatureSource from "../FeatureSource/Sources/SimpleFeatureSource"
export interface GeoLocationPointProperties {
id: "gps",
"user:location": "yes",
"date": string,
"latitude": number
"longitude": number,
"speed": number,
"accuracy": number
"heading": number
"altitude": number
export interface GeoLocationPointProperties {
id: "gps"
"user:location": "yes"
date: string
latitude: number
longitude: number
speed: number
accuracy: number
heading: number
altitude: number
}
export default class GeoLocationHandler extends VariableUiElement {
private readonly currentLocation?: SimpleFeatureSource
/**
* Wether or not the geolocation is active, aka the user requested the current location
*/
private readonly _isActive: UIEventSource<boolean>;
private readonly _isActive: UIEventSource<boolean>
/**
* Wether or not the geolocation is locked, aka the user requested the current location and wants the crosshair to follow the user
*/
private readonly _isLocked: UIEventSource<boolean>;
private readonly _isLocked: UIEventSource<boolean>
/**
* The callback over the permission API
* @private
*/
private readonly _permission: UIEventSource<string>;
private readonly _permission: UIEventSource<string>
/**
* Literally: _currentGPSLocation.data != undefined
* @private
*/
private readonly _hasLocation: Store<boolean>;
private readonly _currentGPSLocation: UIEventSource<Coordinates>;
private readonly _hasLocation: Store<boolean>
private readonly _currentGPSLocation: UIEventSource<GeolocationCoordinates>
/**
* Kept in order to update the marker
* @private
*/
private readonly _leafletMap: UIEventSource<L.Map>;
private readonly _leafletMap: UIEventSource<L.Map>
/**
* The date when the user requested the geolocation. If we have a location, it'll autozoom to it the first 30 secs
*/
private _lastUserRequest: UIEventSource<Date>;
private _lastUserRequest: UIEventSource<Date>
/**
* A small flag on localstorage. If the user previously granted the geolocation, it will be set.
@ -64,54 +63,52 @@ export default class GeoLocationHandler extends VariableUiElement {
* If the user denies the geolocation this time, we unset this flag
* @private
*/
private readonly _previousLocationGrant: UIEventSource<string>;
private readonly _layoutToUse: LayoutConfig;
private readonly _previousLocationGrant: UIEventSource<string>
private readonly _layoutToUse: LayoutConfig
constructor(
state: {
selectedElement: UIEventSource<any>;
currentUserLocation?: SimpleFeatureSource,
leafletMap: UIEventSource<any>,
layoutToUse: LayoutConfig,
featureSwitchGeolocation: UIEventSource<boolean>
}
) {
const currentGPSLocation = new UIEventSource<Coordinates>(undefined, "GPS-coordinate")
constructor(state: {
selectedElement: UIEventSource<any>
currentUserLocation?: SimpleFeatureSource
leafletMap: UIEventSource<any>
layoutToUse: LayoutConfig
featureSwitchGeolocation: UIEventSource<boolean>
}) {
const currentGPSLocation = new UIEventSource<GeolocationCoordinates>(
undefined,
"GPS-coordinate"
)
const leafletMap = state.leafletMap
const initedAt = new Date()
let autozoomDone = false;
const hasLocation = currentGPSLocation.map(
(location) => location !== undefined
);
const previousLocationGrant = LocalStorageSource.Get(
"geolocation-permissions"
);
const isActive = new UIEventSource<boolean>(false);
const isLocked = new UIEventSource<boolean>(false);
const permission = new UIEventSource<string>("");
const lastClick = new UIEventSource<Date>(undefined);
const lastClickWithinThreeSecs = lastClick.map(lastClick => {
let autozoomDone = false
const hasLocation = currentGPSLocation.map((location) => location !== undefined)
const previousLocationGrant = LocalStorageSource.Get("geolocation-permissions")
const isActive = new UIEventSource<boolean>(false)
const isLocked = new UIEventSource<boolean>(false)
const permission = new UIEventSource<string>("")
const lastClick = new UIEventSource<Date>(undefined)
const lastClickWithinThreeSecs = lastClick.map((lastClick) => {
if (lastClick === undefined) {
return false;
return false
}
const timeDiff = (new Date().getTime() - lastClick.getTime()) / 1000
return timeDiff <= 3
})
const latLonGiven = QueryParameters.wasInitialized("lat") && QueryParameters.wasInitialized("lon")
const willFocus = lastClick.map(lastUserRequest => {
const latLonGiven =
QueryParameters.wasInitialized("lat") && QueryParameters.wasInitialized("lon")
const willFocus = lastClick.map((lastUserRequest) => {
const timeDiffInited = (new Date().getTime() - initedAt.getTime()) / 1000
if (!latLonGiven && !autozoomDone && timeDiffInited < Constants.zoomToLocationTimeout) {
return true
}
if (lastUserRequest === undefined) {
return false;
return false
}
const timeDiff = (new Date().getTime() - lastUserRequest.getTime()) / 1000
return timeDiff <= Constants.zoomToLocationTimeout
})
lastClick.addCallbackAndRunD(_ => {
lastClick.addCallbackAndRunD((_) => {
window.setTimeout(() => {
if (lastClickWithinThreeSecs.data || willFocus.data) {
lastClick.ping()
@ -123,7 +120,7 @@ export default class GeoLocationHandler extends VariableUiElement {
hasLocation.map(
(hasLocationData) => {
if (permission.data === "denied") {
return Svg.location_refused_svg();
return Svg.location_refused_svg()
}
if (!isActive.data) {
@ -134,7 +131,7 @@ export default class GeoLocationHandler extends VariableUiElement {
// If will focus is active too, we indicate this differently
const icon = willFocus.data ? Svg.location_svg() : Svg.location_empty_svg()
icon.SetStyle("animation: spin 4s linear infinite;")
return icon;
return icon
}
if (isLocked.data) {
return Svg.location_locked_svg()
@ -144,42 +141,41 @@ export default class GeoLocationHandler extends VariableUiElement {
}
// We have a location, so we show a dot in the center
return Svg.location_svg();
return Svg.location_svg()
},
[isActive, isLocked, permission, lastClickWithinThreeSecs, willFocus]
)
);
)
this.SetClass("mapcontrol")
this._isActive = isActive;
this._isLocked = isLocked;
this._isActive = isActive
this._isLocked = isLocked
this._permission = permission
this._previousLocationGrant = previousLocationGrant;
this._currentGPSLocation = currentGPSLocation;
this._leafletMap = leafletMap;
this._layoutToUse = state.layoutToUse;
this._hasLocation = hasLocation;
this._previousLocationGrant = previousLocationGrant
this._currentGPSLocation = currentGPSLocation
this._leafletMap = leafletMap
this._layoutToUse = state.layoutToUse
this._hasLocation = hasLocation
this._lastUserRequest = lastClick
const self = this;
const self = this
const currentPointer = this._isActive.map(
(isActive) => {
if (isActive && !self._hasLocation.data) {
return "cursor-wait";
return "cursor-wait"
}
return "cursor-pointer";
return "cursor-pointer"
},
[this._hasLocation]
);
)
currentPointer.addCallbackAndRun((pointerClass) => {
self.RemoveClass("cursor-wait")
self.RemoveClass("cursor-pointer")
self.SetClass(pointerClass);
});
self.SetClass(pointerClass)
})
this.onClick(() => {
/*
* If the previous click was within 3 seconds (and we have an active location), then we lock to the location
* If the previous click was within 3 seconds (and we have an active location), then we lock to the location
*/
if (self._hasLocation.data) {
if (isLocked.data) {
@ -197,14 +193,16 @@ export default class GeoLocationHandler extends VariableUiElement {
}
}
self.init(true, true);
});
self.init(true, true)
})
const doAutoZoomToLocation =
!latLonGiven &&
state.featureSwitchGeolocation.data &&
state.selectedElement.data !== undefined
this.init(false, doAutoZoomToLocation)
const doAutoZoomToLocation = !latLonGiven && state.featureSwitchGeolocation.data && state.selectedElement.data !== undefined
this.init(false, doAutoZoomToLocation);
isLocked.addCallbackAndRunD(isLocked => {
isLocked.addCallbackAndRunD((isLocked) => {
if (isLocked) {
leafletMap.data?.dragging?.disable()
} else {
@ -214,47 +212,45 @@ export default class GeoLocationHandler extends VariableUiElement {
this.currentLocation = state.currentUserLocation
this._currentGPSLocation.addCallback((location) => {
self._previousLocationGrant.setData("granted");
self._previousLocationGrant.setData("granted")
const feature = {
"type": "Feature",
type: "Feature",
properties: <GeoLocationPointProperties>{
id: "gps",
"user:location": "yes",
"date": new Date().toISOString(),
"latitude": location.latitude,
"longitude": location.longitude,
"speed": location.speed,
"accuracy": location.accuracy,
"heading": location.heading,
"altitude": location.altitude
date: new Date().toISOString(),
latitude: location.latitude,
longitude: location.longitude,
speed: location.speed,
accuracy: location.accuracy,
heading: location.heading,
altitude: location.altitude,
},
geometry: {
type: "Point",
coordinates: [location.longitude, location.latitude],
}
},
}
self.currentLocation?.features?.setData([{feature, freshness: new Date()}])
self.currentLocation?.features?.setData([{ feature, freshness: new Date() }])
if (willFocus.data) {
console.log("Zooming to user location: willFocus is set")
lastClick.setData(undefined);
autozoomDone = true;
self.MoveToCurrentLocation(16);
lastClick.setData(undefined)
autozoomDone = true
self.MoveToCurrentLocation(16)
} else if (self._isLocked.data) {
self.MoveToCurrentLocation();
self.MoveToCurrentLocation()
}
});
})
}
private init(askPermission: boolean, zoomToLocation: boolean) {
const self = this;
const self = this
if (self._isActive.data) {
self.MoveToCurrentLocation(16);
return;
self.MoveToCurrentLocation(16)
return
}
if (typeof navigator === "undefined") {
@ -262,27 +258,25 @@ export default class GeoLocationHandler extends VariableUiElement {
}
try {
navigator?.permissions
?.query({name: "geolocation"})
?.then(function (status) {
console.log("Geolocation permission is ", status.state);
if (status.state === "granted") {
self.StartGeolocating(zoomToLocation);
}
self._permission.setData(status.state);
status.onchange = function () {
self._permission.setData(status.state);
};
});
navigator?.permissions?.query({ name: "geolocation" })?.then(function (status) {
console.log("Geolocation permission is ", status.state)
if (status.state === "granted") {
self.StartGeolocating(zoomToLocation)
}
self._permission.setData(status.state)
status.onchange = function () {
self._permission.setData(status.state)
}
})
} catch (e) {
console.error(e);
console.error(e)
}
if (askPermission) {
self.StartGeolocating(zoomToLocation);
self.StartGeolocating(zoomToLocation)
} else if (this._previousLocationGrant.data === "granted") {
this._previousLocationGrant.setData("");
self.StartGeolocating(zoomToLocation);
this._previousLocationGrant.setData("")
self.StartGeolocating(zoomToLocation)
}
}
@ -311,7 +305,7 @@ export default class GeoLocationHandler extends VariableUiElement {
* handler._currentGPSLocation.setData(<any> {latitude : 60, longitude: 60) // out of bounds
* handler.MoveToCurrentLocation()
* resultingLocation // => [60, 60]
*
*
* // should refuse to move if out of bounds
* let resultingLocation = undefined
* let resultingzoom = 1
@ -322,7 +316,7 @@ export default class GeoLocationHandler extends VariableUiElement {
* layoutToUse: new LayoutConfig(<any>{
* id: 'test',
* title: {"en":"test"}
* "lockLocation": [ [ 2.1, 50.4], [6.4, 51.54 ]],
* "lockLocation": [ [ 2.1, 50.4], [6.4, 51.54 ]],
* description: "A testing theme",
* layers: []
* }),
@ -337,20 +331,20 @@ export default class GeoLocationHandler extends VariableUiElement {
* resultingLocation // => [51.3, 4.1]
*/
private MoveToCurrentLocation(targetZoom?: number) {
const location = this._currentGPSLocation.data;
this._lastUserRequest.setData(undefined);
const location = this._currentGPSLocation.data
this._lastUserRequest.setData(undefined)
if (
this._currentGPSLocation.data.latitude === 0 &&
this._currentGPSLocation.data.longitude === 0
) {
console.debug("Not moving to GPS-location: it is null island");
return;
console.debug("Not moving to GPS-location: it is null island")
return
}
// We check that the GPS location is not out of bounds
const b = this._layoutToUse.lockLocation;
let inRange = true;
const b = this._layoutToUse.lockLocation
let inRange = true
if (b) {
if (b !== true) {
// B is an array with our locklocation
@ -358,41 +352,44 @@ export default class GeoLocationHandler extends VariableUiElement {
}
}
if (!inRange) {
console.log("Not zooming to GPS location: out of bounds", b, location);
console.log("Not zooming to GPS location: out of bounds", b, location)
} else {
const currentZoom = this._leafletMap.data.getZoom()
this._leafletMap.data.setView([location.latitude, location.longitude], Math.max(targetZoom ?? 0, currentZoom));
this._leafletMap.data.setView(
[location.latitude, location.longitude],
Math.max(targetZoom ?? 0, currentZoom)
)
}
}
private StartGeolocating(zoomToGPS = true) {
const self = this;
const self = this
this._lastUserRequest.setData(zoomToGPS ? new Date() : new Date(0))
if (self._permission.data === "denied") {
self._previousLocationGrant.setData("");
self._previousLocationGrant.setData("")
self._isActive.setData(false)
return "";
return ""
}
if (this._currentGPSLocation.data !== undefined) {
this.MoveToCurrentLocation(16);
this.MoveToCurrentLocation(16)
}
if (self._isActive.data) {
return;
return
}
self._isActive.setData(true);
self._isActive.setData(true)
navigator.geolocation.watchPosition(
function (position) {
self._currentGPSLocation.setData(position.coords);
self._currentGPSLocation.setData(position.coords)
},
function () {
console.warn("Could not get location with navigator.geolocation");
console.warn("Could not get location with navigator.geolocation")
},
{
enableHighAccuracy: true
enableHighAccuracy: true,
}
);
)
}
}

View file

@ -1,112 +1,124 @@
import {Store, UIEventSource} from "../UIEventSource";
import {Or} from "../Tags/Or";
import {Overpass} from "../Osm/Overpass";
import FeatureSource from "../FeatureSource/FeatureSource";
import {Utils} from "../../Utils";
import {TagsFilter} from "../Tags/TagsFilter";
import SimpleMetaTagger from "../SimpleMetaTagger";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import RelationsTracker from "../Osm/RelationsTracker";
import {BBox} from "../BBox";
import Loc from "../../Models/Loc";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import Constants from "../../Models/Constants";
import TileFreshnessCalculator from "../FeatureSource/TileFreshnessCalculator";
import {Tiles} from "../../Models/TileRange";
import { Store, UIEventSource } from "../UIEventSource"
import { Or } from "../Tags/Or"
import { Overpass } from "../Osm/Overpass"
import FeatureSource from "../FeatureSource/FeatureSource"
import { Utils } from "../../Utils"
import { TagsFilter } from "../Tags/TagsFilter"
import SimpleMetaTagger from "../SimpleMetaTagger"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import RelationsTracker from "../Osm/RelationsTracker"
import { BBox } from "../BBox"
import Loc from "../../Models/Loc"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import Constants from "../../Models/Constants"
import TileFreshnessCalculator from "../FeatureSource/TileFreshnessCalculator"
import { Tiles } from "../../Models/TileRange"
export default class OverpassFeatureSource implements FeatureSource {
public readonly name = "OverpassFeatureSource"
/**
* The last loaded features of the geojson
*/
public readonly features: UIEventSource<{ feature: any, freshness: Date }[]> = new UIEventSource<any[]>(undefined);
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]> =
new UIEventSource<any[]>(undefined)
public readonly runningQuery: UIEventSource<boolean> = new UIEventSource<boolean>(false)
public readonly timeout: UIEventSource<number> = new UIEventSource<number>(0)
public readonly runningQuery: UIEventSource<boolean> = new UIEventSource<boolean>(false);
public readonly timeout: UIEventSource<number> = new UIEventSource<number>(0);
public readonly relationsTracker: RelationsTracker
public readonly relationsTracker: RelationsTracker;
private readonly retries: UIEventSource<number> = new UIEventSource<number>(0);
private readonly retries: UIEventSource<number> = new UIEventSource<number>(0)
private readonly state: {
readonly locationControl: Store<Loc>,
readonly layoutToUse: LayoutConfig,
readonly overpassUrl: Store<string[]>;
readonly overpassTimeout: Store<number>;
readonly locationControl: Store<Loc>
readonly layoutToUse: LayoutConfig
readonly overpassUrl: Store<string[]>
readonly overpassTimeout: Store<number>
readonly currentBounds: Store<BBox>
}
private readonly _isActive: Store<boolean>
/**
* Callback to handle all the data
*/
private readonly onBboxLoaded: (bbox: BBox, date: Date, layers: LayerConfig[], zoomlevel: number) => void;
private readonly onBboxLoaded: (
bbox: BBox,
date: Date,
layers: LayerConfig[],
zoomlevel: number
) => void
/**
* Keeps track of how fresh the data is
* @private
*/
private readonly freshnesses: Map<string, TileFreshnessCalculator>;
private readonly freshnesses: Map<string, TileFreshnessCalculator>
constructor(
state: {
readonly locationControl: Store<Loc>,
readonly layoutToUse: LayoutConfig,
readonly overpassUrl: Store<string[]>;
readonly overpassTimeout: Store<number>;
readonly overpassMaxZoom: Store<number>,
readonly locationControl: Store<Loc>
readonly layoutToUse: LayoutConfig
readonly overpassUrl: Store<string[]>
readonly overpassTimeout: Store<number>
readonly overpassMaxZoom: Store<number>
readonly currentBounds: Store<BBox>
},
options: {
padToTiles: Store<number>,
isActive?: Store<boolean>,
relationTracker: RelationsTracker,
onBboxLoaded?: (bbox: BBox, date: Date, layers: LayerConfig[], zoomlevel: number) => void,
padToTiles: Store<number>
isActive?: Store<boolean>
relationTracker: RelationsTracker
onBboxLoaded?: (
bbox: BBox,
date: Date,
layers: LayerConfig[],
zoomlevel: number
) => void
freshnesses?: Map<string, TileFreshnessCalculator>
}) {
}
) {
this.state = state
this._isActive = options.isActive;
this._isActive = options.isActive
this.onBboxLoaded = options.onBboxLoaded
this.relationsTracker = options.relationTracker
this.freshnesses = options.freshnesses
const self = this;
state.currentBounds.addCallback(_ => {
const self = this
state.currentBounds.addCallback((_) => {
self.update(options.padToTiles.data)
})
}
private GetFilter(interpreterUrl: string, layersToDownload: LayerConfig[]): Overpass {
let filters: TagsFilter[] = [];
let extraScripts: string[] = [];
let filters: TagsFilter[] = []
let extraScripts: string[] = []
for (const layer of layersToDownload) {
if (layer.source.overpassScript !== undefined) {
extraScripts.push(layer.source.overpassScript)
} else {
filters.push(layer.source.osmTags);
filters.push(layer.source.osmTags)
}
}
filters = Utils.NoNull(filters)
extraScripts = Utils.NoNull(extraScripts)
if (filters.length + extraScripts.length === 0) {
return undefined;
return undefined
}
return new Overpass(new Or(filters), extraScripts, interpreterUrl, this.state.overpassTimeout, this.relationsTracker);
return new Overpass(
new Or(filters),
extraScripts,
interpreterUrl,
this.state.overpassTimeout,
this.relationsTracker
)
}
private update(paddedZoomLevel: number) {
if (!this._isActive.data) {
return;
return
}
const self = this;
this.updateAsync(paddedZoomLevel).then(bboxDate => {
const self = this
this.updateAsync(paddedZoomLevel).then((bboxDate) => {
if (bboxDate === undefined || self.onBboxLoaded === undefined) {
return;
return
}
const [bbox, date, layers] = bboxDate
self.onBboxLoaded(bbox, date, layers, paddedZoomLevel)
@ -115,56 +127,58 @@ export default class OverpassFeatureSource implements FeatureSource {
private async updateAsync(padToZoomLevel: number): Promise<[BBox, Date, LayerConfig[]]> {
if (this.runningQuery.data) {
console.log("Still running a query, not updating");
return undefined;
console.log("Still running a query, not updating")
return undefined
}
if (this.timeout.data > 0) {
console.log("Still in timeout - not updating")
return undefined;
return undefined
}
let data: any = undefined
let date: Date = undefined
let lastUsed = 0;
let lastUsed = 0
const layersToDownload = []
const neededTiles = this.state.currentBounds.data.expandToTileBounds(padToZoomLevel).containingTileRange(padToZoomLevel)
const neededTiles = this.state.currentBounds.data
.expandToTileBounds(padToZoomLevel)
.containingTileRange(padToZoomLevel)
for (const layer of this.state.layoutToUse.layers) {
if (typeof (layer) === "string") {
if (typeof layer === "string") {
throw "A layer was not expanded!"
}
if (Constants.priviliged_layers.indexOf(layer.id) >= 0) {
continue
}
if (this.state.locationControl.data.zoom < layer.minzoom) {
continue;
continue
}
if (layer.doNotDownload) {
continue;
continue
}
if (layer.source.geojsonSource !== undefined) {
// Not our responsibility to download this layer!
continue;
continue
}
const freshness = this.freshnesses?.get(layer.id)
if (freshness !== undefined) {
const oldestDataDate = Math.min(...Tiles.MapRange(neededTiles, (x, y) => {
const date = freshness.freshnessFor(padToZoomLevel, x, y);
if (date === undefined) {
return 0
}
return date.getTime()
})) / 1000;
const oldestDataDate =
Math.min(
...Tiles.MapRange(neededTiles, (x, y) => {
const date = freshness.freshnessFor(padToZoomLevel, x, y)
if (date === undefined) {
return 0
}
return date.getTime()
})
) / 1000
const now = new Date().getTime()
const minRequiredAge = (now / 1000) - layer.maxAgeOfCache
const minRequiredAge = now / 1000 - layer.maxAgeOfCache
if (oldestDataDate >= minRequiredAge) {
// still fresh enough - not updating
continue
}
}
layersToDownload.push(layer)
@ -172,34 +186,35 @@ export default class OverpassFeatureSource implements FeatureSource {
if (layersToDownload.length == 0) {
console.debug("Not updating - no layers needed")
return;
return
}
const self = this;
const self = this
const overpassUrls = self.state.overpassUrl.data
let bounds: BBox
do {
try {
bounds = this.state.currentBounds.data?.pad(this.state.layoutToUse.widenFactor)?.expandToTileBounds(padToZoomLevel);
bounds = this.state.currentBounds.data
?.pad(this.state.layoutToUse.widenFactor)
?.expandToTileBounds(padToZoomLevel)
if (bounds === undefined) {
return undefined;
return undefined
}
const overpass = this.GetFilter(overpassUrls[lastUsed], layersToDownload);
const overpass = this.GetFilter(overpassUrls[lastUsed], layersToDownload)
if (overpass === undefined) {
return undefined;
return undefined
}
this.runningQuery.setData(true);
this.runningQuery.setData(true)
[data, date] = await overpass.queryGeoJson(bounds)
;[data, date] = await overpass.queryGeoJson(bounds)
console.log("Querying overpass is done", data)
} catch (e) {
self.retries.data++;
self.retries.ping();
console.error(`QUERY FAILED due to`, e);
self.retries.data++
self.retries.ping()
console.error(`QUERY FAILED due to`, e)
await Utils.waitFor(1000)
@ -208,34 +223,38 @@ export default class OverpassFeatureSource implements FeatureSource {
console.log("Trying next time with", overpassUrls[lastUsed])
} else {
lastUsed = 0
self.timeout.setData(self.retries.data * 5);
self.timeout.setData(self.retries.data * 5)
while (self.timeout.data > 0) {
await Utils.waitFor(1000)
console.log(self.timeout.data)
self.timeout.data--
self.timeout.ping();
self.timeout.ping()
}
}
}
} while (data === undefined && this._isActive.data);
} while (data === undefined && this._isActive.data)
try {
if (data === undefined) {
return undefined
}
data.features.forEach(feature => SimpleMetaTagger.objectMetaInfo.applyMetaTagsOnFeature(feature, date, undefined, this.state));
self.features.setData(data.features.map(f => ({feature: f, freshness: date})));
return [bounds, date, layersToDownload];
data.features.forEach((feature) =>
SimpleMetaTagger.objectMetaInfo.applyMetaTagsOnFeature(
feature,
date,
undefined,
this.state
)
)
self.features.setData(data.features.map((f) => ({ feature: f, freshness: date })))
return [bounds, date, layersToDownload]
} catch (e) {
console.error("Got the overpass response, but could not process it: ", e, e.stack)
return undefined
} finally {
self.retries.setData(0);
self.runningQuery.setData(false);
self.retries.setData(0)
self.runningQuery.setData(false)
}
}
}
}

View file

@ -1,46 +1,42 @@
import {Changes} from "../Osm/Changes";
import Constants from "../../Models/Constants";
import {UIEventSource} from "../UIEventSource";
import {Utils} from "../../Utils";
import { Changes } from "../Osm/Changes"
import Constants from "../../Models/Constants"
import { UIEventSource } from "../UIEventSource"
import { Utils } from "../../Utils"
export default class PendingChangesUploader {
private lastChange: Date;
private lastChange: Date
constructor(changes: Changes, selectedFeature: UIEventSource<any>) {
const self = this;
this.lastChange = new Date();
const self = this
this.lastChange = new Date()
changes.pendingChanges.addCallback(() => {
self.lastChange = new Date();
self.lastChange = new Date()
window.setTimeout(() => {
const diff = (new Date().getTime() - self.lastChange.getTime()) / 1000;
const diff = (new Date().getTime() - self.lastChange.getTime()) / 1000
if (Constants.updateTimeoutSec >= diff - 1) {
changes.flushChanges("Flushing changes due to timeout");
changes.flushChanges("Flushing changes due to timeout")
}
}, Constants.updateTimeoutSec * 1000);
});
}, Constants.updateTimeoutSec * 1000)
})
selectedFeature
.stabilized(10000)
.addCallback(feature => {
if (feature === undefined) {
// The popup got closed - we flush
changes.flushChanges("Flushing changes due to popup closed");
}
});
selectedFeature.stabilized(10000).addCallback((feature) => {
if (feature === undefined) {
// The popup got closed - we flush
changes.flushChanges("Flushing changes due to popup closed")
}
})
if (Utils.runningFromConsole) {
return;
return
}
document.addEventListener('mouseout', e => {
document.addEventListener("mouseout", (e) => {
// @ts-ignore
if (!e.toElement && !e.relatedTarget) {
changes.flushChanges("Flushing changes due to focus lost");
changes.flushChanges("Flushing changes due to focus lost")
}
});
})
document.onfocus = () => {
changes.flushChanges("OnFocus")
@ -50,28 +46,28 @@ export default class PendingChangesUploader {
changes.flushChanges("OnFocus")
}
try {
document.addEventListener("visibilitychange", () => {
changes.flushChanges("Visibility change")
}, false);
document.addEventListener(
"visibilitychange",
() => {
changes.flushChanges("Visibility change")
},
false
)
} catch (e) {
console.warn("Could not register visibility change listener", e)
}
function onunload(e) {
if (changes.pendingChanges.data.length == 0) {
return;
return
}
changes.flushChanges("onbeforeunload - probably closing or something similar");
e.preventDefault();
changes.flushChanges("onbeforeunload - probably closing or something similar")
e.preventDefault()
return "Saving your last changes..."
}
window.onbeforeunload = onunload
// https://stackoverflow.com/questions/3239834/window-onbeforeunload-not-working-on-the-ipad#4824156
window.addEventListener("pagehide", onunload)
}
}
}

View file

@ -1,51 +1,47 @@
/**
* This actor will download the latest version of the selected element from OSM and update the tags if necessary.
*/
import {UIEventSource} from "../UIEventSource";
import {ElementStorage} from "../ElementStorage";
import {Changes} from "../Osm/Changes";
import {OsmObject} from "../Osm/OsmObject";
import {OsmConnection} from "../Osm/OsmConnection";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import SimpleMetaTagger from "../SimpleMetaTagger";
import { UIEventSource } from "../UIEventSource"
import { ElementStorage } from "../ElementStorage"
import { Changes } from "../Osm/Changes"
import { OsmObject } from "../Osm/OsmObject"
import { OsmConnection } from "../Osm/OsmConnection"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import SimpleMetaTagger from "../SimpleMetaTagger"
export default class SelectedElementTagsUpdater {
private static readonly metatags = new Set(["timestamp",
private static readonly metatags = new Set([
"timestamp",
"version",
"changeset",
"user",
"uid",
"id"])
"id",
])
constructor(state: {
selectedElement: UIEventSource<any>,
allElements: ElementStorage,
changes: Changes,
osmConnection: OsmConnection,
selectedElement: UIEventSource<any>
allElements: ElementStorage
changes: Changes
osmConnection: OsmConnection
layoutToUse: LayoutConfig
}) {
state.osmConnection.isLoggedIn.addCallbackAndRun(isLoggedIn => {
state.osmConnection.isLoggedIn.addCallbackAndRun((isLoggedIn) => {
if (isLoggedIn) {
SelectedElementTagsUpdater.installCallback(state)
return true;
return true
}
})
}
public static installCallback(state: {
selectedElement: UIEventSource<any>,
allElements: ElementStorage,
changes: Changes,
osmConnection: OsmConnection,
selectedElement: UIEventSource<any>
allElements: ElementStorage
changes: Changes
osmConnection: OsmConnection
layoutToUse: LayoutConfig
}) {
state.selectedElement.addCallbackAndRunD(s => {
state.selectedElement.addCallbackAndRunD((s) => {
let id = s.properties?.id
const backendUrl = state.osmConnection._oauth_config.url
@ -55,31 +51,31 @@ export default class SelectedElementTagsUpdater {
if (!(id.startsWith("way") || id.startsWith("node") || id.startsWith("relation"))) {
// This object is _not_ from OSM, so we skip it!
return;
return
}
if (id.indexOf("-") >= 0) {
// This is a new object
return;
return
}
OsmObject.DownloadPropertiesOf(id).then(latestTags => {
OsmObject.DownloadPropertiesOf(id).then((latestTags) => {
SelectedElementTagsUpdater.applyUpdate(state, latestTags, id)
})
});
})
}
public static applyUpdate(state: {
selectedElement: UIEventSource<any>,
allElements: ElementStorage,
changes: Changes,
osmConnection: OsmConnection,
layoutToUse: LayoutConfig
}, latestTags: any, id: string
public static applyUpdate(
state: {
selectedElement: UIEventSource<any>
allElements: ElementStorage
changes: Changes
osmConnection: OsmConnection
layoutToUse: LayoutConfig
},
latestTags: any,
id: string
) {
try {
const leftRightSensitive = state.layoutToUse.isLeftRightSensitive()
if (leftRightSensitive) {
@ -87,11 +83,11 @@ export default class SelectedElementTagsUpdater {
}
const pendingChanges = state.changes.pendingChanges.data
.filter(change => change.type + "/" + change.id === id)
.filter(change => change.tags !== undefined);
.filter((change) => change.type + "/" + change.id === id)
.filter((change) => change.tags !== undefined)
for (const pendingChange of pendingChanges) {
const tagChanges = pendingChange.tags;
const tagChanges = pendingChange.tags
for (const tagChange of tagChanges) {
const key = tagChange.k
const v = tagChange.v
@ -103,10 +99,9 @@ export default class SelectedElementTagsUpdater {
}
}
// With the changes applied, we merge them onto the upstream object
let somethingChanged = false;
const currentTagsSource = state.allElements.getEventSourceById(id);
let somethingChanged = false
const currentTagsSource = state.allElements.getEventSourceById(id)
const currentTags = currentTagsSource.data
for (const key in latestTags) {
let osmValue = latestTags[key]
@ -117,7 +112,7 @@ export default class SelectedElementTagsUpdater {
const localValue = currentTags[key]
if (localValue !== osmValue) {
somethingChanged = true;
somethingChanged = true
currentTags[key] = osmValue
}
}
@ -137,7 +132,6 @@ export default class SelectedElementTagsUpdater {
somethingChanged = true
}
if (somethingChanged) {
console.log("Detected upstream changes to the object when opening it, updating...")
currentTagsSource.ping()
@ -148,6 +142,4 @@ export default class SelectedElementTagsUpdater {
console.error("Updating the tags of selected element ", id, "failed due to", e)
}
}
}
}

View file

@ -1,63 +1,67 @@
import {UIEventSource} from "../UIEventSource";
import {OsmObject} from "../Osm/OsmObject";
import Loc from "../../Models/Loc";
import {ElementStorage} from "../ElementStorage";
import FeaturePipeline from "../FeatureSource/FeaturePipeline";
import {GeoOperations} from "../GeoOperations";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import { UIEventSource } from "../UIEventSource"
import { OsmObject } from "../Osm/OsmObject"
import Loc from "../../Models/Loc"
import { ElementStorage } from "../ElementStorage"
import FeaturePipeline from "../FeatureSource/FeaturePipeline"
import { GeoOperations } from "../GeoOperations"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
/**
* Makes sure the hash shows the selected element and vice-versa.
*/
export default class SelectedFeatureHandler {
private static readonly _no_trigger_on = new Set(["welcome", "copyright", "layers", "new", "filters", "location_track", "", undefined])
private readonly hash: UIEventSource<string>;
private static readonly _no_trigger_on = new Set([
"welcome",
"copyright",
"layers",
"new",
"filters",
"location_track",
"",
undefined,
])
private readonly hash: UIEventSource<string>
private readonly state: {
selectedElement: UIEventSource<any>,
allElements: ElementStorage,
locationControl: UIEventSource<Loc>,
selectedElement: UIEventSource<any>
allElements: ElementStorage
locationControl: UIEventSource<Loc>
layoutToUse: LayoutConfig
}
constructor(
hash: UIEventSource<string>,
state: {
selectedElement: UIEventSource<any>,
allElements: ElementStorage,
featurePipeline: FeaturePipeline,
locationControl: UIEventSource<Loc>,
selectedElement: UIEventSource<any>
allElements: ElementStorage
featurePipeline: FeaturePipeline
locationControl: UIEventSource<Loc>
layoutToUse: LayoutConfig
}
) {
this.hash = hash;
this.hash = hash
this.state = state
// If the hash changes, set the selected element correctly
const self = this;
const self = this
hash.addCallback(() => self.setSelectedElementFromHash())
state.featurePipeline?.newDataLoadedSignal?.addCallbackAndRunD(_ => {
state.featurePipeline?.newDataLoadedSignal?.addCallbackAndRunD((_) => {
// New data was loaded. In initial startup, the hash might be set (via the URL) but might not be selected yet
if (hash.data === undefined || SelectedFeatureHandler._no_trigger_on.has(hash.data)) {
// This is an invalid hash anyway
return;
return
}
if (state.selectedElement.data !== undefined) {
// We already have something selected
return;
return
}
self.setSelectedElementFromHash()
})
this.initialLoad()
}
/**
* On startup: check if the hash is loaded and eventually zoom to it
* @private
@ -65,21 +69,18 @@ export default class SelectedFeatureHandler {
private initialLoad() {
const hash = this.hash.data
if (hash === undefined || hash === "" || hash.indexOf("-") >= 0) {
return;
return
}
if (SelectedFeatureHandler._no_trigger_on.has(hash)) {
return;
return
}
if (!(hash.startsWith("node") || hash.startsWith("way") || hash.startsWith("relation"))) {
return;
return
}
OsmObject.DownloadObjectAsync(hash).then(obj => {
OsmObject.DownloadObjectAsync(hash).then((obj) => {
try {
console.log("Downloaded selected object from OSM-API for initial load: ", hash)
const geojson = obj.asGeoJson()
this.state.allElements.addOrGetElement(geojson)
@ -88,9 +89,7 @@ export default class SelectedFeatureHandler {
} catch (e) {
console.error(e)
}
})
}
private setSelectedElementFromHash() {
@ -98,22 +97,21 @@ export default class SelectedFeatureHandler {
const h = this.hash.data
if (h === undefined || h === "") {
// Hash has been cleared - we clear the selected element
state.selectedElement.setData(undefined);
state.selectedElement.setData(undefined)
} else {
// we search the element to select
const feature = state.allElements.ContainingFeatures.get(h)
if (feature === undefined) {
return;
return
}
const currentlySeleced = state.selectedElement.data
if (currentlySeleced === undefined) {
state.selectedElement.setData(feature)
return;
return
}
if (currentlySeleced.properties?.id === feature.properties.id) {
// We already have the right feature
return;
return
}
state.selectedElement.setData(feature)
}
@ -121,25 +119,24 @@ export default class SelectedFeatureHandler {
// If a feature is selected via the hash, zoom there
private zoomToSelectedFeature() {
const selected = this.state.selectedElement.data
if (selected === undefined) {
return
}
const centerpoint = GeoOperations.centerpointCoordinates(selected)
const location = this.state.locationControl;
const location = this.state.locationControl
location.data.lon = centerpoint[0]
location.data.lat = centerpoint[1]
const minZoom = Math.max(14, ...(this.state.layoutToUse?.layers?.map(l => l.minzoomVisible) ?? []))
const minZoom = Math.max(
14,
...(this.state.layoutToUse?.layers?.map((l) => l.minzoomVisible) ?? [])
)
if (location.data.zoom < minZoom) {
location.data.zoom = minZoom
}
location.ping();
location.ping()
}
}
}

View file

@ -1,88 +1,87 @@
import * as L from "leaflet";
import {UIEventSource} from "../UIEventSource";
import ScrollableFullScreen from "../../UI/Base/ScrollableFullScreen";
import FilteredLayer from "../../Models/FilteredLayer";
import Constants from "../../Models/Constants";
import BaseUIElement from "../../UI/BaseUIElement";
import * as L from "leaflet"
import { UIEventSource } from "../UIEventSource"
import ScrollableFullScreen from "../../UI/Base/ScrollableFullScreen"
import FilteredLayer from "../../Models/FilteredLayer"
import Constants from "../../Models/Constants"
import BaseUIElement from "../../UI/BaseUIElement"
/**
* The stray-click-hanlders adds a marker to the map if no feature was clicked.
* Shows the given uiToShow-element in the messagebox
*/
export default class StrayClickHandler {
private _lastMarker;
private _lastMarker
constructor(
state: {
LastClickLocation: UIEventSource<{ lat: number, lon: number }>,
selectedElement: UIEventSource<string>,
filteredLayers: UIEventSource<FilteredLayer[]>,
LastClickLocation: UIEventSource<{ lat: number; lon: number }>
selectedElement: UIEventSource<string>
filteredLayers: UIEventSource<FilteredLayer[]>
leafletMap: UIEventSource<L.Map>
},
uiToShow: ScrollableFullScreen,
iconToShow: BaseUIElement) {
const self = this;
iconToShow: BaseUIElement
) {
const self = this
const leafletMap = state.leafletMap
state.filteredLayers.data.forEach((filteredLayer) => {
filteredLayer.isDisplayed.addCallback(isEnabled => {
filteredLayer.isDisplayed.addCallback((isEnabled) => {
if (isEnabled && self._lastMarker && leafletMap.data !== undefined) {
// When a layer is activated, we remove the 'last click location' in order to force the user to reclick
// This reclick might be at a location where a feature now appeared...
state.leafletMap.data.removeLayer(self._lastMarker);
state.leafletMap.data.removeLayer(self._lastMarker)
}
})
})
state.LastClickLocation.addCallback(function (lastClick) {
if (self._lastMarker !== undefined) {
state.leafletMap.data?.removeLayer(self._lastMarker);
state.leafletMap.data?.removeLayer(self._lastMarker)
}
if (lastClick === undefined) {
return;
return
}
state.selectedElement.setData(undefined);
state.selectedElement.setData(undefined)
const clickCoor: [number, number] = [lastClick.lat, lastClick.lon]
self._lastMarker = L.marker(clickCoor, {
icon: L.divIcon({
html: iconToShow.ConstructElement(),
iconSize: [50, 50],
iconAnchor: [25, 50],
popupAnchor: [0, -45]
})
});
popupAnchor: [0, -45],
}),
})
const popup = L.popup({
autoPan: true,
autoPanPaddingTopLeft: [15, 15],
closeOnEscapeKey: true,
autoClose: true
}).setContent("<div id='strayclick' style='height: 65vh'></div>");
self._lastMarker.addTo(leafletMap.data);
self._lastMarker.bindPopup(popup);
autoClose: true,
}).setContent("<div id='strayclick' style='height: 65vh'></div>")
self._lastMarker.addTo(leafletMap.data)
self._lastMarker.bindPopup(popup)
self._lastMarker.on("click", () => {
if (leafletMap.data.getZoom() < Constants.userJourney.minZoomLevelToAddNewPoints) {
self._lastMarker.closePopup()
leafletMap.data.flyTo(clickCoor, Constants.userJourney.minZoomLevelToAddNewPoints)
return;
leafletMap.data.flyTo(
clickCoor,
Constants.userJourney.minZoomLevelToAddNewPoints
)
return
}
uiToShow.AttachTo("strayclick")
uiToShow.Activate();
});
});
uiToShow.Activate()
})
})
state.selectedElement.addCallback(() => {
if (self._lastMarker !== undefined) {
leafletMap.data.removeLayer(self._lastMarker);
this._lastMarker = undefined;
leafletMap.data.removeLayer(self._lastMarker)
this._lastMarker = undefined
}
})
}
}
}

View file

@ -1,19 +1,19 @@
import {Store, UIEventSource} from "../UIEventSource";
import Locale from "../../UI/i18n/Locale";
import TagRenderingAnswer from "../../UI/Popup/TagRenderingAnswer";
import Combine from "../../UI/Base/Combine";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {ElementStorage} from "../ElementStorage";
import {Utils} from "../../Utils";
import { Store, UIEventSource } from "../UIEventSource"
import Locale from "../../UI/i18n/Locale"
import TagRenderingAnswer from "../../UI/Popup/TagRenderingAnswer"
import Combine from "../../UI/Base/Combine"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { ElementStorage } from "../ElementStorage"
import { Utils } from "../../Utils"
export default class TitleHandler {
constructor(state: {
selectedElement: Store<any>,
layoutToUse: LayoutConfig,
selectedElement: Store<any>
layoutToUse: LayoutConfig
allElements: ElementStorage
}) {
const currentTitle: Store<string> = state.selectedElement.map(
selected => {
(selected) => {
const layout = state.layoutToUse
const defaultTitle = layout?.title?.txt ?? "MapComplete"
@ -21,27 +21,32 @@ export default class TitleHandler {
return defaultTitle
}
const tags = selected.properties;
const tags = selected.properties
for (const layer of layout.layers) {
if (layer.title === undefined) {
continue;
continue
}
if (layer.source.osmTags.matchesProperties(tags)) {
const tagsSource = state.allElements.getEventSourceById(tags.id) ?? new UIEventSource<any>(tags)
const tagsSource =
state.allElements.getEventSourceById(tags.id) ??
new UIEventSource<any>(tags)
const title = new TagRenderingAnswer(tagsSource, layer.title, {})
return new Combine([defaultTitle, " | ", title]).ConstructElement()?.textContent ?? defaultTitle;
return (
new Combine([defaultTitle, " | ", title]).ConstructElement()
?.textContent ?? defaultTitle
)
}
}
return defaultTitle
}, [Locale.language]
},
[Locale.language]
)
currentTitle.addCallbackAndRunD(title => {
currentTitle.addCallbackAndRunD((title) => {
if (Utils.runningFromConsole) {
return
}
document.title = title
})
}
}
}

View file

@ -1,31 +1,32 @@
import * as turf from "@turf/turf";
import {TileRange, Tiles} from "../Models/TileRange";
import {GeoOperations} from "./GeoOperations";
import * as turf from "@turf/turf"
import { TileRange, Tiles } from "../Models/TileRange"
import { GeoOperations } from "./GeoOperations"
export class BBox {
static global: BBox = new BBox([[-180, -90], [180, 90]]);
readonly maxLat: number;
readonly maxLon: number;
readonly minLat: number;
readonly minLon: number;
static global: BBox = new BBox([
[-180, -90],
[180, 90],
])
readonly maxLat: number
readonly maxLon: number
readonly minLat: number
readonly minLon: number
/***
* Coordinates should be [[lon, lat],[lon, lat]]
* @param coordinates
*/
constructor(coordinates) {
this.maxLat = -90;
this.maxLon = -180;
this.minLat = 90;
this.minLon = 180;
this.maxLat = -90
this.maxLon = -180
this.minLat = 90
this.minLon = 180
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]);
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])
}
this.maxLon = Math.min(this.maxLon, 180)
@ -33,27 +34,32 @@ export class BBox {
this.minLon = Math.max(this.minLon, -180)
this.minLat = Math.max(this.minLat, -90)
this.check();
this.check()
}
static fromLeafletBounds(bounds) {
return new BBox([[bounds.getWest(), bounds.getNorth()], [bounds.getEast(), bounds.getSouth()]])
return new BBox([
[bounds.getWest(), bounds.getNorth()],
[bounds.getEast(), bounds.getSouth()],
])
}
static get(feature): BBox {
if (feature.bbox?.overlapsWith === undefined) {
const turfBbox: number[] = turf.bbox(feature)
feature.bbox = new BBox([[turfBbox[0], turfBbox[1]], [turfBbox[2], turfBbox[3]]]);
feature.bbox = new BBox([
[turfBbox[0], turfBbox[1]],
[turfBbox[2], turfBbox[3]],
])
}
return feature.bbox;
return feature.bbox
}
static bboxAroundAll(bboxes: BBox[]): BBox {
let maxLat: number = -90;
let maxLon: number = -180;
let minLat: number = 80;
let minLon: number = 180;
let maxLat: number = -90
let maxLon: number = -180
let minLat: number = 80
let minLon: number = 180
for (const bbox of bboxes) {
maxLat = Math.max(maxLat, bbox.maxLat)
@ -61,17 +67,20 @@ export class BBox {
minLat = Math.min(minLat, bbox.minLat)
minLon = Math.min(minLon, bbox.minLon)
}
return new BBox([[maxLon, maxLat], [minLon, minLat]])
return new BBox([
[maxLon, maxLat],
[minLon, minLat],
])
}
/**
* Calculates the BBox based on a slippy map tile number
*
*
* const bbox = BBox.fromTile(16, 32754, 21785)
* bbox.minLon // => -0.076904296875
* bbox.maxLon // => -0.0714111328125
* bbox.minLat // => 51.5292513551899
* bbox.maxLat // => 51.53266860674158
* bbox.minLon // => -0.076904296875
* bbox.maxLon // => -0.0714111328125
* bbox.minLat // => 51.5292513551899
* bbox.maxLat // => 51.53266860674158
*/
static fromTile(z: number, x: number, y: number): BBox {
return new BBox(Tiles.tile_bounds_lon_lat(z, x, y))
@ -85,11 +94,10 @@ export class BBox {
}
public unionWith(other: BBox) {
return new BBox([[
Math.max(this.maxLon, other.maxLon),
Math.max(this.maxLat, other.maxLat)],
[Math.min(this.minLon, other.minLon),
Math.min(this.minLat, other.minLat)]])
return new BBox([
[Math.max(this.maxLon, other.maxLon), Math.max(this.maxLat, other.maxLat)],
[Math.min(this.minLon, other.minLon), Math.min(this.minLat, other.minLat)],
])
}
/**
@ -102,32 +110,31 @@ export class BBox {
public overlapsWith(other: BBox) {
if (this.maxLon < other.minLon) {
return false;
return false
}
if (this.maxLat < other.minLat) {
return false;
return false
}
if (this.minLon > other.maxLon) {
return false;
return false
}
return this.minLat <= other.maxLat;
return this.minLat <= other.maxLat
}
public isContainedIn(other: BBox) {
if (this.maxLon > other.maxLon) {
return false;
return false
}
if (this.maxLat > other.maxLat) {
return false;
return false
}
if (this.minLon < other.minLon) {
return false;
return false
}
if (this.minLat < other.minLat) {
return false
}
return true;
return true
}
getEast() {
@ -147,32 +154,35 @@ export class BBox {
}
contains(lonLat: [number, number]) {
return this.minLat <= lonLat[1] && lonLat[1] <= this.maxLat
&& this.minLon <= lonLat[0] && lonLat[0] <= this.maxLon
return (
this.minLat <= lonLat[1] &&
lonLat[1] <= this.maxLat &&
this.minLon <= lonLat[0] &&
lonLat[0] <= this.maxLon
)
}
pad(factor: number, maxIncrease = 2): BBox {
const latDiff = Math.min(maxIncrease / 2, Math.abs(this.maxLat - this.minLat) * factor)
const lonDiff = Math.min(maxIncrease / 2, Math.abs(this.maxLon - this.minLon) * factor)
return new BBox([[
this.minLon - lonDiff,
this.minLat - latDiff
], [this.maxLon + lonDiff,
this.maxLat + latDiff]])
return new BBox([
[this.minLon - lonDiff, this.minLat - latDiff],
[this.maxLon + lonDiff, this.maxLat + latDiff],
])
}
padAbsolute(degrees: number): BBox {
return new BBox([[
this.minLon - degrees,
this.minLat - degrees
], [this.maxLon + degrees,
this.maxLat + degrees]])
return new BBox([
[this.minLon - degrees, this.minLat - degrees],
[this.maxLon + degrees, this.maxLat + degrees],
])
}
toLeaflet() {
return [[this.minLat, this.minLon], [this.maxLat, this.maxLon]]
return [
[this.minLat, this.minLon],
[this.maxLat, this.maxLon],
]
}
asGeoJson(properties: any): any {
@ -181,16 +191,16 @@ export class BBox {
properties: properties,
geometry: {
type: "Polygon",
coordinates: [[
[this.minLon, this.minLat],
[this.maxLon, this.minLat],
[this.maxLon, this.maxLat],
[this.minLon, this.maxLat],
[this.minLon, this.minLat],
]]
}
coordinates: [
[
[this.minLon, this.minLat],
[this.maxLon, this.minLat],
[this.maxLon, this.maxLat],
[this.minLon, this.maxLat],
[this.minLon, this.minLat],
],
],
},
}
}
@ -206,22 +216,22 @@ export class BBox {
return new BBox([].concat(boundsul, boundslr))
}
toMercator(): { minLat: number, maxLat: number, minLon: number, maxLon: number } {
toMercator(): { minLat: number; maxLat: number; minLon: number; maxLon: number } {
const [minLon, minLat] = GeoOperations.ConvertWgs84To900913([this.minLon, this.minLat])
const [maxLon, maxLat] = GeoOperations.ConvertWgs84To900913([this.maxLon, this.maxLat])
return {
minLon, maxLon,
minLat, maxLat
minLon,
maxLon,
minLat,
maxLat,
}
}
private check() {
private check() {
if (isNaN(this.maxLon) || isNaN(this.maxLat) || isNaN(this.minLon) || isNaN(this.minLat)) {
console.trace("BBox with NaN detected:", this);
throw "BBOX has NAN";
console.trace("BBox with NaN detected:", this)
throw "BBOX has NAN"
}
}
}
}

View file

@ -1,46 +1,56 @@
/// Given a feature source, calculates a list of OSM-contributors who mapped the latest versions
import {Store, UIEventSource} from "./UIEventSource";
import FeaturePipeline from "./FeatureSource/FeaturePipeline";
import Loc from "../Models/Loc";
import {BBox} from "./BBox";
import { Store, UIEventSource } from "./UIEventSource"
import FeaturePipeline from "./FeatureSource/FeaturePipeline"
import Loc from "../Models/Loc"
import { BBox } from "./BBox"
export default class ContributorCount {
public readonly Contributors: UIEventSource<Map<string, number>> = new UIEventSource<
Map<string, number>
>(new Map<string, number>())
private readonly state: {
featurePipeline: FeaturePipeline
currentBounds: Store<BBox>
locationControl: Store<Loc>
}
private lastUpdate: Date = undefined
public readonly Contributors: UIEventSource<Map<string, number>> = new UIEventSource<Map<string, number>>(new Map<string, number>());
private readonly state: { featurePipeline: FeaturePipeline, currentBounds: Store<BBox>, locationControl: Store<Loc> };
private lastUpdate: Date = undefined;
constructor(state: { featurePipeline: FeaturePipeline, currentBounds: Store<BBox>, locationControl: Store<Loc> }) {
this.state = state;
const self = this;
state.currentBounds.map(bbox => {
constructor(state: {
featurePipeline: FeaturePipeline
currentBounds: Store<BBox>
locationControl: Store<Loc>
}) {
this.state = state
const self = this
state.currentBounds.map((bbox) => {
self.update(bbox)
})
state.featurePipeline.runningQuery.addCallbackAndRun(
_ => self.update(state.currentBounds.data)
state.featurePipeline.runningQuery.addCallbackAndRun((_) =>
self.update(state.currentBounds.data)
)
}
private update(bbox: BBox) {
if (bbox === undefined) {
return;
return
}
const now = new Date();
if (this.lastUpdate !== undefined && ((now.getTime() - this.lastUpdate.getTime()) < 1000 * 60)) {
return;
const now = new Date()
if (
this.lastUpdate !== undefined &&
now.getTime() - this.lastUpdate.getTime() < 1000 * 60
) {
return
}
this.lastUpdate = now;
this.lastUpdate = now
const featuresList = this.state.featurePipeline.GetAllFeaturesWithin(bbox)
const hist = new Map<string, number>();
const hist = new Map<string, number>()
for (const list of featuresList) {
for (const feature of list) {
const contributor = feature.properties["_last_edit:contributor"]
const count = hist.get(contributor) ?? 0;
const count = hist.get(contributor) ?? 0
hist.set(contributor, count + 1)
}
}
this.Contributors.setData(hist)
}
}
}

View file

@ -1,35 +1,37 @@
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig";
import {QueryParameters} from "./Web/QueryParameters";
import {AllKnownLayouts} from "../Customizations/AllKnownLayouts";
import {FixedUiElement} from "../UI/Base/FixedUiElement";
import {Utils} from "../Utils";
import Combine from "../UI/Base/Combine";
import {SubtleButton} from "../UI/Base/SubtleButton";
import BaseUIElement from "../UI/BaseUIElement";
import {UIEventSource} from "./UIEventSource";
import {LocalStorageSource} from "./Web/LocalStorageSource";
import LZString from "lz-string";
import {FixLegacyTheme} from "../Models/ThemeConfig/Conversion/LegacyJsonConvert";
import {LayerConfigJson} from "../Models/ThemeConfig/Json/LayerConfigJson";
import SharedTagRenderings from "../Customizations/SharedTagRenderings";
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"
import { QueryParameters } from "./Web/QueryParameters"
import { AllKnownLayouts } from "../Customizations/AllKnownLayouts"
import { FixedUiElement } from "../UI/Base/FixedUiElement"
import { Utils } from "../Utils"
import Combine from "../UI/Base/Combine"
import { SubtleButton } from "../UI/Base/SubtleButton"
import BaseUIElement from "../UI/BaseUIElement"
import { UIEventSource } from "./UIEventSource"
import { LocalStorageSource } from "./Web/LocalStorageSource"
import LZString from "lz-string"
import { FixLegacyTheme } from "../Models/ThemeConfig/Conversion/LegacyJsonConvert"
import { LayerConfigJson } from "../Models/ThemeConfig/Json/LayerConfigJson"
import SharedTagRenderings from "../Customizations/SharedTagRenderings"
import * as known_layers from "../assets/generated/known_layers.json"
import {PrepareTheme} from "../Models/ThemeConfig/Conversion/PrepareTheme";
import { PrepareTheme } from "../Models/ThemeConfig/Conversion/PrepareTheme"
import * as licenses from "../assets/generated/license_info.json"
import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig";
import {FixImages} from "../Models/ThemeConfig/Conversion/FixImages";
import Svg from "../Svg";
import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig"
import { FixImages } from "../Models/ThemeConfig/Conversion/FixImages"
import Svg from "../Svg"
export default class DetermineLayout {
private static readonly _knownImages = new Set(Array.from(licenses).map((l) => l.path))
private static readonly _knownImages =new Set( Array.from(licenses).map(l => l.path))
/**
* Gets the correct layout for this website
*/
public static async GetLayout(): Promise<LayoutConfig> {
const loadCustomThemeParam = QueryParameters.GetQueryParameter("userlayout", "false", "If not 'false', a custom (non-official) theme is loaded. This custom layout can be done in multiple ways: \n\n- The hash of the URL contains a base64-encoded .json-file containing the theme definition\n- The hash of the URL contains a lz-compressed .json-file, as generated by the custom theme generator\n- The parameter itself is an URL, in which case that URL will be downloaded. It should point to a .json of a theme")
const layoutFromBase64 = decodeURIComponent(loadCustomThemeParam.data);
const loadCustomThemeParam = QueryParameters.GetQueryParameter(
"userlayout",
"false",
"If not 'false', a custom (non-official) theme is loaded. This custom layout can be done in multiple ways: \n\n- The hash of the URL contains a base64-encoded .json-file containing the theme definition\n- The hash of the URL contains a lz-compressed .json-file, as generated by the custom theme generator\n- The parameter itself is an URL, in which case that URL will be downloaded. It should point to a .json of a theme"
)
const layoutFromBase64 = decodeURIComponent(loadCustomThemeParam.data)
if (layoutFromBase64.startsWith("http")) {
return await DetermineLayout.LoadRemoteTheme(layoutFromBase64)
@ -42,150 +44,164 @@ export default class DetermineLayout {
let layoutId: string = undefined
const path = window.location.pathname.split("/").slice(-1)[0];
const path = window.location.pathname.split("/").slice(-1)[0]
if (path !== "theme.html" && path !== "") {
layoutId = path;
layoutId = path
if (path.endsWith(".html")) {
layoutId = path.substr(0, path.length - 5);
layoutId = path.substr(0, path.length - 5)
}
console.log("Using layout", layoutId);
console.log("Using layout", layoutId)
}
layoutId = QueryParameters.GetQueryParameter("layout", layoutId, "The layout to load into MapComplete").data;
layoutId = QueryParameters.GetQueryParameter(
"layout",
layoutId,
"The layout to load into MapComplete"
).data
return AllKnownLayouts.allKnownLayouts.get(layoutId?.toLowerCase())
}
public static LoadLayoutFromHash(
userLayoutParam: UIEventSource<string>
): LayoutConfig | null {
let hash = location.hash.substr(1);
let json: any;
public static LoadLayoutFromHash(userLayoutParam: UIEventSource<string>): LayoutConfig | null {
let hash = location.hash.substr(1)
let json: any
try {
// layoutFromBase64 contains the name of the theme. This is partly to do tracking with goat counter
const dedicatedHashFromLocalStorage = LocalStorageSource.Get(
"user-layout-" + userLayoutParam.data?.replace(" ", "_")
);
)
if (dedicatedHashFromLocalStorage.data?.length < 10) {
dedicatedHashFromLocalStorage.setData(undefined);
dedicatedHashFromLocalStorage.setData(undefined)
}
const hashFromLocalStorage = LocalStorageSource.Get(
"last-loaded-user-layout"
);
const hashFromLocalStorage = LocalStorageSource.Get("last-loaded-user-layout")
if (hash.length < 10) {
hash =
dedicatedHashFromLocalStorage.data ??
hashFromLocalStorage.data;
hash = dedicatedHashFromLocalStorage.data ?? hashFromLocalStorage.data
} else {
console.log("Saving hash to local storage");
hashFromLocalStorage.setData(hash);
dedicatedHashFromLocalStorage.setData(hash);
console.log("Saving hash to local storage")
hashFromLocalStorage.setData(hash)
dedicatedHashFromLocalStorage.setData(hash)
}
try {
json = JSON.parse(atob(hash));
json = JSON.parse(atob(hash))
} catch (e) {
// We try to decode with lz-string
try {
json = JSON.parse(Utils.UnMinify(LZString.decompressFromBase64(hash)))
} catch (e) {
console.error(e)
DetermineLayout.ShowErrorOnCustomTheme("Could not decode the hash", new FixedUiElement("Not a valid (LZ-compressed) JSON"))
return null;
DetermineLayout.ShowErrorOnCustomTheme(
"Could not decode the hash",
new FixedUiElement("Not a valid (LZ-compressed) JSON")
)
return null
}
}
const layoutToUse = DetermineLayout.prepCustomTheme(json)
userLayoutParam.setData(layoutToUse.id);
userLayoutParam.setData(layoutToUse.id)
return layoutToUse
} catch (e) {
console.error(e)
if (hash === undefined || hash.length < 10) {
DetermineLayout.ShowErrorOnCustomTheme("Could not load a theme from the hash", new FixedUiElement("Hash does not contain data"), json)
DetermineLayout.ShowErrorOnCustomTheme(
"Could not load a theme from the hash",
new FixedUiElement("Hash does not contain data"),
json
)
}
this.ShowErrorOnCustomTheme("Could not parse the hash", new FixedUiElement(e), json)
return null;
return null
}
}
public static ShowErrorOnCustomTheme(
intro: string = "Error: could not parse the custom layout:",
error: BaseUIElement,
json?: any) {
json?: any
) {
new Combine([
intro,
error.SetClass("alert"),
new SubtleButton(Svg.back_svg(),
"Go back to the theme overview",
{url: window.location.protocol + "//" + window.location.host + "/index.html", newTab: false}),
json !== undefined ? new SubtleButton(Svg.download_svg(),"Download the JSON file").onClick(() => {
Utils.offerContentsAsDownloadableFile(JSON.stringify(json, null, " "), "theme_definition.json")
}) : undefined
new SubtleButton(Svg.back_svg(), "Go back to the theme overview", {
url: window.location.protocol + "//" + window.location.host + "/index.html",
newTab: false,
}),
json !== undefined
? new SubtleButton(Svg.download_svg(), "Download the JSON file").onClick(() => {
Utils.offerContentsAsDownloadableFile(
JSON.stringify(json, null, " "),
"theme_definition.json"
)
})
: undefined,
])
.SetClass("flex flex-col clickable")
.AttachTo("centermessage");
.AttachTo("centermessage")
}
private static prepCustomTheme(json: any, sourceUrl?: string, forceId?: string): LayoutConfig {
if(json.layers === undefined && json.tagRenderings !== undefined){
const iconTr = json.mapRendering.map(mr => mr.icon).find(icon => icon !== undefined)
if (json.layers === undefined && json.tagRenderings !== undefined) {
const iconTr = json.mapRendering.map((mr) => mr.icon).find((icon) => icon !== undefined)
const icon = new TagRenderingConfig(iconTr).render.txt
json = {
id: json.id,
description: json.description,
descriptionTail: {
en: "<div class='alert'>Layer only mode.</div> The loaded custom theme actually isn't a custom theme, but only contains a layer."
en: "<div class='alert'>Layer only mode.</div> The loaded custom theme actually isn't a custom theme, but only contains a layer.",
},
icon,
title: json.name,
layers: [json],
}
}
const knownLayersDict = new Map<string, LayerConfigJson>()
for (const key in known_layers.layers) {
const layer = known_layers.layers[key]
knownLayersDict.set(layer.id,<LayerConfigJson> layer)
knownLayersDict.set(layer.id, <LayerConfigJson>layer)
}
const converState = {
tagRenderings: SharedTagRenderings.SharedTagRenderingJson,
sharedLayers: knownLayersDict,
publicLayers: new Set<string>()
publicLayers: new Set<string>(),
}
json = new FixLegacyTheme().convertStrict(json, "While loading a dynamic theme")
const raw = json;
const raw = json
json = new FixImages(DetermineLayout._knownImages).convertStrict(json, "While fixing the images")
json.enableNoteImports = json.enableNoteImports ?? false;
json = new FixImages(DetermineLayout._knownImages).convertStrict(
json,
"While fixing the images"
)
json.enableNoteImports = json.enableNoteImports ?? false
json = new PrepareTheme(converState).convertStrict(json, "While preparing a dynamic theme")
console.log("The layoutconfig is ", json)
json.id = forceId ?? json.id
return new LayoutConfig(json, false, {
definitionRaw: JSON.stringify(raw, null, " "),
definedAtUrl: sourceUrl
definedAtUrl: sourceUrl,
})
}
private static async LoadRemoteTheme(link: string): Promise<LayoutConfig | null> {
console.log("Downloading map theme from ", link);
console.log("Downloading map theme from ", link)
new FixedUiElement(`Downloading the theme from the <a href="${link}">link</a>...`)
.AttachTo("centermessage");
new FixedUiElement(`Downloading the theme from the <a href="${link}">link</a>...`).AttachTo(
"centermessage"
)
try {
let parsed = await Utils.downloadJson(link)
try {
let forcedId = parsed.id
const url = new URL(link)
if(!(url.hostname === "localhost" || url.hostname === "127.0.0.1")){
forcedId = link;
if (!(url.hostname === "localhost" || url.hostname === "127.0.0.1")) {
forcedId = link
}
console.log("Loaded remote link:", link)
return DetermineLayout.prepCustomTheme(parsed, link, forcedId);
return DetermineLayout.prepCustomTheme(parsed, link, forcedId)
} catch (e) {
console.error(e)
DetermineLayout.ShowErrorOnCustomTheme(
@ -193,17 +209,15 @@ export default class DetermineLayout {
new FixedUiElement(e),
parsed
)
return null;
return null
}
} catch (e) {
console.error(e)
DetermineLayout.ShowErrorOnCustomTheme(
`<a href="${link}">${link}</a> is invalid - probably not found or invalid JSON:`,
new FixedUiElement(e)
)
return null;
return null
}
}
}
}

View file

@ -1,20 +1,17 @@
/**
* Keeps track of a dictionary 'elementID' -> UIEventSource<tags>
*/
import {UIEventSource} from "./UIEventSource";
import {GeoJSONObject} from "@turf/turf";
import { UIEventSource } from "./UIEventSource"
import { GeoJSONObject } from "@turf/turf"
export class ElementStorage {
public ContainingFeatures = new Map<string, any>()
private _elements = new Map<string, UIEventSource<any>>()
public ContainingFeatures = new Map<string, any>();
private _elements = new Map<string, UIEventSource<any>>();
constructor() {
}
constructor() {}
addElementById(id: string, eventSource: UIEventSource<any>) {
this._elements.set(id, eventSource);
this._elements.set(id, eventSource)
}
/**
@ -24,8 +21,8 @@ export class ElementStorage {
* Note: it will cleverly merge the tags, if needed
*/
addOrGetElement(feature: any): UIEventSource<any> {
const elementId = feature.properties.id;
const newProperties = feature.properties;
const elementId = feature.properties.id
const newProperties = feature.properties
const es = this.addOrGetById(elementId, newProperties)
@ -33,91 +30,89 @@ export class ElementStorage {
feature.properties = es.data
if (!this.ContainingFeatures.has(elementId)) {
this.ContainingFeatures.set(elementId, feature);
this.ContainingFeatures.set(elementId, feature)
}
return es;
return es
}
getEventSourceById(elementId): UIEventSource<any> {
if (elementId === undefined) {
return undefined;
return undefined
}
return this._elements.get(elementId);
return this._elements.get(elementId)
}
has(id) {
return this._elements.has(id);
return this._elements.has(id)
}
addAlias(oldId: string, newId: string){
addAlias(oldId: string, newId: string) {
if (newId === undefined) {
// We removed the node/way/relation with type 'type' and id 'oldId' on openstreetmap!
const element = this.getEventSourceById(oldId);
const element = this.getEventSourceById(oldId)
element.data._deleted = "yes"
element.ping();
return;
element.ping()
return
}
if (oldId == newId) {
return undefined;
return undefined
}
const element = this.getEventSourceById( oldId);
const element = this.getEventSourceById(oldId)
if (element === undefined) {
// Element to rewrite not found, probably a node or relation that is not rendered
return undefined
}
element.data.id = newId;
this.addElementById(newId, element);
this.ContainingFeatures.set(newId, this.ContainingFeatures.get( oldId))
element.ping();
element.data.id = newId
this.addElementById(newId, element)
this.ContainingFeatures.set(newId, this.ContainingFeatures.get(oldId))
element.ping()
}
private addOrGetById(elementId: string, newProperties: any): UIEventSource<any> {
if (!this._elements.has(elementId)) {
const eventSource = new UIEventSource<any>(newProperties, "tags of " + elementId);
this._elements.set(elementId, eventSource);
return eventSource;
const eventSource = new UIEventSource<any>(newProperties, "tags of " + elementId)
this._elements.set(elementId, eventSource)
return eventSource
}
const es = this._elements.get(elementId);
const es = this._elements.get(elementId)
if (es.data == newProperties) {
// Reference comparison gives the same object! we can just return the event source
return es;
return es
}
const keptKeys = es.data;
const keptKeys = es.data
// The element already exists
// We use the new feature to overwrite all the properties in the already existing eventsource
const debug_msg = []
let somethingChanged = false;
let somethingChanged = false
for (const k in newProperties) {
if (!newProperties.hasOwnProperty(k)) {
continue;
continue
}
const v = newProperties[k];
const v = newProperties[k]
if (keptKeys[k] !== v) {
if (v === undefined) {
// The new value is undefined; the tag might have been removed
// It might be a metatag as well
// In the latter case, we do keep the tag!
if (!k.startsWith("_")) {
delete keptKeys[k]
debug_msg.push(("Erased " + k))
debug_msg.push("Erased " + k)
}
} else {
keptKeys[k] = v;
keptKeys[k] = v
debug_msg.push(k + " --> " + v)
}
somethingChanged = true;
somethingChanged = true
}
}
if (somethingChanged) {
es.ping();
es.ping()
}
return es;
return es
}
}
}

View file

@ -1,11 +1,11 @@
import {GeoOperations} from "./GeoOperations";
import Combine from "../UI/Base/Combine";
import RelationsTracker from "./Osm/RelationsTracker";
import BaseUIElement from "../UI/BaseUIElement";
import List from "../UI/Base/List";
import Title from "../UI/Base/Title";
import {BBox} from "./BBox";
import {Feature, Geometry, MultiPolygon, Polygon} from "@turf/turf";
import { GeoOperations } from "./GeoOperations"
import Combine from "../UI/Base/Combine"
import RelationsTracker from "./Osm/RelationsTracker"
import BaseUIElement from "../UI/BaseUIElement"
import List from "../UI/Base/List"
import Title from "../UI/Base/Title"
import { BBox } from "./BBox"
import { Feature, Geometry, MultiPolygon, Polygon } from "@turf/turf"
export interface ExtraFuncParams {
/**
@ -13,7 +13,7 @@ export interface ExtraFuncParams {
* Note that more features then requested can be given back.
* Format: [ [ geojson, geojson, geojson, ... ], [geojson, ...], ...]
*/
getFeaturesWithin: (layerId: string, bbox: BBox) => Feature<Geometry, { id: string }>[][],
getFeaturesWithin: (layerId: string, bbox: BBox) => Feature<Geometry, { id: string }>[][]
memberships: RelationsTracker
getFeatureById: (id: string) => Feature<Geometry, { id: string }>
}
@ -22,19 +22,23 @@ export interface ExtraFuncParams {
* Describes a function that is added to a geojson object in order to calculate calculated tags
*/
interface ExtraFunction {
readonly _name: string;
readonly _args: string[];
readonly _doc: string;
readonly _f: (params: ExtraFuncParams, feat: Feature<Geometry, any>) => any;
readonly _name: string
readonly _args: string[]
readonly _doc: string
readonly _f: (params: ExtraFuncParams, feat: Feature<Geometry, any>) => any
}
class EnclosingFunc implements ExtraFunction {
_name = "enclosingFeatures"
_doc = ["Gives a list of all features in the specified layers which fully contain this object. Returned features will always be (multi)polygons. (LineStrings and Points from the other layers are ignored)", "",
_doc = [
"Gives a list of all features in the specified layers which fully contain this object. Returned features will always be (multi)polygons. (LineStrings and Points from the other layers are ignored)",
"",
"The result is a list of features: `{feat: Polygon}[]`",
"This function will never return the feature itself."].join("\n")
_args = ["...layerIds - one or more layer ids of the layer from which every feature is checked for overlap)"]
"This function will never return the feature itself.",
].join("\n")
_args = [
"...layerIds - one or more layer ids of the layer from which every feature is checked for overlap)",
]
_f(params: ExtraFuncParams, feat: Feature<Geometry, any>) {
return (...layerIds: string[]) => {
@ -45,10 +49,10 @@ class EnclosingFunc implements ExtraFunction {
for (const layerId of layerIds) {
const otherFeaturess = params.getFeaturesWithin(layerId, bbox)
if (otherFeaturess === undefined) {
continue;
continue
}
if (otherFeaturess.length === 0) {
continue;
continue
}
for (const otherFeatures of otherFeaturess) {
for (const otherFeature of otherFeatures) {
@ -56,26 +60,33 @@ class EnclosingFunc implements ExtraFunction {
continue
}
seenIds.add(otherFeature.properties.id)
if (otherFeature.geometry.type !== "Polygon" && otherFeature.geometry.type !== "MultiPolygon") {
continue;
if (
otherFeature.geometry.type !== "Polygon" &&
otherFeature.geometry.type !== "MultiPolygon"
) {
continue
}
if (GeoOperations.completelyWithin(feat, <Feature<Polygon | MultiPolygon, any>>otherFeature)) {
result.push({feat: otherFeature})
if (
GeoOperations.completelyWithin(
feat,
<Feature<Polygon | MultiPolygon, any>>otherFeature
)
) {
result.push({ feat: otherFeature })
}
}
}
}
return result;
return result
}
}
}
class OverlapFunc implements ExtraFunction {
_name = "overlapWith";
_doc = ["Gives a list of features from the specified layer which this feature (partly) overlaps with. A point which is embedded in the feature is detected as well.",
_name = "overlapWith"
_doc = [
"Gives a list of features from the specified layer which this feature (partly) overlaps with. A point which is embedded in the feature is detected as well.",
"If the current feature is a point, all features that this point is embeded in are given.",
"",
"The returned value is `{ feat: GeoJSONFeature, overlap: number}[]` where `overlap` is the overlapping surface are (in m²) for areas, the overlapping length (in meter) if the current feature is a line or `undefined` if the current feature is a point.",
@ -83,27 +94,29 @@ class OverlapFunc implements ExtraFunction {
"",
"For example to get all objects which overlap or embed from a layer, use `_contained_climbing_routes_properties=feat.overlapWith('climbing_route')`",
"",
"Also see [enclosingFeatures](#enclosingFeatures) which can be used to get all objects which fully contain this feature"
"Also see [enclosingFeatures](#enclosingFeatures) which can be used to get all objects which fully contain this feature",
].join("\n")
_args = ["...layerIds - one or more layer ids of the layer from which every feature is checked for overlap)"]
_args = [
"...layerIds - one or more layer ids of the layer from which every feature is checked for overlap)",
]
_f(params, feat) {
return (...layerIds: string[]) => {
const result: { feat: any, overlap: number }[] = []
const result: { feat: any; overlap: number }[] = []
const seenIds = new Set<string>()
const bbox = BBox.get(feat)
for (const layerId of layerIds) {
const otherFeaturess = params.getFeaturesWithin(layerId, bbox)
if (otherFeaturess === undefined) {
continue;
continue
}
if (otherFeaturess.length === 0) {
continue;
continue
}
for (const otherFeatures of otherFeaturess) {
const overlap = GeoOperations.calculateOverlap(feat, otherFeatures)
for (const overlappingFeature of overlap) {
if(seenIds.has(overlappingFeature.feat.properties.id)){
if (seenIds.has(overlappingFeature.feat.properties.id)) {
continue
}
seenIds.add(overlappingFeature.feat.properties.id)
@ -113,105 +126,113 @@ class OverlapFunc implements ExtraFunction {
}
result.sort((a, b) => b.overlap - a.overlap)
return result;
return result
}
}
}
class IntersectionFunc implements ExtraFunction {
_name = "intersectionsWith";
_doc = "Gives the intersection points with selected features. Only works with (Multi)Polygons and LineStrings.\n\n" +
_name = "intersectionsWith"
_doc =
"Gives the intersection points with selected features. Only works with (Multi)Polygons and LineStrings.\n\n" +
"Returns a `{feat: GeoJson, intersections: [number,number][]}` where `feat` is the full, original feature. This list is in random order.\n\n" +
"If the current feature is a point, this function will return an empty list.\n" +
"Points from other layers are ignored - even if the points are parts of the current linestring."
_args = ["...layerIds - one or more layer ids of the layer from which every feature is checked for intersection)"]
_args = [
"...layerIds - one or more layer ids of the layer from which every feature is checked for intersection)",
]
_f(params: ExtraFuncParams, feat) {
return (...layerIds: string[]) => {
const result: { feat: any, intersections: [number, number][] }[] = []
const result: { feat: any; intersections: [number, number][] }[] = []
const bbox = BBox.get(feat)
for (const layerId of layerIds) {
const otherLayers = params.getFeaturesWithin(layerId, bbox)
if (otherLayers === undefined) {
continue;
continue
}
if (otherLayers.length === 0) {
continue;
continue
}
for (const tile of otherLayers) {
for (const otherFeature of tile) {
const intersections = GeoOperations.LineIntersections(feat, otherFeature)
if (intersections.length === 0) {
continue
}
result.push({feat: otherFeature, intersections})
result.push({ feat: otherFeature, intersections })
}
}
}
return result;
return result
}
}
}
class DistanceToFunc implements ExtraFunction {
_name = "distanceTo";
_doc = "Calculates the distance between the feature and a specified point in meter. The input should either be a pair of coordinates, a geojson feature or the ID of an object";
_name = "distanceTo"
_doc =
"Calculates the distance between the feature and a specified point in meter. The input should either be a pair of coordinates, a geojson feature or the ID of an object"
_args = ["feature OR featureID OR longitude", "undefined OR latitude"]
_f(featuresPerLayer, feature) {
return (arg0, lat) => {
if (arg0 === undefined) {
return undefined;
return undefined
}
if (typeof arg0 === "number") {
// Feature._lon and ._lat is conveniently place by one of the other metatags
return GeoOperations.distanceBetween([arg0, lat], GeoOperations.centerpointCoordinates(feature));
return GeoOperations.distanceBetween(
[arg0, lat],
GeoOperations.centerpointCoordinates(feature)
)
}
if (typeof arg0 === "string") {
// This is an identifier
const feature = featuresPerLayer.getFeatureById(arg0)
if (feature === undefined) {
return undefined;
return undefined
}
arg0 = feature;
arg0 = feature
}
// arg0 is probably a geojsonfeature
return GeoOperations.distanceBetween(GeoOperations.centerpointCoordinates(arg0), GeoOperations.centerpointCoordinates(feature))
return GeoOperations.distanceBetween(
GeoOperations.centerpointCoordinates(arg0),
GeoOperations.centerpointCoordinates(feature)
)
}
}
}
class ClosestObjectFunc implements ExtraFunction {
_name = "closest"
_doc = "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. Returns a single geojson feature or undefined if nothing is found (or not yet loaded)"
_doc =
"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. Returns a single geojson feature or undefined if nothing is found (or not yet loaded)"
_args = ["list of features or a layer name or '*' to get all features"]
_f(params, feature) {
return (features) => ClosestNObjectFunc.GetClosestNFeatures(params, feature, features)?.[0]?.feat
return (features) =>
ClosestNObjectFunc.GetClosestNFeatures(params, feature, features)?.[0]?.feat
}
}
class ClosestNObjectFunc implements ExtraFunction {
_name = "closestn"
_doc = "Given either a list of geojson features or a single layer name, gives the n closest objects which are nearest to the feature (excluding the feature itself). In the case of ways/polygons, only the centerpoint is considered. " +
_doc =
"Given either a list of geojson features or a single layer name, gives the n closest objects which are nearest to the feature (excluding the feature itself). In the case of ways/polygons, only the centerpoint is considered. " +
"Returns a list of `{feat: geojson, distance:number}` the empty list if nothing is found (or not yet loaded)\n\n" +
"If a 'unique tag key' is given, the tag with this key will only appear once (e.g. if 'name' is given, all features will have a different name)"
_args = ["list of features or layer name or '*' to get all features", "amount of features", "unique tag key (optional)", "maxDistanceInMeters (optional)"]
_args = [
"list of features or layer name or '*' to get all features",
"amount of features",
"unique tag key (optional)",
"maxDistanceInMeters (optional)",
]
/**
* Gets the closes N features, sorted by ascending distance.
@ -223,45 +244,61 @@ class ClosestNObjectFunc implements ExtraFunction {
* @constructor
* @private
*/
static GetClosestNFeatures(params: ExtraFuncParams,
feature: any,
features: string | any[],
options?: { maxFeatures?: number, uniqueTag?: string | undefined, maxDistance?: number }): { feat: any, distance: number }[] {
static GetClosestNFeatures(
params: ExtraFuncParams,
feature: any,
features: string | any[],
options?: { maxFeatures?: number; uniqueTag?: string | undefined; maxDistance?: number }
): { feat: any; distance: number }[] {
const maxFeatures = options?.maxFeatures ?? 1
const maxDistance = options?.maxDistance ?? 500
const uniqueTag: string | undefined = options?.uniqueTag
if (typeof features === "string") {
const name = features
const bbox = GeoOperations.bbox(GeoOperations.buffer(GeoOperations.bbox(feature), maxDistance))
const bbox = GeoOperations.bbox(
GeoOperations.buffer(GeoOperations.bbox(feature), maxDistance)
)
features = params.getFeaturesWithin(name, new BBox(bbox.geometry.coordinates))
} else {
features = [features]
}
if (features === undefined) {
return;
return
}
const selfCenter = GeoOperations.centerpointCoordinates(feature)
let closestFeatures: { feat: any, distance: number }[] = [];
let closestFeatures: { feat: any; distance: number }[] = []
for (const featureList of features) {
// Features is provided by 'getFeaturesWithin' which returns a list of lists of features, hence the double loop here
for (const otherFeature of featureList) {
if (otherFeature === feature || otherFeature.properties.id === feature.properties.id) {
continue; // We ignore self
if (
otherFeature === feature ||
otherFeature.properties.id === feature.properties.id
) {
continue // We ignore self
}
const distance = GeoOperations.distanceBetween(
GeoOperations.centerpointCoordinates(otherFeature),
selfCenter
)
if (distance === undefined || distance === null || isNaN(distance)) {
console.error("Could not calculate the distance between", feature, "and", otherFeature)
console.error(
"Could not calculate the distance between",
feature,
"and",
otherFeature
)
throw "Undefined distance!"
}
if (distance === 0) {
console.trace("Got a suspiciously zero distance between", otherFeature, "and self-feature", feature)
console.trace(
"Got a suspiciously zero distance between",
otherFeature,
"and self-feature",
feature
)
}
if (distance > maxDistance) {
@ -272,13 +309,15 @@ class ClosestNObjectFunc implements ExtraFunction {
// This is the first matching feature we find - always add it
closestFeatures.push({
feat: otherFeature,
distance: distance
distance: distance,
})
continue;
continue
}
if (closestFeatures.length >= maxFeatures && closestFeatures[maxFeatures - 1].distance < distance) {
if (
closestFeatures.length >= maxFeatures &&
closestFeatures[maxFeatures - 1].distance < distance
) {
// The last feature of the list (and thus the furthest away is still closer
// No use for checking, as we already have plenty of features!
continue
@ -286,11 +325,13 @@ class ClosestNObjectFunc implements ExtraFunction {
let targetIndex = closestFeatures.length
for (let i = 0; i < closestFeatures.length; i++) {
const closestFeature = closestFeatures[i];
const closestFeature = closestFeatures[i]
if (uniqueTag !== undefined) {
const uniqueTagsMatch = otherFeature.properties[uniqueTag] !== undefined &&
closestFeature.feat.properties[uniqueTag] === otherFeature.properties[uniqueTag]
const uniqueTagsMatch =
otherFeature.properties[uniqueTag] !== undefined &&
closestFeature.feat.properties[uniqueTag] ===
otherFeature.properties[uniqueTag]
if (uniqueTagsMatch) {
targetIndex = -1
if (closestFeature.distance > distance) {
@ -298,9 +339,9 @@ class ClosestNObjectFunc implements ExtraFunction {
// We want to see the tag `uniquetag=some_value` only once in the entire list (e.g. to prevent road segements of identical names to fill up the list of 'names of nearby roads')
// AT this point, we have found a closer segment with the same, identical tag
// so we replace directly
closestFeatures[i] = {feat: otherFeature, distance: distance}
closestFeatures[i] = { feat: otherFeature, distance: distance }
}
break;
break
}
}
@ -316,19 +357,19 @@ class ClosestNObjectFunc implements ExtraFunction {
}
}
}
break;
break
}
}
if (targetIndex == -1) {
continue; // value is already swapped by the unique tag
continue // value is already swapped by the unique tag
}
if (targetIndex < maxFeatures) {
// insert and drop one
closestFeatures.splice(targetIndex, 0, {
feat: otherFeature,
distance: distance
distance: distance,
})
if (closestFeatures.length >= maxFeatures) {
closestFeatures.splice(maxFeatures, 1)
@ -337,19 +378,15 @@ class ClosestNObjectFunc implements ExtraFunction {
// Overwrite the last element
closestFeatures[targetIndex] = {
feat: otherFeature,
distance: distance
distance: distance,
}
}
}
}
return closestFeatures;
return closestFeatures
}
_f(params, feature) {
return (features, amount, uniqueTag, maxDistanceInMeters) => {
let distance: number = Number(maxDistanceInMeters)
if (isNaN(distance)) {
@ -358,60 +395,54 @@ class ClosestNObjectFunc implements ExtraFunction {
return ClosestNObjectFunc.GetClosestNFeatures(params, feature, features, {
maxFeatures: Number(amount),
uniqueTag: uniqueTag,
maxDistance: distance
});
maxDistance: distance,
})
}
}
}
class Memberships implements ExtraFunction {
_name = "memberships"
_doc = "Gives a list of `{role: string, relation: Relation}`-objects, containing all the relations that this feature is part of. " +
_doc =
"Gives a list of `{role: string, relation: Relation}`-objects, containing all the relations that this feature is part of. " +
"\n\n" +
"For example: `_part_of_walking_routes=feat.memberships().map(r => r.relation.tags.name).join(';')`"
_args = []
_f(params, feat) {
return () =>
params.memberships.knownRelations.data.get(feat.properties.id) ?? []
return () => params.memberships.knownRelations.data.get(feat.properties.id) ?? []
}
}
class GetParsed implements ExtraFunction {
_name = "get"
_doc = "Gets the property of the feature, parses it (as JSON) and returns it. Might return 'undefined' if not defined, null, ..."
_doc =
"Gets the property of the feature, parses it (as JSON) and returns it. Might return 'undefined' if not defined, null, ..."
_args = ["key"]
_f(params, feat) {
return key => {
return (key) => {
const value = feat.properties[key]
if (value === undefined) {
return undefined;
return undefined
}
try {
const parsed = JSON.parse(value)
if (parsed === null) {
return undefined;
return undefined
}
return parsed;
return parsed
} catch (e) {
console.warn("Could not parse property " + key + " due to: " + e + ", the value is " + value)
return undefined;
console.warn(
"Could not parse property " + key + " due to: " + e + ", the value is " + value
)
return undefined
}
}
}
}
export class ExtraFunctions {
static readonly intro = new Combine([
new Title("Calculating tags with Javascript", 2),
"In some cases, it is useful to have some tags calculated based on other properties. Some useful tags are available by default (e.g. `lat`, `lon`, `_country`), as detailed above.",
@ -421,13 +452,13 @@ export class ExtraFunctions {
new List([
"DO NOT DO THIS AS BEGINNER",
"**Only do this if all other techniques fail** This should _not_ be done to create a rendering effect, only to calculate a specific value",
"**THIS MIGHT BE DISABLED WITHOUT ANY NOTICE ON UNOFFICIAL THEMES** As unofficial themes might be loaded from the internet, this is the equivalent of injecting arbitrary code into the client. It'll be disabled if abuse occurs."
"**THIS MIGHT BE DISABLED WITHOUT ANY NOTICE ON UNOFFICIAL THEMES** As unofficial themes might be loaded from the internet, this is the equivalent of injecting arbitrary code into the client. It'll be disabled if abuse occurs.",
]),
"To enable this feature, add a field `calculatedTags` in the layer object, e.g.:",
"````",
"\"calculatedTags\": [",
" \"_someKey=javascript-expression\",",
" \"name=feat.properties.name ?? feat.properties.ref ?? feat.properties.operator\",",
'"calculatedTags": [',
' "_someKey=javascript-expression",',
' "name=feat.properties.name ?? feat.properties.ref ?? feat.properties.operator",',
" \"_distanceCloserThen3Km=feat.distanceTo( some_lon, some_lat) < 3 ? 'yes' : 'no'\" ",
" ]",
"````",
@ -436,11 +467,12 @@ export class ExtraFunctions {
new List([
"`area` contains the surface area (in square meters) of the object",
"`lat` and `lon` contain the latitude and longitude"
"`lat` and `lon` contain the latitude and longitude",
]),
"Some advanced functions are available on **feat** as well:"
]).SetClass("flex-col").AsMarkdown();
"Some advanced functions are available on **feat** as well:",
])
.SetClass("flex-col")
.AsMarkdown()
private static readonly allFuncs: ExtraFunction[] = [
new DistanceToFunc(),
@ -450,8 +482,8 @@ export class ExtraFunctions {
new ClosestObjectFunc(),
new ClosestNObjectFunc(),
new Memberships(),
new GetParsed()
];
new GetParsed(),
]
public static FullPatchFeature(params: ExtraFuncParams, feature) {
if (feature._is_patched) {
@ -464,20 +496,15 @@ export class ExtraFunctions {
}
public static HelpText(): BaseUIElement {
const elems = []
for (const func of ExtraFunctions.allFuncs) {
elems.push(new Title(func._name, 3),
func._doc,
new List(func._args ?? [], true))
elems.push(new Title(func._name, 3), func._doc, new List(func._args ?? [], true))
}
return new Combine([
ExtraFunctions.intro,
new List(ExtraFunctions.allFuncs.map(func => `[${func._name}](#${func._name})`)),
...elems
]);
new List(ExtraFunctions.allFuncs.map((func) => `[${func._name}](#${func._name})`)),
...elems,
])
}
}

View file

@ -1,26 +1,30 @@
import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import MetaTagging from "../../MetaTagging";
import {ElementStorage} from "../../ElementStorage";
import {ExtraFuncParams} from "../../ExtraFunctions";
import FeaturePipeline from "../FeaturePipeline";
import {BBox} from "../../BBox";
import {UIEventSource} from "../../UIEventSource";
import { FeatureSourceForLayer, Tiled } from "../FeatureSource"
import MetaTagging from "../../MetaTagging"
import { ElementStorage } from "../../ElementStorage"
import { ExtraFuncParams } from "../../ExtraFunctions"
import FeaturePipeline from "../FeaturePipeline"
import { BBox } from "../../BBox"
import { UIEventSource } from "../../UIEventSource"
/****
* Concerned with the logic of updating the right layer at the right time
*/
class MetatagUpdater {
public readonly neededLayerBboxes = new Map<string /*layerId*/, BBox>()
private source: FeatureSourceForLayer & Tiled;
private source: FeatureSourceForLayer & Tiled
private readonly params: ExtraFuncParams
private state: { allElements?: ElementStorage };
private state: { allElements?: ElementStorage }
private readonly isDirty = new UIEventSource(false)
constructor(source: FeatureSourceForLayer & Tiled, state: { allElements?: ElementStorage }, featurePipeline: FeaturePipeline) {
this.state = state;
this.source = source;
const self = this;
constructor(
source: FeatureSourceForLayer & Tiled,
state: { allElements?: ElementStorage },
featurePipeline: FeaturePipeline
) {
this.state = state
this.source = source
const self = this
this.params = {
getFeatureById(id) {
return state.allElements.ContainingFeatures.get(id)
@ -29,21 +33,20 @@ class MetatagUpdater {
// We keep track of the BBOX that this source needs
let oldBbox: BBox = self.neededLayerBboxes.get(layerId)
if (oldBbox === undefined) {
self.neededLayerBboxes.set(layerId, bbox);
self.neededLayerBboxes.set(layerId, bbox)
} else if (!bbox.isContainedIn(oldBbox)) {
self.neededLayerBboxes.set(layerId, oldBbox.unionWith(bbox))
}
return featurePipeline.GetFeaturesWithin(layerId, bbox)
},
memberships: featurePipeline.relationTracker
memberships: featurePipeline.relationTracker,
}
this.isDirty.stabilized(100).addCallback(dirty => {
this.isDirty.stabilized(100).addCallback((dirty) => {
if (dirty) {
self.updateMetaTags()
}
})
this.source.features.addCallbackAndRunD(_ => self.isDirty.setData(true))
this.source.features.addCallbackAndRunD((_) => self.isDirty.setData(true))
}
public requestUpdate() {
@ -57,56 +60,58 @@ class MetatagUpdater {
this.isDirty.setData(false)
return
}
MetaTagging.addMetatags(
features,
this.params,
this.source.layer.layerDef,
this.state)
MetaTagging.addMetatags(features, this.params, this.source.layer.layerDef, this.state)
this.isDirty.setData(false)
}
}
export default class MetaTagRecalculator {
private _state: {
allElements?: ElementStorage
};
private _featurePipeline: FeaturePipeline;
private readonly _alreadyRegistered: Set<FeatureSourceForLayer & Tiled> = new Set<FeatureSourceForLayer & Tiled>()
}
private _featurePipeline: FeaturePipeline
private readonly _alreadyRegistered: Set<FeatureSourceForLayer & Tiled> = new Set<
FeatureSourceForLayer & Tiled
>()
private readonly _notifiers: MetatagUpdater[] = []
/**
* The meta tag recalculator receives tiles of layers via the 'registerSource'-function.
* It keeps track of which sources have had their share calculated, and which should be re-updated if some other data is loaded
*/
constructor(state: { allElements?: ElementStorage, currentView: FeatureSourceForLayer & Tiled }, featurePipeline: FeaturePipeline) {
this._featurePipeline = featurePipeline;
this._state = state;
if(state.currentView !== undefined){
const currentViewUpdater = new MetatagUpdater(state.currentView, this._state, this._featurePipeline)
this._alreadyRegistered.add(state.currentView)
this._notifiers.push(currentViewUpdater)
state.currentView.features.addCallback(_ => {
console.debug("Requesting an update for currentView")
currentViewUpdater.updateMetaTags();
})
}
constructor(
state: { allElements?: ElementStorage; currentView: FeatureSourceForLayer & Tiled },
featurePipeline: FeaturePipeline
) {
this._featurePipeline = featurePipeline
this._state = state
if (state.currentView !== undefined) {
const currentViewUpdater = new MetatagUpdater(
state.currentView,
this._state,
this._featurePipeline
)
this._alreadyRegistered.add(state.currentView)
this._notifiers.push(currentViewUpdater)
state.currentView.features.addCallback((_) => {
console.debug("Requesting an update for currentView")
currentViewUpdater.updateMetaTags()
})
}
}
public registerSource(source: FeatureSourceForLayer & Tiled, recalculateOnEveryChange = false) {
if (source === undefined) {
return;
return
}
if (this._alreadyRegistered.has(source)) {
return;
return
}
this._alreadyRegistered.add(source)
this._notifiers.push(new MetatagUpdater(source, this._state, this._featurePipeline))
const self = this;
source.features.addCallbackAndRunD(_ => {
const self = this
source.features.addCallbackAndRunD((_) => {
const layerName = source.layer.layerDef.id
for (const updater of self._notifiers) {
const neededBbox = updater.neededLayerBboxes.get(layerName)
@ -118,7 +123,5 @@ export default class MetaTagRecalculator {
}
}
})
}
}
}

View file

@ -1,22 +1,21 @@
import FeatureSource from "../FeatureSource";
import {Store} from "../../UIEventSource";
import {ElementStorage} from "../../ElementStorage";
import FeatureSource from "../FeatureSource"
import { Store } from "../../UIEventSource"
import { ElementStorage } from "../../ElementStorage"
/**
* Makes sure that every feature is added to the ElementsStorage, so that the tags-eventsource can be retrieved
*/
export default class RegisteringAllFromFeatureSourceActor {
public readonly features: Store<{ feature: any; freshness: Date }[]>;
public readonly name;
public readonly features: Store<{ feature: any; freshness: Date }[]>
public readonly name
constructor(source: FeatureSource, allElements: ElementStorage) {
this.features = source.features;
this.name = "RegisteringSource of " + source.name;
this.features.addCallbackAndRunD(features => {
this.features = source.features
this.name = "RegisteringSource of " + source.name
this.features.addCallbackAndRunD((features) => {
for (const feature of features) {
allElements.addOrGetElement(feature.feature)
}
})
}
}
}

View file

@ -1,12 +1,12 @@
import FeatureSource, {Tiled} from "../FeatureSource";
import {Tiles} from "../../../Models/TileRange";
import {IdbLocalStorage} from "../../Web/IdbLocalStorage";
import {UIEventSource} from "../../UIEventSource";
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig";
import {BBox} from "../../BBox";
import SimpleFeatureSource from "../Sources/SimpleFeatureSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import Loc from "../../../Models/Loc";
import FeatureSource, { Tiled } from "../FeatureSource"
import { Tiles } from "../../../Models/TileRange"
import { IdbLocalStorage } from "../../Web/IdbLocalStorage"
import { UIEventSource } from "../../UIEventSource"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import { BBox } from "../../BBox"
import SimpleFeatureSource from "../Sources/SimpleFeatureSource"
import FilteredLayer from "../../../Models/FilteredLayer"
import Loc from "../../../Models/Loc"
/***
* Saves all the features that are passed in to localstorage, so they can be retrieved on the next run
@ -15,20 +15,23 @@ import Loc from "../../../Models/Loc";
*/
export default class SaveTileToLocalStorageActor {
private readonly visitedTiles: UIEventSource<Map<number, Date>>
private readonly _layer: LayerConfig;
private readonly _layer: LayerConfig
private readonly _flayer: FilteredLayer
private readonly initializeTime = new Date()
constructor(layer: FilteredLayer) {
this._flayer = layer
this._layer = layer.layerDef
this.visitedTiles = IdbLocalStorage.Get("visited_tiles_" + this._layer.id,
{defaultValue: new Map<number, Date>(),})
this.visitedTiles.stabilized(100).addCallbackAndRunD(tiles => {
this.visitedTiles = IdbLocalStorage.Get("visited_tiles_" + this._layer.id, {
defaultValue: new Map<number, Date>(),
})
this.visitedTiles.stabilized(100).addCallbackAndRunD((tiles) => {
for (const key of Array.from(tiles.keys())) {
const tileFreshness = tiles.get(key)
const toOld = (this.initializeTime.getTime() - tileFreshness.getTime()) > 1000 * this._layer.maxAgeOfCache
const toOld =
this.initializeTime.getTime() - tileFreshness.getTime() >
1000 * this._layer.maxAgeOfCache
if (toOld) {
// Purge this tile
this.SetIdb(key, undefined)
@ -37,27 +40,28 @@ export default class SaveTileToLocalStorageActor {
}
}
this.visitedTiles.ping()
return true;
return true
})
}
public LoadTilesFromDisk(currentBounds: UIEventSource<BBox>, location: UIEventSource<Loc>,
registerFreshness: (tileId: number, freshness: Date) => void,
registerTile: ((src: FeatureSource & Tiled) => void)) {
const self = this;
public LoadTilesFromDisk(
currentBounds: UIEventSource<BBox>,
location: UIEventSource<Loc>,
registerFreshness: (tileId: number, freshness: Date) => void,
registerTile: (src: FeatureSource & Tiled) => void
) {
const self = this
const loadedTiles = new Set<number>()
this.visitedTiles.addCallbackD(tiles => {
this.visitedTiles.addCallbackD((tiles) => {
if (tiles.size === 0) {
// We don't do anything yet as probably not yet loaded from disk
// We'll unregister later on
return;
return
}
currentBounds.addCallbackAndRunD(bbox => {
currentBounds.addCallbackAndRunD((bbox) => {
if (self._layer.minzoomVisible > location.data.zoom) {
// Not enough zoom
return;
return
}
// Iterate over all available keys in the local storage, check which are needed and fresh enough
@ -71,32 +75,35 @@ export default class SaveTileToLocalStorageActor {
registerFreshness(key, tileFreshness)
const tileBbox = BBox.fromTileIndex(key)
if (!bbox.overlapsWith(tileBbox)) {
continue;
continue
}
if (loadedTiles.has(key)) {
// Already loaded earlier
continue
}
loadedTiles.add(key)
this.GetIdb(key).then((features: { feature: any, freshness: Date }[]) => {
if(features === undefined){
return;
this.GetIdb(key).then((features: { feature: any; freshness: Date }[]) => {
if (features === undefined) {
return
}
console.debug("Loaded tile " + self._layer.id + "_" + key + " from disk")
const src = new SimpleFeatureSource(self._flayer, key, new UIEventSource<{ feature: any; freshness: Date }[]>(features))
const src = new SimpleFeatureSource(
self._flayer,
key,
new UIEventSource<{ feature: any; freshness: Date }[]>(features)
)
registerTile(src)
})
}
})
return true; // Remove the callback
return true // Remove the callback
})
}
public addTile(tile: FeatureSource & Tiled) {
const self = this
tile.features.addCallbackAndRunD(features => {
tile.features.addCallbackAndRunD((features) => {
const now = new Date()
if (features.length > 0) {
@ -109,11 +116,10 @@ export default class SaveTileToLocalStorageActor {
public poison(lon: number, lat: number) {
for (let z = 0; z < 25; z++) {
const {x, y} = Tiles.embedded_tile(lat, lon, z)
const { x, y } = Tiles.embedded_tile(lat, lon, z)
const tileId = Tiles.tile_index(z, x, y)
this.visitedTiles.data.delete(tileId)
}
}
public MarkVisited(tileId: number, freshness: Date) {
@ -125,11 +131,18 @@ export default class SaveTileToLocalStorageActor {
try {
IdbLocalStorage.SetDirectly(this._layer.id + "_" + tileIndex, data)
} catch (e) {
console.error("Could not save tile to indexed-db: ", e, "tileIndex is:", tileIndex, "for layer", this._layer.id)
console.error(
"Could not save tile to indexed-db: ",
e,
"tileIndex is:",
tileIndex,
"for layer",
this._layer.id
)
}
}
private GetIdb(tileIndex) {
return IdbLocalStorage.GetDirectly(this._layer.id + "_" + tileIndex)
}
}
}

View file

@ -1,34 +1,33 @@
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import FilteringFeatureSource from "./Sources/FilteringFeatureSource";
import PerLayerFeatureSourceSplitter from "./PerLayerFeatureSourceSplitter";
import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "./FeatureSource";
import TiledFeatureSource from "./TiledFeatureSource/TiledFeatureSource";
import {Store, UIEventSource} from "../UIEventSource";
import {TileHierarchyTools} from "./TiledFeatureSource/TileHierarchy";
import RememberingSource from "./Sources/RememberingSource";
import OverpassFeatureSource from "../Actors/OverpassFeatureSource";
import GeoJsonSource from "./Sources/GeoJsonSource";
import Loc from "../../Models/Loc";
import RegisteringAllFromFeatureSourceActor from "./Actors/RegisteringAllFromFeatureSourceActor";
import SaveTileToLocalStorageActor from "./Actors/SaveTileToLocalStorageActor";
import DynamicGeoJsonTileSource from "./TiledFeatureSource/DynamicGeoJsonTileSource";
import {TileHierarchyMerger} from "./TiledFeatureSource/TileHierarchyMerger";
import RelationsTracker from "../Osm/RelationsTracker";
import {NewGeometryFromChangesFeatureSource} from "./Sources/NewGeometryFromChangesFeatureSource";
import ChangeGeometryApplicator from "./Sources/ChangeGeometryApplicator";
import {BBox} from "../BBox";
import OsmFeatureSource from "./TiledFeatureSource/OsmFeatureSource";
import {Tiles} from "../../Models/TileRange";
import TileFreshnessCalculator from "./TileFreshnessCalculator";
import FullNodeDatabaseSource from "./TiledFeatureSource/FullNodeDatabaseSource";
import MapState from "../State/MapState";
import {ElementStorage} from "../ElementStorage";
import {OsmFeature} from "../../Models/OsmFeature";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import {FilterState} from "../../Models/FilteredLayer";
import {GeoOperations} from "../GeoOperations";
import {Utils} from "../../Utils";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import FilteringFeatureSource from "./Sources/FilteringFeatureSource"
import PerLayerFeatureSourceSplitter from "./PerLayerFeatureSourceSplitter"
import FeatureSource, { FeatureSourceForLayer, IndexedFeatureSource, Tiled } from "./FeatureSource"
import TiledFeatureSource from "./TiledFeatureSource/TiledFeatureSource"
import { Store, UIEventSource } from "../UIEventSource"
import { TileHierarchyTools } from "./TiledFeatureSource/TileHierarchy"
import RememberingSource from "./Sources/RememberingSource"
import OverpassFeatureSource from "../Actors/OverpassFeatureSource"
import GeoJsonSource from "./Sources/GeoJsonSource"
import Loc from "../../Models/Loc"
import RegisteringAllFromFeatureSourceActor from "./Actors/RegisteringAllFromFeatureSourceActor"
import SaveTileToLocalStorageActor from "./Actors/SaveTileToLocalStorageActor"
import DynamicGeoJsonTileSource from "./TiledFeatureSource/DynamicGeoJsonTileSource"
import { TileHierarchyMerger } from "./TiledFeatureSource/TileHierarchyMerger"
import RelationsTracker from "../Osm/RelationsTracker"
import { NewGeometryFromChangesFeatureSource } from "./Sources/NewGeometryFromChangesFeatureSource"
import ChangeGeometryApplicator from "./Sources/ChangeGeometryApplicator"
import { BBox } from "../BBox"
import OsmFeatureSource from "./TiledFeatureSource/OsmFeatureSource"
import { Tiles } from "../../Models/TileRange"
import TileFreshnessCalculator from "./TileFreshnessCalculator"
import FullNodeDatabaseSource from "./TiledFeatureSource/FullNodeDatabaseSource"
import MapState from "../State/MapState"
import { ElementStorage } from "../ElementStorage"
import { OsmFeature } from "../../Models/OsmFeature"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { FilterState } from "../../Models/FilteredLayer"
import { GeoOperations } from "../GeoOperations"
import { Utils } from "../../Utils"
/**
* The features pipeline ties together a myriad of various datasources:
@ -42,12 +41,12 @@ import {Utils} from "../../Utils";
*
*/
export default class FeaturePipeline {
public readonly sufficientlyZoomed: Store<boolean>;
public readonly runningQuery: Store<boolean>;
public readonly timeout: UIEventSource<number>;
public readonly sufficientlyZoomed: Store<boolean>
public readonly runningQuery: Store<boolean>
public readonly timeout: UIEventSource<number>
public readonly somethingLoaded: UIEventSource<boolean> = new UIEventSource<boolean>(false)
public readonly newDataLoadedSignal: UIEventSource<FeatureSource> = new UIEventSource<FeatureSource>(undefined)
public readonly newDataLoadedSignal: UIEventSource<FeatureSource> =
new UIEventSource<FeatureSource>(undefined)
public readonly relationTracker: RelationsTracker
/**
* Keeps track of all raw OSM-nodes.
@ -55,19 +54,19 @@ export default class FeaturePipeline {
*/
public readonly fullNodeDatabase?: FullNodeDatabaseSource
private readonly overpassUpdater: OverpassFeatureSource
private state: MapState;
private readonly perLayerHierarchy: Map<string, TileHierarchyMerger>;
private state: MapState
private readonly perLayerHierarchy: Map<string, TileHierarchyMerger>
/**
* Keeps track of the age of the loaded data.
* Has one freshness-Calculator for every layer
* @private
*/
private readonly freshnesses = new Map<string, TileFreshnessCalculator>();
private readonly oldestAllowedDate: Date;
private readonly freshnesses = new Map<string, TileFreshnessCalculator>()
private readonly oldestAllowedDate: Date
private readonly osmSourceZoomLevel
private readonly localStorageSavers = new Map<string, SaveTileToLocalStorageActor>()
private readonly newGeometryHandler : NewGeometryFromChangesFeatureSource;
private readonly newGeometryHandler: NewGeometryFromChangesFeatureSource
constructor(
handleFeatureSource: (source: FeatureSourceForLayer & Tiled) => void,
@ -77,33 +76,40 @@ export default class FeaturePipeline {
handleRawFeatureSource: (source: FeatureSourceForLayer) => void
}
) {
this.state = state;
this.state = state
const self = this
const expiryInSeconds = Math.min(...state.layoutToUse?.layers?.map(l => l.maxAgeOfCache) ?? [])
this.oldestAllowedDate = new Date(new Date().getTime() - expiryInSeconds);
this.osmSourceZoomLevel = state.osmApiTileSize.data;
const useOsmApi = state.locationControl.map(l => l.zoom > (state.overpassMaxZoom.data ?? 12))
const expiryInSeconds = Math.min(
...(state.layoutToUse?.layers?.map((l) => l.maxAgeOfCache) ?? [])
)
this.oldestAllowedDate = new Date(new Date().getTime() - expiryInSeconds)
this.osmSourceZoomLevel = state.osmApiTileSize.data
const useOsmApi = state.locationControl.map(
(l) => l.zoom > (state.overpassMaxZoom.data ?? 12)
)
this.relationTracker = new RelationsTracker()
state.changes.allChanges.addCallbackAndRun(allChanges => {
allChanges.filter(ch => ch.id < 0 && ch.changes !== undefined)
.map(ch => ch.changes)
.filter(coor => coor["lat"] !== undefined && coor["lon"] !== undefined)
.forEach(coor => {
state.layoutToUse.layers.forEach(l => self.localStorageSavers.get(l.id)?.poison(coor["lon"], coor["lat"]))
state.changes.allChanges.addCallbackAndRun((allChanges) => {
allChanges
.filter((ch) => ch.id < 0 && ch.changes !== undefined)
.map((ch) => ch.changes)
.filter((coor) => coor["lat"] !== undefined && coor["lon"] !== undefined)
.forEach((coor) => {
state.layoutToUse.layers.forEach((l) =>
self.localStorageSavers.get(l.id)?.poison(coor["lon"], coor["lat"])
)
})
})
this.sufficientlyZoomed = state.locationControl.map(location => {
if (location?.zoom === undefined) {
return false;
}
let minzoom = Math.min(...state.filteredLayers.data.map(layer => layer.layerDef.minzoom ?? 18));
return location.zoom >= minzoom;
this.sufficientlyZoomed = state.locationControl.map((location) => {
if (location?.zoom === undefined) {
return false
}
);
let minzoom = Math.min(
...state.filteredLayers.data.map((layer) => layer.layerDef.minzoom ?? 18)
)
return location.zoom >= minzoom
})
const neededTilesFromOsm = this.getNeededTilesFromOsm(this.sufficientlyZoomed)
@ -111,9 +117,11 @@ export default class FeaturePipeline {
this.perLayerHierarchy = perLayerHierarchy
// Given a tile, wraps it and passes it on to render (handled by 'handleFeatureSource'
function patchedHandleFeatureSource(src: FeatureSourceForLayer & IndexedFeatureSource & Tiled) {
function patchedHandleFeatureSource(
src: FeatureSourceForLayer & IndexedFeatureSource & Tiled
) {
// This will already contain the merged features for this tile. In other words, this will only be triggered once for every tile
const withChanges = new ChangeGeometryApplicator(src, state.changes);
const withChanges = new ChangeGeometryApplicator(src, state.changes)
const srcFiltered = new FilteringFeatureSource(state, src.tileIndex, withChanges)
handleFeatureSource(srcFiltered)
@ -127,31 +135,29 @@ export default class FeaturePipeline {
function handlePriviligedFeatureSource(src: FeatureSourceForLayer & Tiled) {
// Passthrough to passed function, except that it registers as well
handleFeatureSource(src)
src.features.addCallbackAndRunD(fs => {
fs.forEach(ff => state.allElements.addOrGetElement(ff.feature))
src.features.addCallbackAndRunD((fs) => {
fs.forEach((ff) => state.allElements.addOrGetElement(ff.feature))
})
}
for (const filteredLayer of state.filteredLayers.data) {
const id = filteredLayer.layerDef.id
const source = filteredLayer.layerDef.source
const hierarchy = new TileHierarchyMerger(filteredLayer, (tile, _) => patchedHandleFeatureSource(tile))
const hierarchy = new TileHierarchyMerger(filteredLayer, (tile, _) =>
patchedHandleFeatureSource(tile)
)
perLayerHierarchy.set(id, hierarchy)
this.freshnesses.set(id, new TileFreshnessCalculator())
if (id === "type_node") {
this.fullNodeDatabase = new FullNodeDatabaseSource(
filteredLayer,
tile => {
new RegisteringAllFromFeatureSourceActor(tile, state.allElements)
perLayerHierarchy.get(tile.layer.layerDef.id).registerTile(tile)
tile.features.addCallbackAndRunD(_ => self.onNewDataLoaded(tile))
});
continue;
this.fullNodeDatabase = new FullNodeDatabaseSource(filteredLayer, (tile) => {
new RegisteringAllFromFeatureSourceActor(tile, state.allElements)
perLayerHierarchy.get(tile.layer.layerDef.id).registerTile(tile)
tile.features.addCallbackAndRunD((_) => self.onNewDataLoaded(tile))
})
continue
}
if (id === "gps_location") {
@ -187,13 +193,15 @@ export default class FeaturePipeline {
// We load the cached values and register them
// Getting data from upstream happens a bit lower
localTileSaver.LoadTilesFromDisk(
state.currentBounds, state.locationControl,
(tileIndex, freshness) => self.freshnesses.get(id).addTileLoad(tileIndex, freshness),
state.currentBounds,
state.locationControl,
(tileIndex, freshness) =>
self.freshnesses.get(id).addTileLoad(tileIndex, freshness),
(tile) => {
console.debug("Loaded tile ", id, tile.tileIndex, "from local cache")
new RegisteringAllFromFeatureSourceActor(tile, state.allElements)
hierarchy.registerTile(tile);
tile.features.addCallbackAndRunD(_ => self.onNewDataLoaded(tile))
hierarchy.registerTile(tile)
tile.features.addCallbackAndRunD((_) => self.onNewDataLoaded(tile))
}
)
@ -213,47 +221,48 @@ export default class FeaturePipeline {
registerTile: (tile) => {
new RegisteringAllFromFeatureSourceActor(tile, state.allElements)
perLayerHierarchy.get(id).registerTile(tile)
tile.features.addCallbackAndRunD(_ => self.onNewDataLoaded(tile))
}
tile.features.addCallbackAndRunD((_) => self.onNewDataLoaded(tile))
},
})
} else {
new RegisteringAllFromFeatureSourceActor(src, state.allElements)
perLayerHierarchy.get(id).registerTile(src)
src.features.addCallbackAndRunD(_ => self.onNewDataLoaded(src))
src.features.addCallbackAndRunD((_) => self.onNewDataLoaded(src))
}
} else {
new DynamicGeoJsonTileSource(
filteredLayer,
tile => {
(tile) => {
new RegisteringAllFromFeatureSourceActor(tile, state.allElements)
perLayerHierarchy.get(id).registerTile(tile)
tile.features.addCallbackAndRunD(_ => self.onNewDataLoaded(tile))
tile.features.addCallbackAndRunD((_) => self.onNewDataLoaded(tile))
},
state
)
}
}
const osmFeatureSource = new OsmFeatureSource({
isActive: useOsmApi,
neededTiles: neededTilesFromOsm,
handleTile: tile => {
handleTile: (tile) => {
new RegisteringAllFromFeatureSourceActor(tile, state.allElements)
if (tile.layer.layerDef.maxAgeOfCache > 0) {
const saver = self.localStorageSavers.get(tile.layer.layerDef.id)
if (saver === undefined) {
console.error("No localStorageSaver found for layer ", tile.layer.layerDef.id)
console.error(
"No localStorageSaver found for layer ",
tile.layer.layerDef.id
)
}
saver?.addTile(tile)
}
perLayerHierarchy.get(tile.layer.layerDef.id).registerTile(tile)
tile.features.addCallbackAndRunD(_ => self.onNewDataLoaded(tile))
tile.features.addCallbackAndRunD((_) => self.onNewDataLoaded(tile))
},
state: state,
markTileVisited: (tileId) =>
state.filteredLayers.data.forEach(flayer => {
state.filteredLayers.data.forEach((flayer) => {
const layer = flayer.layerDef
if (layer.maxAgeOfCache > 0) {
const saver = self.localStorageSavers.get(layer.id)
@ -264,110 +273,128 @@ export default class FeaturePipeline {
}
}
self.freshnesses.get(layer.id).addTileLoad(tileId, new Date())
})
}),
})
if (this.fullNodeDatabase !== undefined) {
osmFeatureSource.rawDataHandlers.push((osmJson, tileId) => this.fullNodeDatabase.handleOsmJson(osmJson, tileId))
osmFeatureSource.rawDataHandlers.push((osmJson, tileId) =>
this.fullNodeDatabase.handleOsmJson(osmJson, tileId)
)
}
const updater = this.initOverpassUpdater(state, useOsmApi)
this.overpassUpdater = updater;
this.overpassUpdater = updater
this.timeout = updater.timeout
// Actually load data from the overpass source
new PerLayerFeatureSourceSplitter(state.filteredLayers,
(source) => TiledFeatureSource.createHierarchy(source, {
layer: source.layer,
minZoomLevel: source.layer.layerDef.minzoom,
noDuplicates: true,
maxFeatureCount: state.layoutToUse.clustering.minNeededElements,
maxZoomLevel: state.layoutToUse.clustering.maxZoom,
registerTile: (tile) => {
// We save the tile data for the given layer to local storage - data sourced from overpass
self.localStorageSavers.get(tile.layer.layerDef.id)?.addTile(tile)
perLayerHierarchy.get(source.layer.layerDef.id).registerTile(new RememberingSource(tile))
tile.features.addCallbackAndRunD(f => {
if (f.length === 0) {
return
}
self.onNewDataLoaded(tile)
})
}
}),
new PerLayerFeatureSourceSplitter(
state.filteredLayers,
(source) =>
TiledFeatureSource.createHierarchy(source, {
layer: source.layer,
minZoomLevel: source.layer.layerDef.minzoom,
noDuplicates: true,
maxFeatureCount: state.layoutToUse.clustering.minNeededElements,
maxZoomLevel: state.layoutToUse.clustering.maxZoom,
registerTile: (tile) => {
// We save the tile data for the given layer to local storage - data sourced from overpass
self.localStorageSavers.get(tile.layer.layerDef.id)?.addTile(tile)
perLayerHierarchy
.get(source.layer.layerDef.id)
.registerTile(new RememberingSource(tile))
tile.features.addCallbackAndRunD((f) => {
if (f.length === 0) {
return
}
self.onNewDataLoaded(tile)
})
},
}),
updater,
{
handleLeftovers: (leftOvers) => {
console.warn("Overpass returned a few non-matched features:", leftOvers)
}
})
},
}
)
// Also load points/lines that are newly added.
const newGeometry = new NewGeometryFromChangesFeatureSource(state.changes, state.allElements, state.osmConnection._oauth_config.url)
this.newGeometryHandler = newGeometry;
newGeometry.features.addCallbackAndRun(geometries => {
// Also load points/lines that are newly added.
const newGeometry = new NewGeometryFromChangesFeatureSource(
state.changes,
state.allElements,
state.osmConnection._oauth_config.url
)
this.newGeometryHandler = newGeometry
newGeometry.features.addCallbackAndRun((geometries) => {
console.debug("New geometries are:", geometries)
})
new RegisteringAllFromFeatureSourceActor(newGeometry, state.allElements)
// A NewGeometryFromChangesFeatureSource does not split per layer, so we do this next
new PerLayerFeatureSourceSplitter(state.filteredLayers,
new PerLayerFeatureSourceSplitter(
state.filteredLayers,
(perLayer) => {
// We don't bother to split them over tiles as it'll contain little features by default, so we simply add them like this
perLayerHierarchy.get(perLayer.layer.layerDef.id).registerTile(perLayer)
// AT last, we always apply the metatags whenever possible
perLayer.features.addCallbackAndRunD(_ => {
self.onNewDataLoaded(perLayer);
perLayer.features.addCallbackAndRunD((_) => {
self.onNewDataLoaded(perLayer)
})
},
newGeometry,
{
handleLeftovers: (leftOvers) => {
console.warn("Got some leftovers from the filteredLayers: ", leftOvers)
}
},
}
)
this.runningQuery = updater.runningQuery.map(
overpass => {
console.log("FeaturePipeline: runningQuery state changed: Overpass", overpass ? "is querying," : "is idle,",
"osmFeatureSource is", osmFeatureSource.isRunning ? "is running and needs " + neededTilesFromOsm.data?.length + " tiles (already got " + osmFeatureSource.downloadedTiles.size + " tiles )" : "is idle")
return overpass || osmFeatureSource.isRunning.data;
}, [osmFeatureSource.isRunning]
(overpass) => {
console.log(
"FeaturePipeline: runningQuery state changed: Overpass",
overpass ? "is querying," : "is idle,",
"osmFeatureSource is",
osmFeatureSource.isRunning
? "is running and needs " +
neededTilesFromOsm.data?.length +
" tiles (already got " +
osmFeatureSource.downloadedTiles.size +
" tiles )"
: "is idle"
)
return overpass || osmFeatureSource.isRunning.data
},
[osmFeatureSource.isRunning]
)
}
public GetAllFeaturesWithin(bbox: BBox): OsmFeature[][] {
const self = this
const tiles: OsmFeature[][] = []
Array.from(this.perLayerHierarchy.keys())
.forEach(key => {
const fetched : OsmFeature[][] = self.GetFeaturesWithin(key, bbox)
tiles.push(...fetched);
})
return tiles;
Array.from(this.perLayerHierarchy.keys()).forEach((key) => {
const fetched: OsmFeature[][] = self.GetFeaturesWithin(key, bbox)
tiles.push(...fetched)
})
return tiles
}
public GetAllFeaturesAndMetaWithin(bbox: BBox, layerIdWhitelist?: Set<string>):
{features: OsmFeature[], layer: string}[] {
public GetAllFeaturesAndMetaWithin(
bbox: BBox,
layerIdWhitelist?: Set<string>
): { features: OsmFeature[]; layer: string }[] {
const self = this
const tiles :{features: any[], layer: string}[]= []
Array.from(this.perLayerHierarchy.keys())
.forEach(key => {
if(layerIdWhitelist !== undefined && !layerIdWhitelist.has(key)){
return;
}
return tiles.push({
layer: key,
features: [].concat(...self.GetFeaturesWithin(key, bbox))
});
const tiles: { features: any[]; layer: string }[] = []
Array.from(this.perLayerHierarchy.keys()).forEach((key) => {
if (layerIdWhitelist !== undefined && !layerIdWhitelist.has(key)) {
return
}
return tiles.push({
layer: key,
features: [].concat(...self.GetFeaturesWithin(key, bbox)),
})
return tiles;
})
return tiles
}
/**
@ -380,16 +407,24 @@ export default class FeaturePipeline {
}
const requestedHierarchy = this.perLayerHierarchy.get(layerId)
if (requestedHierarchy === undefined) {
console.warn("Layer ", layerId, "is not defined. Try one of ", Array.from(this.perLayerHierarchy.keys()))
return undefined;
console.warn(
"Layer ",
layerId,
"is not defined. Try one of ",
Array.from(this.perLayerHierarchy.keys())
)
return undefined
}
return TileHierarchyTools.getTiles(requestedHierarchy, bbox)
.filter(featureSource => featureSource.features?.data !== undefined)
.map(featureSource => featureSource.features.data.map(fs => fs.feature))
.filter((featureSource) => featureSource.features?.data !== undefined)
.map((featureSource) => featureSource.features.data.map((fs) => fs.feature))
}
public GetTilesPerLayerWithin(bbox: BBox, handleTile: (tile: FeatureSourceForLayer & Tiled) => void) {
Array.from(this.perLayerHierarchy.values()).forEach(hierarchy => {
public GetTilesPerLayerWithin(
bbox: BBox,
handleTile: (tile: FeatureSourceForLayer & Tiled) => void
) {
Array.from(this.perLayerHierarchy.values()).forEach((hierarchy) => {
TileHierarchyTools.getTiles(hierarchy, bbox).forEach(handleTile)
})
}
@ -399,16 +434,16 @@ export default class FeaturePipeline {
}
private freshnessForVisibleLayers(z: number, x: number, y: number): Date {
let oldestDate = undefined;
let oldestDate = undefined
for (const flayer of this.state.filteredLayers.data) {
if (!flayer.isDisplayed.data && !flayer.layerDef.forceLoad) {
continue
}
if (this.state.locationControl.data.zoom < flayer.layerDef.minzoom) {
continue;
continue
}
if (flayer.layerDef.maxAgeOfCache === 0) {
return undefined;
return undefined
}
const freshnessCalc = this.freshnesses.get(flayer.layerDef.id)
if (freshnessCalc === undefined) {
@ -428,117 +463,136 @@ export default class FeaturePipeline {
}
/*
* Gives an UIEventSource containing the tileIndexes of the tiles that should be loaded from OSM
* */
* Gives an UIEventSource containing the tileIndexes of the tiles that should be loaded from OSM
* */
private getNeededTilesFromOsm(isSufficientlyZoomed: Store<boolean>): Store<number[]> {
const self = this
return this.state.currentBounds.map(bbox => {
if (bbox === undefined) {
return []
}
if (!isSufficientlyZoomed.data) {
return [];
}
const osmSourceZoomLevel = self.osmSourceZoomLevel
const range = bbox.containingTileRange(osmSourceZoomLevel)
const tileIndexes = []
if (range.total >= 100) {
// Too much tiles!
return undefined
}
Tiles.MapRange(range, (x, y) => {
const i = Tiles.tile_index(osmSourceZoomLevel, x, y);
const oldestDate = self.freshnessForVisibleLayers(osmSourceZoomLevel, x, y)
if (oldestDate !== undefined && oldestDate > this.oldestAllowedDate) {
console.debug("Skipping tile", osmSourceZoomLevel, x, y, "as a decently fresh one is available")
// The cached tiles contain decently fresh data
return undefined;
return this.state.currentBounds.map(
(bbox) => {
if (bbox === undefined) {
return []
}
tileIndexes.push(i)
})
return tileIndexes
}, [isSufficientlyZoomed])
if (!isSufficientlyZoomed.data) {
return []
}
const osmSourceZoomLevel = self.osmSourceZoomLevel
const range = bbox.containingTileRange(osmSourceZoomLevel)
const tileIndexes = []
if (range.total >= 100) {
// Too much tiles!
return undefined
}
Tiles.MapRange(range, (x, y) => {
const i = Tiles.tile_index(osmSourceZoomLevel, x, y)
const oldestDate = self.freshnessForVisibleLayers(osmSourceZoomLevel, x, y)
if (oldestDate !== undefined && oldestDate > this.oldestAllowedDate) {
console.debug(
"Skipping tile",
osmSourceZoomLevel,
x,
y,
"as a decently fresh one is available"
)
// The cached tiles contain decently fresh data
return undefined
}
tileIndexes.push(i)
})
return tileIndexes
},
[isSufficientlyZoomed]
)
}
private initOverpassUpdater(state: {
allElements: ElementStorage;
layoutToUse: LayoutConfig,
currentBounds: Store<BBox>,
locationControl: Store<Loc>,
readonly overpassUrl: Store<string[]>;
readonly overpassTimeout: Store<number>;
readonly overpassMaxZoom: Store<number>,
}, useOsmApi: Store<boolean>): OverpassFeatureSource {
const minzoom = Math.min(...state.layoutToUse.layers.map(layer => layer.minzoom))
const overpassIsActive = state.currentBounds.map(bbox => {
if (bbox === undefined) {
console.debug("Disabling overpass source: no bbox")
return false
}
let zoom = state.locationControl.data.zoom
if (zoom < minzoom) {
// We are zoomed out over the zoomlevel of any layer
console.debug("Disabling overpass source: zoom < minzoom")
return false;
}
const range = bbox.containingTileRange(zoom)
if (range.total >= 5000) {
// Let's assume we don't have so much data cached
return true
}
const self = this;
const allFreshnesses = Tiles.MapRange(range, (x, y) => self.freshnessForVisibleLayers(zoom, x, y))
return allFreshnesses.some(freshness => freshness === undefined || freshness < this.oldestAllowedDate)
}, [state.locationControl])
const self = this;
const updater = new OverpassFeatureSource(state,
{
padToTiles: state.locationControl.map(l => Math.min(15, l.zoom + 1)),
relationTracker: this.relationTracker,
isActive: useOsmApi.map(b => !b && overpassIsActive.data, [overpassIsActive]),
freshnesses: this.freshnesses,
onBboxLoaded: (bbox, date, downloadedLayers, paddedToZoomLevel) => {
Tiles.MapRange(bbox.containingTileRange(paddedToZoomLevel), (x, y) => {
const tileIndex = Tiles.tile_index(paddedToZoomLevel, x, y)
downloadedLayers.forEach(layer => {
self.freshnesses.get(layer.id).addTileLoad(tileIndex, date)
self.localStorageSavers.get(layer.id)?.MarkVisited(tileIndex, date)
})
})
private initOverpassUpdater(
state: {
allElements: ElementStorage
layoutToUse: LayoutConfig
currentBounds: Store<BBox>
locationControl: Store<Loc>
readonly overpassUrl: Store<string[]>
readonly overpassTimeout: Store<number>
readonly overpassMaxZoom: Store<number>
},
useOsmApi: Store<boolean>
): OverpassFeatureSource {
const minzoom = Math.min(...state.layoutToUse.layers.map((layer) => layer.minzoom))
const overpassIsActive = state.currentBounds.map(
(bbox) => {
if (bbox === undefined) {
console.debug("Disabling overpass source: no bbox")
return false
}
let zoom = state.locationControl.data.zoom
if (zoom < minzoom) {
// We are zoomed out over the zoomlevel of any layer
console.debug("Disabling overpass source: zoom < minzoom")
return false
}
});
const range = bbox.containingTileRange(zoom)
if (range.total >= 5000) {
// Let's assume we don't have so much data cached
return true
}
const self = this
const allFreshnesses = Tiles.MapRange(range, (x, y) =>
self.freshnessForVisibleLayers(zoom, x, y)
)
return allFreshnesses.some(
(freshness) => freshness === undefined || freshness < this.oldestAllowedDate
)
},
[state.locationControl]
)
const self = this
const updater = new OverpassFeatureSource(state, {
padToTiles: state.locationControl.map((l) => Math.min(15, l.zoom + 1)),
relationTracker: this.relationTracker,
isActive: useOsmApi.map((b) => !b && overpassIsActive.data, [overpassIsActive]),
freshnesses: this.freshnesses,
onBboxLoaded: (bbox, date, downloadedLayers, paddedToZoomLevel) => {
Tiles.MapRange(bbox.containingTileRange(paddedToZoomLevel), (x, y) => {
const tileIndex = Tiles.tile_index(paddedToZoomLevel, x, y)
downloadedLayers.forEach((layer) => {
self.freshnesses.get(layer.id).addTileLoad(tileIndex, date)
self.localStorageSavers.get(layer.id)?.MarkVisited(tileIndex, date)
})
})
},
})
// Register everything in the state' 'AllElements'
new RegisteringAllFromFeatureSourceActor(updater, state.allElements)
return updater;
return updater
}
/**
* Builds upon 'GetAllFeaturesAndMetaWithin', but does stricter BBOX-checking and applies the filters
*/
public getAllVisibleElementsWithmeta(bbox: BBox): { center: [number, number], element: OsmFeature, layer: LayerConfig }[] {
public getAllVisibleElementsWithmeta(
bbox: BBox
): { center: [number, number]; element: OsmFeature; layer: LayerConfig }[] {
if (bbox === undefined) {
console.warn("No bbox")
return []
}
const layers = Utils.toIdRecord(this.state.layoutToUse.layers)
const elementsWithMeta: { features: OsmFeature[], layer: string }[] = this.GetAllFeaturesAndMetaWithin(bbox)
const elementsWithMeta: { features: OsmFeature[]; layer: string }[] =
this.GetAllFeaturesAndMetaWithin(bbox)
let elements: {center: [number, number], element: OsmFeature, layer: LayerConfig }[] = []
let elements: { center: [number, number]; element: OsmFeature; layer: LayerConfig }[] = []
let seenElements = new Set<string>()
for (const elementsWithMetaElement of elementsWithMeta) {
const layer = layers[elementsWithMetaElement.layer]
if(layer.title === undefined){
if (layer.title === undefined) {
continue
}
const filtered = this.state.filteredLayers.data.find(fl => fl.layerDef == layer);
const filtered = this.state.filteredLayers.data.find((fl) => fl.layerDef == layer)
for (let i = 0; i < elementsWithMetaElement.features.length; i++) {
const element = elementsWithMetaElement.features[i];
const element = elementsWithMetaElement.features[i]
if (!filtered.isDisplayed.data) {
continue
}
@ -552,35 +606,38 @@ export default class FeaturePipeline {
if (layer?.isShown !== undefined && !layer.isShown.matchesProperties(element)) {
continue
}
const activeFilters: FilterState[] = Array.from(filtered.appliedFilters.data.values());
if (!activeFilters.every(filter => filter?.currentFilter === undefined || filter?.currentFilter?.matchesProperties(element.properties))) {
const activeFilters: FilterState[] = Array.from(
filtered.appliedFilters.data.values()
)
if (
!activeFilters.every(
(filter) =>
filter?.currentFilter === undefined ||
filter?.currentFilter?.matchesProperties(element.properties)
)
) {
continue
}
const center = GeoOperations.centerpointCoordinates(element);
const center = GeoOperations.centerpointCoordinates(element)
elements.push({
element,
center,
layer: layers[elementsWithMetaElement.layer],
})
}
}
return elements;
return elements
}
/**
* Inject a new point
* Inject a new point
*/
InjectNewPoint(geojson) {
this.newGeometryHandler.features.data.push({
feature: geojson,
freshness: new Date()
freshness: new Date(),
})
this.newGeometryHandler.features.ping();
this.newGeometryHandler.features.ping()
}
}
}

View file

@ -1,19 +1,19 @@
import {Store, UIEventSource} from "../UIEventSource";
import FilteredLayer from "../../Models/FilteredLayer";
import {BBox} from "../BBox";
import {Feature, Geometry} from "@turf/turf";
import {OsmFeature} from "../../Models/OsmFeature";
import { Store, UIEventSource } from "../UIEventSource"
import FilteredLayer from "../../Models/FilteredLayer"
import { BBox } from "../BBox"
import { Feature, Geometry } from "@turf/turf"
import { OsmFeature } from "../../Models/OsmFeature"
export default interface FeatureSource {
features: Store<{ feature: OsmFeature, freshness: Date }[]>;
features: Store<{ feature: OsmFeature; freshness: Date }[]>
/**
* Mainly used for debuging
*/
name: string;
name: string
}
export interface Tiled {
tileIndex: number,
tileIndex: number
bbox: BBox
}

View file

@ -1,8 +1,7 @@
import FeatureSource, {FeatureSourceForLayer, Tiled} from "./FeatureSource";
import {Store} from "../UIEventSource";
import FilteredLayer from "../../Models/FilteredLayer";
import SimpleFeatureSource from "./Sources/SimpleFeatureSource";
import FeatureSource, { FeatureSourceForLayer, Tiled } from "./FeatureSource"
import { Store } from "../UIEventSource"
import FilteredLayer from "../../Models/FilteredLayer"
import SimpleFeatureSource from "./Sources/SimpleFeatureSource"
/**
* In some rare cases, some elements are shown on multiple layers (when 'passthrough' is enabled)
@ -10,30 +9,30 @@ import SimpleFeatureSource from "./Sources/SimpleFeatureSource";
* In any case, this featureSource marks the objects with _matching_layer_id
*/
export default class PerLayerFeatureSourceSplitter {
constructor(layers: Store<FilteredLayer[]>,
handleLayerData: (source: FeatureSourceForLayer & Tiled) => void,
upstream: FeatureSource,
options?: {
tileIndex?: number,
handleLeftovers?: (featuresWithoutLayer: any[]) => void
}) {
constructor(
layers: Store<FilteredLayer[]>,
handleLayerData: (source: FeatureSourceForLayer & Tiled) => void,
upstream: FeatureSource,
options?: {
tileIndex?: number
handleLeftovers?: (featuresWithoutLayer: any[]) => void
}
) {
const knownLayers = new Map<string, SimpleFeatureSource>()
function update() {
const features = upstream.features?.data;
const features = upstream.features?.data
if (features === undefined) {
return;
return
}
if (layers.data === undefined || layers.data.length === 0) {
return;
return
}
// We try to figure out (for each feature) in which feature store it should be saved.
// Note that this splitter is only run when it is invoked by the overpass feature source, so we can't be sure in which layer it should go
const featuresPerLayer = new Map<string, { feature, freshness } []>();
const featuresPerLayer = new Map<string, { feature; freshness }[]>()
const noLayerFound = []
for (const layer of layers.data) {
@ -41,19 +40,19 @@ export default class PerLayerFeatureSourceSplitter {
}
for (const f of features) {
let foundALayer = false;
let foundALayer = false
for (const layer of layers.data) {
if (layer.layerDef.source.osmTags.matchesProperties(f.feature.properties)) {
// We have found our matching layer!
featuresPerLayer.get(layer.layerDef.id).push(f)
foundALayer = true;
foundALayer = true
if (!layer.layerDef.passAllFeatures) {
// If not 'passAllFeatures', we are done for this feature
break
break
}
}
}
if(!foundALayer){
if (!foundALayer) {
noLayerFound.push(f)
}
}
@ -61,11 +60,11 @@ export default class PerLayerFeatureSourceSplitter {
// At this point, we have our features per layer as a list
// We assign them to the correct featureSources
for (const layer of layers.data) {
const id = layer.layerDef.id;
const id = layer.layerDef.id
const features = featuresPerLayer.get(id)
if (features === undefined) {
// No such features for this layer
continue;
continue
}
let featureSource = knownLayers.get(id)
@ -86,7 +85,7 @@ export default class PerLayerFeatureSourceSplitter {
}
}
layers.addCallback(_ => update())
upstream.features.addCallbackAndRunD(_ => update())
layers.addCallback((_) => update())
upstream.features.addCallbackAndRunD((_) => update())
}
}
}

View file

@ -1,52 +1,52 @@
/**
* Applies geometry changes from 'Changes' onto every feature of a featureSource
*/
import {Changes} from "../../Osm/Changes";
import {UIEventSource} from "../../UIEventSource";
import {FeatureSourceForLayer, IndexedFeatureSource} from "../FeatureSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import {ChangeDescription, ChangeDescriptionTools} from "../../Osm/Actions/ChangeDescription";
import { Changes } from "../../Osm/Changes"
import { UIEventSource } from "../../UIEventSource"
import { FeatureSourceForLayer, IndexedFeatureSource } from "../FeatureSource"
import FilteredLayer from "../../../Models/FilteredLayer"
import { ChangeDescription, ChangeDescriptionTools } from "../../Osm/Actions/ChangeDescription"
export default class ChangeGeometryApplicator implements FeatureSourceForLayer {
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]);
public readonly name: string;
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]> =
new UIEventSource<{ feature: any; freshness: Date }[]>([])
public readonly name: string
public readonly layer: FilteredLayer
private readonly source: IndexedFeatureSource;
private readonly changes: Changes;
private readonly source: IndexedFeatureSource
private readonly changes: Changes
constructor(source: (IndexedFeatureSource & FeatureSourceForLayer), changes: Changes) {
this.source = source;
this.changes = changes;
constructor(source: IndexedFeatureSource & FeatureSourceForLayer, changes: Changes) {
this.source = source
this.changes = changes
this.layer = source.layer
this.name = "ChangesApplied(" + source.name + ")"
this.features = new UIEventSource<{ feature: any; freshness: Date }[]>(undefined)
const self = this;
source.features.addCallbackAndRunD(_ => self.update())
changes.allChanges.addCallbackAndRunD(_ => self.update())
const self = this
source.features.addCallbackAndRunD((_) => self.update())
changes.allChanges.addCallbackAndRunD((_) => self.update())
}
private update() {
const upstreamFeatures = this.source.features.data
const upstreamIds = this.source.containedIds.data
const changesToApply = this.changes.allChanges.data
?.filter(ch =>
const changesToApply = this.changes.allChanges.data?.filter(
(ch) =>
// Does upsteram have this element? If not, we skip
upstreamIds.has(ch.type + "/" + ch.id) &&
// Are any (geometry) changes defined?
ch.changes !== undefined &&
// Ignore new elements, they are handled by the NewGeometryFromChangesFeatureSource
ch.id > 0)
ch.id > 0
)
if (changesToApply === undefined || changesToApply.length === 0) {
// No changes to apply!
// Pass the original feature and lets continue our day
this.features.setData(upstreamFeatures);
return;
this.features.setData(upstreamFeatures)
return
}
const changesPerId = new Map<string, ChangeDescription[]>()
@ -58,27 +58,32 @@ export default class ChangeGeometryApplicator implements FeatureSourceForLayer {
changesPerId.set(key, [ch])
}
}
const newFeatures: { feature: any, freshness: Date }[] = []
const newFeatures: { feature: any; freshness: Date }[] = []
for (const feature of upstreamFeatures) {
const changesForFeature = changesPerId.get(feature.feature.properties.id)
if (changesForFeature === undefined) {
// No changes for this element
newFeatures.push(feature)
continue;
continue
}
// Allright! We have a feature to rewrite!
const copy = {
...feature
...feature,
}
// We only apply the last change as that one'll have the latest geometry
const change = changesForFeature[changesForFeature.length - 1]
copy.feature.geometry = ChangeDescriptionTools.getGeojsonGeometry(change)
console.log("Applying a geometry change onto:", feature,"The change is:", change,"which becomes:", copy)
console.log(
"Applying a geometry change onto:",
feature,
"The change is:",
change,
"which becomes:",
copy
)
newFeatures.push(copy)
}
this.features.setData(newFeatures)
}
}
}

View file

@ -1,99 +1,112 @@
import {UIEventSource} from "../../UIEventSource";
import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import {Tiles} from "../../../Models/TileRange";
import {BBox} from "../../BBox";
import { UIEventSource } from "../../UIEventSource"
import FeatureSource, { FeatureSourceForLayer, IndexedFeatureSource, Tiled } from "../FeatureSource"
import FilteredLayer from "../../../Models/FilteredLayer"
import { Tiles } from "../../../Models/TileRange"
import { BBox } from "../../BBox"
export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled, IndexedFeatureSource {
public features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]);
public readonly name;
export default class FeatureSourceMerger
implements FeatureSourceForLayer, Tiled, IndexedFeatureSource
{
public features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<
{ feature: any; freshness: Date }[]
>([])
public readonly name
public readonly layer: FilteredLayer
public readonly tileIndex: number;
public readonly bbox: BBox;
public readonly containedIds: UIEventSource<Set<string>> = new UIEventSource<Set<string>>(new Set())
private readonly _sources: UIEventSource<FeatureSource[]>;
public readonly tileIndex: number
public readonly bbox: BBox
public readonly containedIds: UIEventSource<Set<string>> = new UIEventSource<Set<string>>(
new Set()
)
private readonly _sources: UIEventSource<FeatureSource[]>
/**
* Merges features from different featureSources for a single layer
* Uses the freshest feature available in the case multiple sources offer data with the same identifier
*/
constructor(layer: FilteredLayer, tileIndex: number, bbox: BBox, sources: UIEventSource<FeatureSource[]>) {
this.tileIndex = tileIndex;
this.bbox = bbox;
this._sources = sources;
this.layer = layer;
this.name = "FeatureSourceMerger(" + layer.layerDef.id + ", " + Tiles.tile_from_index(tileIndex).join(",") + ")"
const self = this;
constructor(
layer: FilteredLayer,
tileIndex: number,
bbox: BBox,
sources: UIEventSource<FeatureSource[]>
) {
this.tileIndex = tileIndex
this.bbox = bbox
this._sources = sources
this.layer = layer
this.name =
"FeatureSourceMerger(" +
layer.layerDef.id +
", " +
Tiles.tile_from_index(tileIndex).join(",") +
")"
const self = this
const handledSources = new Set<FeatureSource>();
const handledSources = new Set<FeatureSource>()
sources.addCallbackAndRunD(sources => {
let newSourceRegistered = false;
sources.addCallbackAndRunD((sources) => {
let newSourceRegistered = false
for (let i = 0; i < sources.length; i++) {
let source = sources[i];
let source = sources[i]
if (handledSources.has(source)) {
continue
}
handledSources.add(source)
newSourceRegistered = true
source.features.addCallback(() => {
self.Update();
});
self.Update()
})
if (newSourceRegistered) {
self.Update();
self.Update()
}
}
})
}
private Update() {
let somethingChanged = false;
const all: Map<string, { feature: any, freshness: Date }> = new Map<string, { feature: any; freshness: Date }>();
let somethingChanged = false
const all: Map<string, { feature: any; freshness: Date }> = new Map<
string,
{ feature: any; freshness: Date }
>()
// We seed the dictionary with the previously loaded features
const oldValues = this.features.data ?? [];
const oldValues = this.features.data ?? []
for (const oldValue of oldValues) {
all.set(oldValue.feature.id, oldValue)
}
for (const source of this._sources.data) {
if (source?.features?.data === undefined) {
continue;
continue
}
for (const f of source.features.data) {
const id = f.feature.properties.id;
const id = f.feature.properties.id
if (!all.has(id)) {
// This is a new feature
somethingChanged = true;
all.set(id, f);
continue;
somethingChanged = true
all.set(id, f)
continue
}
// This value has been seen already, either in a previous run or by a previous datasource
// Let's figure out if something changed
const oldV = all.get(id);
const oldV = all.get(id)
if (oldV.freshness < f.freshness) {
// Jup, this feature is fresher
all.set(id, f);
somethingChanged = true;
all.set(id, f)
somethingChanged = true
}
}
}
if (!somethingChanged) {
// We don't bother triggering an update
return;
return
}
const newList = [];
const newList = []
all.forEach((value, _) => {
newList.push(value)
})
this.containedIds.setData(new Set(all.keys()))
this.features.setData(newList);
this.features.setData(newList)
}
}
}

View file

@ -1,34 +1,35 @@
import {Store, UIEventSource} from "../../UIEventSource";
import FilteredLayer, {FilterState} from "../../../Models/FilteredLayer";
import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import {BBox} from "../../BBox";
import {ElementStorage} from "../../ElementStorage";
import {TagsFilter} from "../../Tags/TagsFilter";
import {OsmFeature} from "../../../Models/OsmFeature";
import { Store, UIEventSource } from "../../UIEventSource"
import FilteredLayer, { FilterState } from "../../../Models/FilteredLayer"
import { FeatureSourceForLayer, Tiled } from "../FeatureSource"
import { BBox } from "../../BBox"
import { ElementStorage } from "../../ElementStorage"
import { TagsFilter } from "../../Tags/TagsFilter"
import { OsmFeature } from "../../../Models/OsmFeature"
export default class FilteringFeatureSource implements FeatureSourceForLayer, Tiled {
public features: UIEventSource<{ feature: any; freshness: Date }[]> =
new UIEventSource<{ feature: any; freshness: Date }[]>([]);
public readonly name;
public readonly layer: FilteredLayer;
public features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<
{ feature: any; freshness: Date }[]
>([])
public readonly name
public readonly layer: FilteredLayer
public readonly tileIndex: number
public readonly bbox: BBox
private readonly upstream: FeatureSourceForLayer;
private readonly upstream: FeatureSourceForLayer
private readonly state: {
locationControl: Store<{ zoom: number }>;
selectedElement: Store<any>,
globalFilters: Store<{ filter: FilterState }[]>,
locationControl: Store<{ zoom: number }>
selectedElement: Store<any>
globalFilters: Store<{ filter: FilterState }[]>
allElements: ElementStorage
};
private readonly _alreadyRegistered = new Set<UIEventSource<any>>();
}
private readonly _alreadyRegistered = new Set<UIEventSource<any>>()
private readonly _is_dirty = new UIEventSource(false)
private previousFeatureSet: Set<any> = undefined;
private previousFeatureSet: Set<any> = undefined
constructor(
state: {
locationControl: Store<{ zoom: number }>,
selectedElement: Store<any>,
allElements: ElementStorage,
locationControl: Store<{ zoom: number }>
selectedElement: Store<any>
allElements: ElementStorage
globalFilters: Store<{ filter: FilterState }[]>
},
tileIndex,
@ -41,92 +42,95 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer, Ti
this.upstream = upstream
this.state = state
this.layer = upstream.layer;
const layer = upstream.layer;
const self = this;
this.layer = upstream.layer
const layer = upstream.layer
const self = this
upstream.features.addCallback(() => {
self.update();
});
layer.appliedFilters.addCallback(_ => {
self.update()
})
this._is_dirty.stabilized(1000).addCallbackAndRunD(dirty => {
layer.appliedFilters.addCallback((_) => {
self.update()
})
this._is_dirty.stabilized(1000).addCallbackAndRunD((dirty) => {
if (dirty) {
self.update()
}
})
metataggingUpdated?.addCallback(_ => {
metataggingUpdated?.addCallback((_) => {
self._is_dirty.setData(true)
})
state.globalFilters.addCallback(_ => {
state.globalFilters.addCallback((_) => {
self.update()
})
this.update();
this.update()
}
private update() {
const self = this;
const layer = this.upstream.layer;
const features: { feature: OsmFeature; freshness: Date }[] = (this.upstream.features.data ?? []);
const includedFeatureIds = new Set<string>();
const globalFilters = self.state.globalFilters.data.map(f => f.filter);
const self = this
const layer = this.upstream.layer
const features: { feature: OsmFeature; freshness: Date }[] =
this.upstream.features.data ?? []
const includedFeatureIds = new Set<string>()
const globalFilters = self.state.globalFilters.data.map((f) => f.filter)
const newFeatures = (features ?? []).filter((f) => {
self.registerCallback(f.feature)
const isShown: TagsFilter = layer.layerDef.isShown;
const tags = f.feature.properties;
if (isShown !== undefined && !isShown.matchesProperties(tags) ) {
return false;
const isShown: TagsFilter = layer.layerDef.isShown
const tags = f.feature.properties
if (isShown !== undefined && !isShown.matchesProperties(tags)) {
return false
}
const tagsFilter = Array.from(layer.appliedFilters?.data?.values() ?? [])
for (const filter of tagsFilter) {
const neededTags: TagsFilter = filter?.currentFilter
if (neededTags !== undefined && !neededTags.matchesProperties(f.feature.properties)) {
if (
neededTags !== undefined &&
!neededTags.matchesProperties(f.feature.properties)
) {
// Hidden by the filter on the layer itself - we want to hide it no matter what
return false;
return false
}
}
for (const filter of globalFilters) {
const neededTags: TagsFilter = filter?.currentFilter
if (neededTags !== undefined && !neededTags.matchesProperties(f.feature.properties)) {
if (
neededTags !== undefined &&
!neededTags.matchesProperties(f.feature.properties)
) {
// Hidden by the filter on the layer itself - we want to hide it no matter what
return false;
return false
}
}
includedFeatureIds.add(f.feature.properties.id)
return true;
});
return true
})
const previousSet = this.previousFeatureSet;
const previousSet = this.previousFeatureSet
this._is_dirty.setData(false)
// Is there any difference between the two sets?
if (previousSet !== undefined && previousSet.size === includedFeatureIds.size) {
// The size of the sets is the same - they _might_ be identical
const newItemFound = Array.from(includedFeatureIds).some(id => !previousSet.has(id))
const newItemFound = Array.from(includedFeatureIds).some((id) => !previousSet.has(id))
if (!newItemFound) {
// We know that:
// We know that:
// - The sets have the same size
// - Every item from the new set has been found in the old set
// which means they are identical!
return;
return
}
}
// Something new has been found!
this.features.setData(newFeatures);
this.features.setData(newFeatures)
}
private registerCallback(feature: any) {
@ -139,11 +143,10 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer, Ti
}
this._alreadyRegistered.add(src)
const self = this;
const self = this
// Add a callback as a changed tag migh change the filter
src.addCallbackAndRunD(_ => {
src.addCallbackAndRunD((_) => {
self._is_dirty.setData(true)
})
}
}

View file

@ -1,168 +1,163 @@
/**
* Fetches a geojson file somewhere and passes it along
*/
import {UIEventSource} from "../../UIEventSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import {Utils} from "../../../Utils";
import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import {Tiles} from "../../../Models/TileRange";
import {BBox} from "../../BBox";
import {GeoOperations} from "../../GeoOperations";
import { UIEventSource } from "../../UIEventSource"
import FilteredLayer from "../../../Models/FilteredLayer"
import { Utils } from "../../../Utils"
import { FeatureSourceForLayer, Tiled } from "../FeatureSource"
import { Tiles } from "../../../Models/TileRange"
import { BBox } from "../../BBox"
import { GeoOperations } from "../../GeoOperations"
export default class GeoJsonSource implements FeatureSourceForLayer, Tiled {
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>;
public readonly state = new UIEventSource<undefined | {error: string} | "loaded">(undefined)
public readonly name;
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>
public readonly state = new UIEventSource<undefined | { error: string } | "loaded">(undefined)
public readonly name
public readonly isOsmCache: boolean
public readonly layer: FilteredLayer;
public readonly layer: FilteredLayer
public readonly tileIndex
public readonly bbox;
private readonly seenids: Set<string>;
private readonly idKey ?: string;
public constructor(flayer: FilteredLayer,
zxy?: [number, number, number] | BBox,
options?: {
featureIdBlacklist?: Set<string>
}) {
public readonly bbox
private readonly seenids: Set<string>
private readonly idKey?: string
public constructor(
flayer: FilteredLayer,
zxy?: [number, number, number] | BBox,
options?: {
featureIdBlacklist?: Set<string>
}
) {
if (flayer.layerDef.source.geojsonZoomLevel !== undefined && zxy === undefined) {
throw "Dynamic layers are not supported. Use 'DynamicGeoJsonTileSource instead"
}
this.layer = flayer;
this.layer = flayer
this.idKey = flayer.layerDef.source.idKey
this.seenids = options?.featureIdBlacklist ?? new Set<string>()
let url = flayer.layerDef.source.geojsonSource.replace("{layer}", flayer.layerDef.id);
let url = flayer.layerDef.source.geojsonSource.replace("{layer}", flayer.layerDef.id)
if (zxy !== undefined) {
let tile_bbox: BBox;
let tile_bbox: BBox
if (zxy instanceof BBox) {
tile_bbox = zxy;
tile_bbox = zxy
} else {
const [z, x, y] = zxy;
tile_bbox = BBox.fromTile(z, x, y);
const [z, x, y] = zxy
tile_bbox = BBox.fromTile(z, x, y)
this.tileIndex = Tiles.tile_index(z, x, y)
this.bbox = BBox.fromTile(z, x, y)
url = url
.replace('{z}', "" + z)
.replace('{x}', "" + x)
.replace('{y}', "" + y)
.replace("{z}", "" + z)
.replace("{x}", "" + x)
.replace("{y}", "" + y)
}
let bounds: { minLat: number, maxLat: number, minLon: number, maxLon: number } = tile_bbox
let bounds: { minLat: number; maxLat: number; minLon: number; maxLon: number } =
tile_bbox
if (this.layer.layerDef.source.mercatorCrs) {
bounds = tile_bbox.toMercator()
}
url = url
.replace('{y_min}', "" + bounds.minLat)
.replace('{y_max}', "" + bounds.maxLat)
.replace('{x_min}', "" + bounds.minLon)
.replace('{x_max}', "" + bounds.maxLon)
.replace("{y_min}", "" + bounds.minLat)
.replace("{y_max}", "" + bounds.maxLat)
.replace("{x_min}", "" + bounds.minLon)
.replace("{x_max}", "" + bounds.maxLon)
} else {
this.tileIndex = Tiles.tile_index(0, 0, 0)
this.bbox = BBox.global;
this.bbox = BBox.global
}
this.name = "GeoJsonSource of " + url;
this.name = "GeoJsonSource of " + url
this.isOsmCache = flayer.layerDef.source.isOsmCacheLayer;
this.isOsmCache = flayer.layerDef.source.isOsmCacheLayer
this.features = new UIEventSource<{ feature: any; freshness: Date }[]>([])
this.LoadJSONFrom(url)
}
private LoadJSONFrom(url: string) {
const eventSource = this.features;
const self = this;
const eventSource = this.features
const self = this
Utils.downloadJsonCached(url, 60 * 60)
.then(json => {
.then((json) => {
self.state.setData("loaded")
// TODO: move somewhere else, just for testing
// Check for maproulette data
if (url.startsWith("https://maproulette.org/api/v2/tasks/box/")) {
console.log("MapRoulette data detected")
const data = json;
let maprouletteFeatures: any[] = [];
data.forEach(element => {
const data = json
let maprouletteFeatures: any[] = []
data.forEach((element) => {
maprouletteFeatures.push({
type: "Feature",
geometry: {
type: "Point",
coordinates: [element.point.lng, element.point.lat]
coordinates: [element.point.lng, element.point.lat],
},
properties: {
// Map all properties to the feature
...element,
}
});
});
json.features = maprouletteFeatures;
},
})
})
json.features = maprouletteFeatures
}
if (json.features === undefined || json.features === null) {
return;
return
}
if (self.layer.layerDef.source.mercatorCrs) {
json = GeoOperations.GeoJsonToWGS84(json)
}
const time = new Date();
const newFeatures: { feature: any, freshness: Date } [] = []
let i = 0;
let skipped = 0;
const time = new Date()
const newFeatures: { feature: any; freshness: Date }[] = []
let i = 0
let skipped = 0
for (const feature of json.features) {
const props = feature.properties
for (const key in props) {
if(props[key] === null){
if (props[key] === null) {
delete props[key]
}
if (typeof props[key] !== "string") {
// Make sure all the values are string, it crashes stuff otherwise
props[key] = JSON.stringify(props[key])
}
}
if(self.idKey !== undefined){
if (self.idKey !== undefined) {
props.id = props[self.idKey]
}
if (props.id === undefined) {
props.id = url + "/" + i;
feature.id = url + "/" + i;
i++;
props.id = url + "/" + i
feature.id = url + "/" + i
i++
}
if (self.seenids.has(props.id)) {
skipped++;
continue;
skipped++
continue
}
self.seenids.add(props.id)
let freshness: Date = time;
let freshness: Date = time
if (feature.properties["_last_edit:timestamp"] !== undefined) {
freshness = new Date(props["_last_edit:timestamp"])
}
newFeatures.push({feature: feature, freshness: freshness})
newFeatures.push({ feature: feature, freshness: freshness })
}
if (newFeatures.length == 0) {
return;
return
}
eventSource.setData(eventSource.data.concat(newFeatures))
}).catch(msg => {
console.debug("Could not load geojson layer", url, "due to", msg);
self.state.setData({error: msg})
})
})
.catch((msg) => {
console.debug("Could not load geojson layer", url, "due to", msg)
self.state.setData({ error: msg })
})
}
}

View file

@ -1,49 +1,50 @@
import {Changes} from "../../Osm/Changes";
import {OsmNode, OsmObject, OsmRelation, OsmWay} from "../../Osm/OsmObject";
import FeatureSource from "../FeatureSource";
import {UIEventSource} from "../../UIEventSource";
import {ChangeDescription} from "../../Osm/Actions/ChangeDescription";
import {ElementStorage} from "../../ElementStorage";
import { Changes } from "../../Osm/Changes"
import { OsmNode, OsmObject, OsmRelation, OsmWay } from "../../Osm/OsmObject"
import FeatureSource from "../FeatureSource"
import { UIEventSource } from "../../UIEventSource"
import { ChangeDescription } from "../../Osm/Actions/ChangeDescription"
import { ElementStorage } from "../../ElementStorage"
import { OsmId, OsmTags } from "../../../Models/OsmFeature"
export class NewGeometryFromChangesFeatureSource implements FeatureSource {
// This class name truly puts the 'Java' into 'Javascript'
/**
* A feature source containing exclusively new elements.
*
*
* These elements are probably created by the 'SimpleAddUi' which generates a new point, but the import functionality might create a line or polygon too.
* Other sources of new points are e.g. imports from nodes
*/
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]);
public readonly name: string = "newFeatures";
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]> =
new UIEventSource<{ feature: any; freshness: Date }[]>([])
public readonly name: string = "newFeatures"
constructor(changes: Changes, allElementStorage: ElementStorage, backendUrl: string) {
const seenChanges = new Set<ChangeDescription>()
const features = this.features.data
const self = this
const seenChanges = new Set<ChangeDescription>();
const features = this.features.data;
const self = this;
changes.pendingChanges.stabilized(100).addCallbackAndRunD(changes => {
changes.pendingChanges.stabilized(100).addCallbackAndRunD((changes) => {
if (changes.length === 0) {
return;
return
}
const now = new Date();
let somethingChanged = false;
const now = new Date()
let somethingChanged = false
function add(feature) {
feature.id = feature.properties.id
features.push({
feature: feature,
freshness: now
freshness: now,
})
somethingChanged = true;
somethingChanged = true
}
for (const change of changes) {
if (seenChanges.has(change)) {
// Already handled
continue;
continue
}
seenChanges.add(change)
@ -59,38 +60,36 @@ export class NewGeometryFromChangesFeatureSource implements FeatureSource {
// For this, we introspect the change
if (allElementStorage.has(change.type + "/" + change.id)) {
// The current point already exists, we don't have to do anything here
continue;
continue
}
console.debug("Detected a reused point")
// The 'allElementsStore' does _not_ have this point yet, so we have to create it
OsmObject.DownloadObjectAsync(change.type + "/" + change.id).then(feat => {
OsmObject.DownloadObjectAsync(change.type + "/" + change.id).then((feat) => {
console.log("Got the reused point:", feat)
for (const kv of change.tags) {
feat.tags[kv.k] = kv.v
}
const geojson = feat.asGeoJson();
const geojson = feat.asGeoJson()
allElementStorage.addOrGetElement(geojson)
self.features.data.push({feature: geojson, freshness: new Date()})
self.features.data.push({ feature: geojson, freshness: new Date() })
self.features.ping()
})
continue
} else if (change.id < 0 && change.changes === undefined) {
// The geometry is not described - not a new point
if (change.id < 0) {
console.error("WARNING: got a new point without geometry!")
}
continue;
continue
}
try {
const tags = {}
const tags: OsmTags = {
id: <OsmId>(change.type + "/" + change.id),
}
for (const kv of change.tags) {
tags[kv.k] = kv.v
}
tags["id"] = change.type + "/" + change.id
tags["_backend"] = backendUrl
@ -102,30 +101,31 @@ export class NewGeometryFromChangesFeatureSource implements FeatureSource {
n.lon = change.changes["lon"]
const geojson = n.asGeoJson()
add(geojson)
break;
break
case "way":
const w = new OsmWay(change.id)
w.tags = tags
w.nodes = change.changes["nodes"]
w.coordinates = change.changes["coordinates"].map(([lon, lat]) => [lat, lon])
w.coordinates = change.changes["coordinates"].map(([lon, lat]) => [
lat,
lon,
])
add(w.asGeoJson())
break;
break
case "relation":
const r = new OsmRelation(change.id)
r.tags = tags
r.members = change.changes["members"]
add(r.asGeoJson())
break;
break
}
} catch (e) {
console.error("Could not generate a new geometry to render on screen for:", e)
}
}
if (somethingChanged) {
self.features.ping()
}
})
}
}
}

View file

@ -2,34 +2,36 @@
* Every previously added point is remembered, but new points are added.
* Data coming from upstream will always overwrite a previous value
*/
import FeatureSource, {Tiled} from "../FeatureSource";
import {Store, UIEventSource} from "../../UIEventSource";
import {BBox} from "../../BBox";
import FeatureSource, { Tiled } from "../FeatureSource"
import { Store, UIEventSource } from "../../UIEventSource"
import { BBox } from "../../BBox"
export default class RememberingSource implements FeatureSource, Tiled {
public readonly features: Store<{ feature: any, freshness: Date }[]>;
public readonly name;
public readonly features: Store<{ feature: any; freshness: Date }[]>
public readonly name
public readonly tileIndex: number
public readonly bbox: BBox
constructor(source: FeatureSource & Tiled) {
const self = this;
this.name = "RememberingSource of " + source.name;
const self = this
this.name = "RememberingSource of " + source.name
this.tileIndex = source.tileIndex
this.bbox = source.bbox;
this.bbox = source.bbox
const empty = [];
const featureSource = new UIEventSource<{feature: any, freshness: Date}[]>(empty)
const empty = []
const featureSource = new UIEventSource<{ feature: any; freshness: Date }[]>(empty)
this.features = featureSource
source.features.addCallbackAndRunD(features => {
const oldFeatures = self.features?.data ?? empty;
source.features.addCallbackAndRunD((features) => {
const oldFeatures = self.features?.data ?? empty
// Then new ids
const ids = new Set<string>(features.map(f => f.feature.properties.id + f.feature.geometry.type));
const ids = new Set<string>(
features.map((f) => f.feature.properties.id + f.feature.geometry.type)
)
// the old data
const oldData = oldFeatures.filter(old => !ids.has(old.feature.properties.id + old.feature.geometry.type))
const oldData = oldFeatures.filter(
(old) => !ids.has(old.feature.properties.id + old.feature.geometry.type)
)
featureSource.setData([...features, ...oldData])
})
}
}
}

View file

@ -1,50 +1,57 @@
/**
* This feature source helps the ShowDataLayer class: it introduces the necessary extra features and indicates with what renderConfig it should be rendered.
*/
import {Store} from "../../UIEventSource";
import {GeoOperations} from "../../GeoOperations";
import FeatureSource from "../FeatureSource";
import PointRenderingConfig from "../../../Models/ThemeConfig/PointRenderingConfig";
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig";
import LineRenderingConfig from "../../../Models/ThemeConfig/LineRenderingConfig";
import { Store } from "../../UIEventSource"
import { GeoOperations } from "../../GeoOperations"
import FeatureSource from "../FeatureSource"
import PointRenderingConfig from "../../../Models/ThemeConfig/PointRenderingConfig"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import LineRenderingConfig from "../../../Models/ThemeConfig/LineRenderingConfig"
export default class RenderingMultiPlexerFeatureSource {
public readonly features: Store<(any & { pointRenderingIndex: number | undefined, lineRenderingIndex: number | undefined })[]>;
private readonly pointRenderings: { rendering: PointRenderingConfig; index: number }[];
private centroidRenderings: { rendering: PointRenderingConfig; index: number }[];
private projectedCentroidRenderings: { rendering: PointRenderingConfig; index: number }[];
private startRenderings: { rendering: PointRenderingConfig; index: number }[];
private endRenderings: { rendering: PointRenderingConfig; index: number }[];
private hasCentroid: boolean;
private lineRenderObjects: LineRenderingConfig[];
public readonly features: Store<
(any & {
pointRenderingIndex: number | undefined
lineRenderingIndex: number | undefined
})[]
>
private readonly pointRenderings: { rendering: PointRenderingConfig; index: number }[]
private centroidRenderings: { rendering: PointRenderingConfig; index: number }[]
private projectedCentroidRenderings: { rendering: PointRenderingConfig; index: number }[]
private startRenderings: { rendering: PointRenderingConfig; index: number }[]
private endRenderings: { rendering: PointRenderingConfig; index: number }[]
private hasCentroid: boolean
private lineRenderObjects: LineRenderingConfig[]
private inspectFeature(feat, addAsPoint: (feat, rendering, centerpoint: [number, number]) => void, withIndex: any[]){
private inspectFeature(
feat,
addAsPoint: (feat, rendering, centerpoint: [number, number]) => void,
withIndex: any[]
) {
if (feat.geometry.type === "Point") {
for (const rendering of this.pointRenderings) {
withIndex.push({
...feat,
pointRenderingIndex: rendering.index
pointRenderingIndex: rendering.index,
})
}
} else {
// This is a a line: add the centroids
let centerpoint: [number, number] = undefined;
let projectedCenterPoint : [number, number] = undefined
if(this.hasCentroid){
centerpoint = GeoOperations.centerpointCoordinates(feat)
if(this.projectedCentroidRenderings.length > 0){
projectedCenterPoint = <[number,number]> GeoOperations.nearestPoint(feat, centerpoint).geometry.coordinates
let centerpoint: [number, number] = undefined
let projectedCenterPoint: [number, number] = undefined
if (this.hasCentroid) {
centerpoint = GeoOperations.centerpointCoordinates(feat)
if (this.projectedCentroidRenderings.length > 0) {
projectedCenterPoint = <[number, number]>(
GeoOperations.nearestPoint(feat, centerpoint).geometry.coordinates
)
}
}
for (const rendering of this.centroidRenderings) {
addAsPoint(feat, rendering, centerpoint)
}
if (feat.geometry.type === "LineString") {
for (const rendering of this.projectedCentroidRenderings) {
addAsPoint(feat, rendering, projectedCenterPoint)
}
@ -58,73 +65,69 @@ export default class RenderingMultiPlexerFeatureSource {
const coordinate = coordinates[coordinates.length - 1]
addAsPoint(feat, rendering, coordinate)
}
}else{
} else {
for (const rendering of this.projectedCentroidRenderings) {
addAsPoint(feat, rendering, centerpoint)
}
}
// AT last, add it 'as is' to what we should render
// AT last, add it 'as is' to what we should render
for (let i = 0; i < this.lineRenderObjects.length; i++) {
withIndex.push({
...feat,
lineRenderingIndex: i
lineRenderingIndex: i,
})
}
}
}
constructor(upstream: FeatureSource, layer: LayerConfig) {
const pointRenderObjects: { rendering: PointRenderingConfig, index: number }[] = layer.mapRendering.map((r, i) => ({
rendering: r,
index: i
}))
this.pointRenderings = pointRenderObjects.filter(r => r.rendering.location.has("point"))
this.centroidRenderings = pointRenderObjects.filter(r => r.rendering.location.has("centroid"))
this.projectedCentroidRenderings = pointRenderObjects.filter(r => r.rendering.location.has("projected_centerpoint"))
this.startRenderings = pointRenderObjects.filter(r => r.rendering.location.has("start"))
this.endRenderings = pointRenderObjects.filter(r => r.rendering.location.has("end"))
this.hasCentroid = this.centroidRenderings.length > 0 || this.projectedCentroidRenderings.length > 0
const pointRenderObjects: { rendering: PointRenderingConfig; index: number }[] =
layer.mapRendering.map((r, i) => ({
rendering: r,
index: i,
}))
this.pointRenderings = pointRenderObjects.filter((r) => r.rendering.location.has("point"))
this.centroidRenderings = pointRenderObjects.filter((r) =>
r.rendering.location.has("centroid")
)
this.projectedCentroidRenderings = pointRenderObjects.filter((r) =>
r.rendering.location.has("projected_centerpoint")
)
this.startRenderings = pointRenderObjects.filter((r) => r.rendering.location.has("start"))
this.endRenderings = pointRenderObjects.filter((r) => r.rendering.location.has("end"))
this.hasCentroid =
this.centroidRenderings.length > 0 || this.projectedCentroidRenderings.length > 0
this.lineRenderObjects = layer.lineRendering
this.features = upstream.features.map(
features => {
if (features === undefined) {
return undefined;
}
const withIndex: any[] = [];
function addAsPoint(feat, rendering, coordinate) {
const patched = {
...feat,
pointRenderingIndex: rendering.index
}
patched.geometry = {
type: "Point",
coordinates: coordinate
}
withIndex.push(patched)
}
for (const f of features) {
const feat = f.feature;
if(feat === undefined){
continue
}
this.inspectFeature(feat, addAsPoint, withIndex)
}
return withIndex;
this.features = upstream.features.map((features) => {
if (features === undefined) {
return undefined
}
);
const withIndex: any[] = []
function addAsPoint(feat, rendering, coordinate) {
const patched = {
...feat,
pointRenderingIndex: rendering.index,
}
patched.geometry = {
type: "Point",
coordinates: coordinate,
}
withIndex.push(patched)
}
for (const f of features) {
const feat = f.feature
if (feat === undefined) {
continue
}
this.inspectFeature(feat, addAsPoint, withIndex)
}
return withIndex
})
}
}
}

View file

@ -1,21 +1,24 @@
import {UIEventSource} from "../../UIEventSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import {BBox} from "../../BBox";
import { UIEventSource } from "../../UIEventSource"
import FilteredLayer from "../../../Models/FilteredLayer"
import { FeatureSourceForLayer, Tiled } from "../FeatureSource"
import { BBox } from "../../BBox"
export default class SimpleFeatureSource implements FeatureSourceForLayer, Tiled {
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>;
public readonly name: string = "SimpleFeatureSource";
public readonly layer: FilteredLayer;
public readonly bbox: BBox = BBox.global;
public readonly tileIndex: number;
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>
public readonly name: string = "SimpleFeatureSource"
public readonly layer: FilteredLayer
public readonly bbox: BBox = BBox.global
public readonly tileIndex: number
constructor(layer: FilteredLayer, tileIndex: number, featureSource?: UIEventSource<{ feature: any; freshness: Date }[]> ) {
constructor(
layer: FilteredLayer,
tileIndex: number,
featureSource?: UIEventSource<{ feature: any; freshness: Date }[]>
) {
this.name = "SimpleFeatureSource(" + layer.layerDef.id + ")"
this.layer = layer
this.tileIndex = tileIndex ?? 0;
this.tileIndex = tileIndex ?? 0
this.bbox = BBox.fromTileIndex(this.tileIndex)
this.features = featureSource ?? new UIEventSource<{ feature: any; freshness: Date }[]>([]);
this.features = featureSource ?? new UIEventSource<{ feature: any; freshness: Date }[]>([])
}
}
}

View file

@ -1,62 +1,90 @@
import FeatureSource, {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import {ImmutableStore, Store, UIEventSource} from "../../UIEventSource";
import {stat} from "fs";
import FilteredLayer from "../../../Models/FilteredLayer";
import {BBox} from "../../BBox";
import {Feature} from "@turf/turf";
import FeatureSource, { FeatureSourceForLayer, Tiled } from "../FeatureSource"
import { ImmutableStore, Store, UIEventSource } from "../../UIEventSource"
import { stat } from "fs"
import FilteredLayer from "../../../Models/FilteredLayer"
import { BBox } from "../../BBox"
import { Feature } from "@turf/turf"
/**
* A simple, read only feature store.
*/
export default class StaticFeatureSource implements FeatureSource {
public readonly features: Store<{ feature: any; freshness: Date }[]>;
public readonly features: Store<{ feature: any; freshness: Date }[]>
public readonly name: string
constructor(features: Store<{ feature: Feature, freshness: Date }[]>, name = "StaticFeatureSource") {
constructor(
features: Store<{ feature: Feature; freshness: Date }[]>,
name = "StaticFeatureSource"
) {
if (features === undefined) {
throw "Static feature source received undefined as source"
}
this.name = name;
this.features = features;
this.name = name
this.features = features
}
public static fromGeojsonAndDate(features: { feature: Feature, freshness: Date }[], name = "StaticFeatureSourceFromGeojsonAndDate"): StaticFeatureSource {
return new StaticFeatureSource(new ImmutableStore(features), name);
public static fromGeojsonAndDate(
features: { feature: Feature; freshness: Date }[],
name = "StaticFeatureSourceFromGeojsonAndDate"
): StaticFeatureSource {
return new StaticFeatureSource(new ImmutableStore(features), name)
}
public static fromGeojson(geojson: Feature[], name = "StaticFeatureSourceFromGeojson"): StaticFeatureSource {
const now = new Date();
return StaticFeatureSource.fromGeojsonAndDate(geojson.map(feature => ({feature, freshness: now})), name);
public static fromGeojson(
geojson: Feature[],
name = "StaticFeatureSourceFromGeojson"
): StaticFeatureSource {
const now = new Date()
return StaticFeatureSource.fromGeojsonAndDate(
geojson.map((feature) => ({ feature, freshness: now })),
name
)
}
public static fromGeojsonStore(geojson: Store<Feature[]>, name = "StaticFeatureSourceFromGeojson"): StaticFeatureSource {
const now = new Date();
const mapped : Store<{feature: Feature, freshness: Date}[]> = geojson.map(features => features.map(feature => ({feature, freshness: now})))
return new StaticFeatureSource(mapped, name);
public static fromGeojsonStore(
geojson: Store<Feature[]>,
name = "StaticFeatureSourceFromGeojson"
): StaticFeatureSource {
const now = new Date()
const mapped: Store<{ feature: Feature; freshness: Date }[]> = geojson.map((features) =>
features.map((feature) => ({ feature, freshness: now }))
)
return new StaticFeatureSource(mapped, name)
}
static fromDateless(featureSource: Store<{ feature: Feature }[]>, name = "StaticFeatureSourceFromDateless") {
const now = new Date();
return new StaticFeatureSource(featureSource.map(features => features.map(feature => ({
feature: feature.feature,
freshness: now
}))), name);
static fromDateless(
featureSource: Store<{ feature: Feature }[]>,
name = "StaticFeatureSourceFromDateless"
) {
const now = new Date()
return new StaticFeatureSource(
featureSource.map((features) =>
features.map((feature) => ({
feature: feature.feature,
freshness: now,
}))
),
name
)
}
}
export class TiledStaticFeatureSource extends StaticFeatureSource implements Tiled, FeatureSourceForLayer{
export class TiledStaticFeatureSource
extends StaticFeatureSource
implements Tiled, FeatureSourceForLayer
{
public readonly bbox: BBox = BBox.global
public readonly tileIndex: number
public readonly layer: FilteredLayer
public readonly bbox: BBox = BBox.global;
public readonly tileIndex: number;
public readonly layer: FilteredLayer;
constructor(features: Store<{ feature: any, freshness: Date }[]>, layer: FilteredLayer ,tileIndex : number = 0) {
super(features);
this.tileIndex = tileIndex ;
this.layer= layer;
constructor(
features: Store<{ feature: any; freshness: Date }[]>,
layer: FilteredLayer,
tileIndex: number = 0
) {
super(features)
this.tileIndex = tileIndex
this.layer = layer
this.bbox = BBox.fromTileIndex(this.tileIndex)
}
}

View file

@ -1,12 +1,11 @@
import {Tiles} from "../../Models/TileRange";
import { Tiles } from "../../Models/TileRange"
export default class TileFreshnessCalculator {
/**
* All the freshnesses per tile index
* @private
*/
private readonly freshnesses = new Map<number, Date>();
private readonly freshnesses = new Map<number, Date>()
/**
* Marks that some data got loaded for this layer
@ -16,14 +15,14 @@ export default class TileFreshnessCalculator {
public addTileLoad(tileId: number, freshness: Date) {
const existingFreshness = this.freshnessFor(...Tiles.tile_from_index(tileId))
if (existingFreshness >= freshness) {
return;
return
}
this.freshnesses.set(tileId, freshness)
// Do we have freshness for the neighbouring tiles? If so, we can mark the tile above as loaded too!
let [z, x, y] = Tiles.tile_from_index(tileId)
if (z === 0) {
return;
return
}
x = x - (x % 2) // Make the tiles always even
y = y - (y % 2)
@ -48,11 +47,7 @@ export default class TileFreshnessCalculator {
const leastFresh = Math.min(ul, ur, ll, lr)
const date = new Date()
date.setTime(leastFresh)
this.addTileLoad(
Tiles.tile_index(z - 1, Math.floor(x / 2), Math.floor(y / 2)),
date
)
this.addTileLoad(Tiles.tile_index(z - 1, Math.floor(x / 2), Math.floor(y / 2)), date)
}
public freshnessFor(z: number, x: number, y: number): Date {
@ -65,7 +60,5 @@ export default class TileFreshnessCalculator {
}
// recurse up
return this.freshnessFor(z - 1, Math.floor(x / 2), Math.floor(y / 2))
}
}
}

View file

@ -1,21 +1,22 @@
import FilteredLayer from "../../../Models/FilteredLayer";
import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import {UIEventSource} from "../../UIEventSource";
import DynamicTileSource from "./DynamicTileSource";
import {Utils} from "../../../Utils";
import GeoJsonSource from "../Sources/GeoJsonSource";
import {BBox} from "../../BBox";
import FilteredLayer from "../../../Models/FilteredLayer"
import { FeatureSourceForLayer, Tiled } from "../FeatureSource"
import { UIEventSource } from "../../UIEventSource"
import DynamicTileSource from "./DynamicTileSource"
import { Utils } from "../../../Utils"
import GeoJsonSource from "../Sources/GeoJsonSource"
import { BBox } from "../../BBox"
export default class DynamicGeoJsonTileSource extends DynamicTileSource {
private static whitelistCache = new Map<string, any>()
constructor(layer: FilteredLayer,
registerLayer: (layer: FeatureSourceForLayer & Tiled) => void,
state: {
locationControl?: UIEventSource<{zoom?: number}>
currentBounds: UIEventSource<BBox>
}) {
constructor(
layer: FilteredLayer,
registerLayer: (layer: FeatureSourceForLayer & Tiled) => void,
state: {
locationControl?: UIEventSource<{ zoom?: number }>
currentBounds: UIEventSource<BBox>
}
) {
const source = layer.layerDef.source
if (source.geojsonZoomLevel === undefined) {
throw "Invalid layer: geojsonZoomLevel expected"
@ -26,7 +27,6 @@ export default class DynamicGeoJsonTileSource extends DynamicTileSource {
let whitelist = undefined
if (source.geojsonSource.indexOf("{x}_{y}.geojson") > 0) {
const whitelistUrl = source.geojsonSource
.replace("{z}", "" + source.geojsonZoomLevel)
.replace("{x}_{y}.geojson", "overview.json")
@ -35,26 +35,33 @@ export default class DynamicGeoJsonTileSource extends DynamicTileSource {
if (DynamicGeoJsonTileSource.whitelistCache.has(whitelistUrl)) {
whitelist = DynamicGeoJsonTileSource.whitelistCache.get(whitelistUrl)
} else {
Utils.downloadJsonCached(whitelistUrl, 1000 * 60 * 60).then(
json => {
const data = new Map<number, Set<number>>();
Utils.downloadJsonCached(whitelistUrl, 1000 * 60 * 60)
.then((json) => {
const data = new Map<number, Set<number>>()
for (const x in json) {
if (x === "zoom") {
continue
}
data.set(Number(x), new Set(json[x]))
}
console.log("The whitelist is", data, "based on ", json, "from", whitelistUrl)
console.log(
"The whitelist is",
data,
"based on ",
json,
"from",
whitelistUrl
)
whitelist = data
DynamicGeoJsonTileSource.whitelistCache.set(whitelistUrl, whitelist)
}
).catch(err => {
console.warn("No whitelist found for ", layer.layerDef.id, err)
})
})
.catch((err) => {
console.warn("No whitelist found for ", layer.layerDef.id, err)
})
}
}
const blackList = (new Set<string>())
const blackList = new Set<string>()
super(
layer,
source.geojsonZoomLevel,
@ -62,29 +69,28 @@ export default class DynamicGeoJsonTileSource extends DynamicTileSource {
if (whitelist !== undefined) {
const isWhiteListed = whitelist.get(zxy[1])?.has(zxy[2])
if (!isWhiteListed) {
console.debug("Not downloading tile", ...zxy, "as it is not on the whitelist")
return undefined;
console.debug(
"Not downloading tile",
...zxy,
"as it is not on the whitelist"
)
return undefined
}
}
const src = new GeoJsonSource(
layer,
zxy,
{
featureIdBlacklist: blackList
}
)
const src = new GeoJsonSource(layer, zxy, {
featureIdBlacklist: blackList,
})
registerLayer(src)
return src
},
state
);
)
}
public static RegisterWhitelist(url: string, json: any) {
const data = new Map<number, Set<number>>();
const data = new Map<number, Set<number>>()
for (const x in json) {
if (x === "zoom") {
continue
@ -93,5 +99,4 @@ export default class DynamicGeoJsonTileSource extends DynamicTileSource {
}
DynamicGeoJsonTileSource.whitelistCache.set(url, data)
}
}
}

View file

@ -1,64 +1,80 @@
import FilteredLayer from "../../../Models/FilteredLayer";
import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import {UIEventSource} from "../../UIEventSource";
import TileHierarchy from "./TileHierarchy";
import {Tiles} from "../../../Models/TileRange";
import {BBox} from "../../BBox";
import FilteredLayer from "../../../Models/FilteredLayer"
import { FeatureSourceForLayer, Tiled } from "../FeatureSource"
import { UIEventSource } from "../../UIEventSource"
import TileHierarchy from "./TileHierarchy"
import { Tiles } from "../../../Models/TileRange"
import { BBox } from "../../BBox"
/***
* A tiled source which dynamically loads the required tiles at a fixed zoom level
*/
export default class DynamicTileSource implements TileHierarchy<FeatureSourceForLayer & Tiled> {
public readonly loadedTiles: Map<number, FeatureSourceForLayer & Tiled>;
private readonly _loadedTiles = new Set<number>();
public readonly loadedTiles: Map<number, FeatureSourceForLayer & Tiled>
private readonly _loadedTiles = new Set<number>()
constructor(
layer: FilteredLayer,
zoomlevel: number,
constructTile: (zxy: [number, number, number]) => (FeatureSourceForLayer & Tiled),
constructTile: (zxy: [number, number, number]) => FeatureSourceForLayer & Tiled,
state: {
currentBounds: UIEventSource<BBox>;
locationControl?: UIEventSource<{zoom?: number}>
currentBounds: UIEventSource<BBox>
locationControl?: UIEventSource<{ zoom?: number }>
}
) {
const self = this;
const self = this
this.loadedTiles = new Map<number, FeatureSourceForLayer & Tiled>()
const neededTiles = state.currentBounds.map(
bounds => {
if (bounds === undefined) {
// We'll retry later
return undefined
}
if (!layer.isDisplayed.data && !layer.layerDef.forceLoad) {
// No need to download! - the layer is disabled
return undefined;
}
const neededTiles = state.currentBounds
.map(
(bounds) => {
if (bounds === undefined) {
// We'll retry later
return undefined
}
if (state.locationControl?.data?.zoom !== undefined && state.locationControl.data.zoom < layer.layerDef.minzoom) {
// No need to download! - the layer is disabled
return undefined;
}
if (!layer.isDisplayed.data && !layer.layerDef.forceLoad) {
// No need to download! - the layer is disabled
return undefined
}
const tileRange = Tiles.TileRangeBetween(zoomlevel, bounds.getNorth(), bounds.getEast(), bounds.getSouth(), bounds.getWest())
if (tileRange.total > 10000) {
console.error("Got a really big tilerange, bounds and location might be out of sync")
return undefined
}
if (
state.locationControl?.data?.zoom !== undefined &&
state.locationControl.data.zoom < layer.layerDef.minzoom
) {
// No need to download! - the layer is disabled
return undefined
}
const needed = Tiles.MapRange(tileRange, (x, y) => Tiles.tile_index(zoomlevel, x, y)).filter(i => !self._loadedTiles.has(i))
if (needed.length === 0) {
return undefined
}
return needed
}
, [layer.isDisplayed, state.locationControl]).stabilized(250);
const tileRange = Tiles.TileRangeBetween(
zoomlevel,
bounds.getNorth(),
bounds.getEast(),
bounds.getSouth(),
bounds.getWest()
)
if (tileRange.total > 10000) {
console.error(
"Got a really big tilerange, bounds and location might be out of sync"
)
return undefined
}
neededTiles.addCallbackAndRunD(neededIndexes => {
const needed = Tiles.MapRange(tileRange, (x, y) =>
Tiles.tile_index(zoomlevel, x, y)
).filter((i) => !self._loadedTiles.has(i))
if (needed.length === 0) {
return undefined
}
return needed
},
[layer.isDisplayed, state.locationControl]
)
.stabilized(250)
neededTiles.addCallbackAndRunD((neededIndexes) => {
console.log("Tiled geojson source ", layer.layerDef.id, " needs", neededIndexes)
if (neededIndexes === undefined) {
return;
return
}
for (const neededIndex of neededIndexes) {
self._loadedTiles.add(neededIndex)
@ -68,10 +84,5 @@ export default class DynamicTileSource implements TileHierarchy<FeatureSourceFor
}
}
})
}
}

View file

@ -1,30 +1,26 @@
import TileHierarchy from "./TileHierarchy";
import FeatureSource, {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import {OsmNode, OsmObject, OsmWay} from "../../Osm/OsmObject";
import SimpleFeatureSource from "../Sources/SimpleFeatureSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import {UIEventSource} from "../../UIEventSource";
import TileHierarchy from "./TileHierarchy"
import FeatureSource, { FeatureSourceForLayer, Tiled } from "../FeatureSource"
import { OsmNode, OsmObject, OsmWay } from "../../Osm/OsmObject"
import SimpleFeatureSource from "../Sources/SimpleFeatureSource"
import FilteredLayer from "../../../Models/FilteredLayer"
import { UIEventSource } from "../../UIEventSource"
export default class FullNodeDatabaseSource implements TileHierarchy<FeatureSource & Tiled> {
public readonly loadedTiles = new Map<number, FeatureSource & Tiled>()
private readonly onTileLoaded: (tile: (Tiled & FeatureSourceForLayer)) => void;
private readonly onTileLoaded: (tile: Tiled & FeatureSourceForLayer) => void
private readonly layer: FilteredLayer
private readonly nodeByIds = new Map<number, OsmNode>();
private readonly nodeByIds = new Map<number, OsmNode>()
private readonly parentWays = new Map<number, UIEventSource<OsmWay[]>>()
constructor(
layer: FilteredLayer,
onTileLoaded: ((tile: Tiled & FeatureSourceForLayer) => void)) {
constructor(layer: FilteredLayer, onTileLoaded: (tile: Tiled & FeatureSourceForLayer) => void) {
this.onTileLoaded = onTileLoaded
this.layer = layer;
this.layer = layer
if (this.layer === undefined) {
throw "Layer is undefined"
}
}
public handleOsmJson(osmJson: any, tileId: number) {
const allObjects = OsmObject.ParseObjects(osmJson.elements)
const nodesById = new Map<number, OsmNode>()
@ -32,7 +28,7 @@ export default class FullNodeDatabaseSource implements TileHierarchy<FeatureSour
if (osmObj.type !== "node") {
continue
}
const osmNode = <OsmNode>osmObj;
const osmNode = <OsmNode>osmObj
nodesById.set(osmNode.id, osmNode)
this.nodeByIds.set(osmNode.id, osmNode)
}
@ -41,33 +37,32 @@ export default class FullNodeDatabaseSource implements TileHierarchy<FeatureSour
if (osmObj.type !== "way") {
continue
}
const osmWay = <OsmWay>osmObj;
const osmWay = <OsmWay>osmObj
for (const nodeId of osmWay.nodes) {
if (!this.parentWays.has(nodeId)) {
const src = new UIEventSource<OsmWay[]>([])
this.parentWays.set(nodeId, src)
src.addCallback(parentWays => {
src.addCallback((parentWays) => {
const tgs = nodesById.get(nodeId).tags
tgs ["parent_ways"] = JSON.stringify(parentWays.map(w => w.tags))
tgs["parent_way_ids"] = JSON.stringify(parentWays.map(w => w.id))
tgs["parent_ways"] = JSON.stringify(parentWays.map((w) => w.tags))
tgs["parent_way_ids"] = JSON.stringify(parentWays.map((w) => w.id))
})
}
const src = this.parentWays.get(nodeId)
src.data.push(osmWay)
src.ping();
src.ping()
}
}
const now = new Date()
const asGeojsonFeatures = Array.from(nodesById.values()).map(osmNode => ({
feature: osmNode.asGeoJson(), freshness: now
const asGeojsonFeatures = Array.from(nodesById.values()).map((osmNode) => ({
feature: osmNode.asGeoJson(),
freshness: now,
}))
const featureSource = new SimpleFeatureSource(this.layer, tileId)
featureSource.features.setData(asGeojsonFeatures)
this.loadedTiles.set(tileId, featureSource)
this.onTileLoaded(featureSource)
}
/**
@ -88,6 +83,4 @@ export default class FullNodeDatabaseSource implements TileHierarchy<FeatureSour
public GetParentWays(nodeId: number): UIEventSource<OsmWay[]> {
return this.parentWays.get(nodeId)
}
}

View file

@ -1,17 +1,17 @@
import {Utils} from "../../../Utils";
import * as OsmToGeoJson from "osmtogeojson";
import StaticFeatureSource from "../Sources/StaticFeatureSource";
import PerLayerFeatureSourceSplitter from "../PerLayerFeatureSourceSplitter";
import {Store, UIEventSource} from "../../UIEventSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import {FeatureSourceForLayer, Tiled} from "../FeatureSource";
import {Tiles} from "../../../Models/TileRange";
import {BBox} from "../../BBox";
import LayoutConfig from "../../../Models/ThemeConfig/LayoutConfig";
import {Or} from "../../Tags/Or";
import {TagsFilter} from "../../Tags/TagsFilter";
import {OsmObject} from "../../Osm/OsmObject";
import {FeatureCollection} from "@turf/turf";
import { Utils } from "../../../Utils"
import * as OsmToGeoJson from "osmtogeojson"
import StaticFeatureSource from "../Sources/StaticFeatureSource"
import PerLayerFeatureSourceSplitter from "../PerLayerFeatureSourceSplitter"
import { Store, UIEventSource } from "../../UIEventSource"
import FilteredLayer from "../../../Models/FilteredLayer"
import { FeatureSourceForLayer, Tiled } from "../FeatureSource"
import { Tiles } from "../../../Models/TileRange"
import { BBox } from "../../BBox"
import LayoutConfig from "../../../Models/ThemeConfig/LayoutConfig"
import { Or } from "../../Tags/Or"
import { TagsFilter } from "../../Tags/TagsFilter"
import { OsmObject } from "../../Osm/OsmObject"
import { FeatureCollection } from "@turf/turf"
/**
* If a tile is needed (requested via the UIEventSource in the constructor), will download the appropriate tile and pass it via 'handleTile'
@ -20,67 +20,70 @@ export default class OsmFeatureSource {
public readonly isRunning: UIEventSource<boolean> = new UIEventSource<boolean>(false)
public readonly downloadedTiles = new Set<number>()
public rawDataHandlers: ((osmJson: any, tileId: number) => void)[] = []
private readonly _backend: string;
private readonly filteredLayers: Store<FilteredLayer[]>;
private readonly handleTile: (fs: (FeatureSourceForLayer & Tiled)) => void;
private isActive: Store<boolean>;
private readonly _backend: string
private readonly filteredLayers: Store<FilteredLayer[]>
private readonly handleTile: (fs: FeatureSourceForLayer & Tiled) => void
private isActive: Store<boolean>
private options: {
handleTile: (tile: FeatureSourceForLayer & Tiled) => void;
isActive: Store<boolean>,
neededTiles: Store<number[]>,
handleTile: (tile: FeatureSourceForLayer & Tiled) => void
isActive: Store<boolean>
neededTiles: Store<number[]>
markTileVisited?: (tileId: number) => void
};
private readonly allowedTags: TagsFilter;
}
private readonly allowedTags: TagsFilter
/**
*
* @param options: allowedFeatures is normally calculated from the layoutToUse
*/
constructor(options: {
handleTile: (tile: FeatureSourceForLayer & Tiled) => void;
isActive: Store<boolean>,
neededTiles: Store<number[]>,
handleTile: (tile: FeatureSourceForLayer & Tiled) => void
isActive: Store<boolean>
neededTiles: Store<number[]>
state: {
readonly filteredLayers: UIEventSource<FilteredLayer[]>;
readonly filteredLayers: UIEventSource<FilteredLayer[]>
readonly osmConnection: {
Backend(): string
};
}
readonly layoutToUse?: LayoutConfig
},
readonly allowedFeatures?: TagsFilter,
}
readonly allowedFeatures?: TagsFilter
markTileVisited?: (tileId: number) => void
}) {
this.options = options;
this._backend = options.state.osmConnection.Backend();
this.filteredLayers = options.state.filteredLayers.map(layers => layers.filter(layer => layer.layerDef.source.geojsonSource === undefined))
this.options = options
this._backend = options.state.osmConnection.Backend()
this.filteredLayers = options.state.filteredLayers.map((layers) =>
layers.filter((layer) => layer.layerDef.source.geojsonSource === undefined)
)
this.handleTile = options.handleTile
this.isActive = options.isActive
const self = this
options.neededTiles.addCallbackAndRunD(neededTiles => {
options.neededTiles.addCallbackAndRunD((neededTiles) => {
self.Update(neededTiles)
})
const neededLayers = (options.state.layoutToUse?.layers ?? [])
.filter(layer => !layer.doNotDownload)
.filter(layer => layer.source.geojsonSource === undefined || layer.source.isOsmCacheLayer)
this.allowedTags = options.allowedFeatures ?? new Or(neededLayers.map(l => l.source.osmTags))
.filter((layer) => !layer.doNotDownload)
.filter(
(layer) => layer.source.geojsonSource === undefined || layer.source.isOsmCacheLayer
)
this.allowedTags =
options.allowedFeatures ?? new Or(neededLayers.map((l) => l.source.osmTags))
}
private async Update(neededTiles: number[]) {
if (this.options.isActive?.data === false) {
return;
return
}
neededTiles = neededTiles.filter(tile => !this.downloadedTiles.has(tile))
neededTiles = neededTiles.filter((tile) => !this.downloadedTiles.has(tile))
if (neededTiles.length == 0) {
return;
return
}
this.isRunning.setData(true)
try {
for (const neededTile of neededTiles) {
this.downloadedTiles.add(neededTile)
await this.LoadTile(...Tiles.tile_from_index(neededTile))
@ -98,24 +101,30 @@ export default class OsmFeatureSource {
* This method will download the full relation and return it as geojson if it was incomplete.
* If the feature is already complete (or is not a relation), the feature will be returned
*/
private async patchIncompleteRelations(feature: {properties: {id: string}},
originalJson: {elements: {type: "node" | "way" | "relation", id: number, } []}): Promise<any> {
if(!feature.properties.id.startsWith("relation")){
private async patchIncompleteRelations(
feature: { properties: { id: string } },
originalJson: { elements: { type: "node" | "way" | "relation"; id: number }[] }
): Promise<any> {
if (!feature.properties.id.startsWith("relation")) {
return feature
}
const relationSpec = originalJson.elements.find(f => "relation/"+f.id === feature.properties.id)
const members : {type: string, ref: number}[] = relationSpec["members"]
const relationSpec = originalJson.elements.find(
(f) => "relation/" + f.id === feature.properties.id
)
const members: { type: string; ref: number }[] = relationSpec["members"]
for (const member of members) {
const isFound = originalJson.elements.some(f => f.id === member.ref && f.type === member.type)
const isFound = originalJson.elements.some(
(f) => f.id === member.ref && f.type === member.type
)
if (isFound) {
continue
}
// This member is missing. We redownload the entire relation instead
console.debug("Fetching incomplete relation "+feature.properties.id)
console.debug("Fetching incomplete relation " + feature.properties.id)
return (await OsmObject.DownloadObjectAsync(feature.properties.id)).asGeoJson()
}
return feature;
return feature
}
private async LoadTile(z, x, y): Promise<void> {
@ -130,52 +139,69 @@ export default class OsmFeatureSource {
const bbox = BBox.fromTile(z, x, y)
const url = `${this._backend}/api/0.6/map?bbox=${bbox.minLon},${bbox.minLat},${bbox.maxLon},${bbox.maxLat}`
let error = undefined;
let error = undefined
try {
const osmJson = await Utils.downloadJson(url)
try {
console.log("Got tile", z, x, y, "from the osm api")
this.rawDataHandlers.forEach(handler => handler(osmJson, Tiles.tile_index(z, x, y)))
const geojson = <FeatureCollection<any , {id: string}>> OsmToGeoJson.default(osmJson,
this.rawDataHandlers.forEach((handler) =>
handler(osmJson, Tiles.tile_index(z, x, y))
)
const geojson = <FeatureCollection<any, { id: string }>>OsmToGeoJson.default(
osmJson,
// @ts-ignore
{
flatProperties: true
});
flatProperties: true,
}
)
// The geojson contains _all_ features at the given location
// We only keep what is needed
geojson.features = geojson.features.filter(feature => this.allowedTags.matchesProperties(feature.properties))
geojson.features = geojson.features.filter((feature) =>
this.allowedTags.matchesProperties(feature.properties)
)
for (let i = 0; i < geojson.features.length; i++) {
geojson.features[i] = await this.patchIncompleteRelations(geojson.features[i], osmJson)
geojson.features[i] = await this.patchIncompleteRelations(
geojson.features[i],
osmJson
)
}
geojson.features.forEach(f => {
geojson.features.forEach((f) => {
f.properties["_backend"] = this._backend
})
const index = Tiles.tile_index(z, x, y);
new PerLayerFeatureSourceSplitter(this.filteredLayers,
const index = Tiles.tile_index(z, x, y)
new PerLayerFeatureSourceSplitter(
this.filteredLayers,
this.handleTile,
StaticFeatureSource.fromGeojson(geojson.features),
{
tileIndex: index
tileIndex: index,
}
);
)
if (this.options.markTileVisited) {
this.options.markTileVisited(index)
}
}catch(e){
console.error("PANIC: got the tile from the OSM-api, but something crashed handling this tile")
error = e;
} catch (e) {
console.error(
"PANIC: got the tile from the OSM-api, but something crashed handling this tile"
)
error = e
}
} catch (e) {
console.error("Could not download tile", z, x, y, "due to", e, "; retrying with smaller bounds")
console.error(
"Could not download tile",
z,
x,
y,
"due to",
e,
"; retrying with smaller bounds"
)
if (e === "rate limited") {
return;
return
}
await this.LoadTile(z + 1, x * 2, y * 2)
await this.LoadTile(z + 1, 1 + x * 2, y * 2)
@ -183,10 +209,8 @@ export default class OsmFeatureSource {
await this.LoadTile(z + 1, 1 + x * 2, 1 + y * 2)
}
if(error !== undefined){
throw error;
if (error !== undefined) {
throw error
}
}
}
}

View file

@ -1,25 +1,24 @@
import FeatureSource, {Tiled} from "../FeatureSource";
import {BBox} from "../../BBox";
import FeatureSource, { Tiled } from "../FeatureSource"
import { BBox } from "../../BBox"
export default interface TileHierarchy<T extends FeatureSource & Tiled> {
/**
* A mapping from 'tile_index' to the actual tile featrues
*/
loadedTiles: Map<number, T>
}
export class TileHierarchyTools {
public static getTiles<T extends FeatureSource & Tiled>(hierarchy: TileHierarchy<T>, bbox: BBox): T[] {
public static getTiles<T extends FeatureSource & Tiled>(
hierarchy: TileHierarchy<T>,
bbox: BBox
): T[] {
const result: T[] = []
hierarchy.loadedTiles.forEach((tile) => {
if (tile.bbox.overlapsWith(bbox)) {
result.push(tile)
}
})
return result;
return result
}
}
}

View file

@ -1,20 +1,32 @@
import TileHierarchy from "./TileHierarchy";
import {UIEventSource} from "../../UIEventSource";
import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import FeatureSourceMerger from "../Sources/FeatureSourceMerger";
import {Tiles} from "../../../Models/TileRange";
import {BBox} from "../../BBox";
import TileHierarchy from "./TileHierarchy"
import { UIEventSource } from "../../UIEventSource"
import FeatureSource, { FeatureSourceForLayer, IndexedFeatureSource, Tiled } from "../FeatureSource"
import FilteredLayer from "../../../Models/FilteredLayer"
import FeatureSourceMerger from "../Sources/FeatureSourceMerger"
import { Tiles } from "../../../Models/TileRange"
import { BBox } from "../../BBox"
export class TileHierarchyMerger implements TileHierarchy<FeatureSourceForLayer & Tiled> {
public readonly loadedTiles: Map<number, FeatureSourceForLayer & Tiled> = new Map<number, FeatureSourceForLayer & Tiled>();
public readonly layer: FilteredLayer;
private readonly sources: Map<number, UIEventSource<FeatureSource[]>> = new Map<number, UIEventSource<FeatureSource[]>>();
private _handleTile: (src: FeatureSourceForLayer & IndexedFeatureSource, index: number) => void;
public readonly loadedTiles: Map<number, FeatureSourceForLayer & Tiled> = new Map<
number,
FeatureSourceForLayer & Tiled
>()
public readonly layer: FilteredLayer
private readonly sources: Map<number, UIEventSource<FeatureSource[]>> = new Map<
number,
UIEventSource<FeatureSource[]>
>()
private _handleTile: (src: FeatureSourceForLayer & IndexedFeatureSource, index: number) => void
constructor(layer: FilteredLayer, handleTile: (src: FeatureSourceForLayer & IndexedFeatureSource & Tiled, index: number) => void) {
this.layer = layer;
this._handleTile = handleTile;
constructor(
layer: FilteredLayer,
handleTile: (
src: FeatureSourceForLayer & IndexedFeatureSource & Tiled,
index: number
) => void
) {
this.layer = layer
this._handleTile = handleTile
}
/**
@ -23,22 +35,24 @@ export class TileHierarchyMerger implements TileHierarchy<FeatureSourceForLayer
* @param src
*/
public registerTile(src: FeatureSource & Tiled) {
const index = src.tileIndex
if (this.sources.has(index)) {
const sources = this.sources.get(index)
sources.data.push(src)
sources.ping()
return;
return
}
// We have to setup
const sources = new UIEventSource<FeatureSource[]>([src])
this.sources.set(index, sources)
const merger = new FeatureSourceMerger(this.layer, index, BBox.fromTile(...Tiles.tile_from_index(index)), sources)
const merger = new FeatureSourceMerger(
this.layer,
index,
BBox.fromTile(...Tiles.tile_from_index(index)),
sources
)
this.loadedTiles.set(index, merger)
this._handleTile(merger, index)
}
}
}

View file

@ -1,53 +1,65 @@
import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource";
import {Store, UIEventSource} from "../../UIEventSource";
import FilteredLayer from "../../../Models/FilteredLayer";
import TileHierarchy from "./TileHierarchy";
import {Tiles} from "../../../Models/TileRange";
import {BBox} from "../../BBox";
import FeatureSource, { FeatureSourceForLayer, IndexedFeatureSource, Tiled } from "../FeatureSource"
import { Store, UIEventSource } from "../../UIEventSource"
import FilteredLayer from "../../../Models/FilteredLayer"
import TileHierarchy from "./TileHierarchy"
import { Tiles } from "../../../Models/TileRange"
import { BBox } from "../../BBox"
/**
* Contains all features in a tiled fashion.
* The data will be automatically broken down into subtiles when there are too much features in a single tile or if the zoomlevel is too high
*/
export default class TiledFeatureSource implements Tiled, IndexedFeatureSource, FeatureSourceForLayer, TileHierarchy<IndexedFeatureSource & FeatureSourceForLayer & Tiled> {
public readonly z: number;
public readonly x: number;
public readonly y: number;
public readonly parent: TiledFeatureSource;
export default class TiledFeatureSource
implements
Tiled,
IndexedFeatureSource,
FeatureSourceForLayer,
TileHierarchy<IndexedFeatureSource & FeatureSourceForLayer & Tiled>
{
public readonly z: number
public readonly x: number
public readonly y: number
public readonly parent: TiledFeatureSource
public readonly root: TiledFeatureSource
public readonly layer: FilteredLayer;
public readonly layer: FilteredLayer
/* An index of all known tiles. allTiles[z][x][y].get('layerid') will yield the corresponding tile.
* Only defined on the root element!
* Only defined on the root element!
*/
public readonly loadedTiles: Map<number, TiledFeatureSource & FeatureSourceForLayer> = undefined;
public readonly loadedTiles: Map<number, TiledFeatureSource & FeatureSourceForLayer> = undefined
public readonly maxFeatureCount: number;
public readonly name;
public readonly features: UIEventSource<{ feature: any, freshness: Date }[]>
public readonly maxFeatureCount: number
public readonly name
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>
public readonly containedIds: Store<Set<string>>
public readonly bbox: BBox;
public readonly tileIndex: number;
public readonly bbox: BBox
public readonly tileIndex: number
private upper_left: TiledFeatureSource
private upper_right: TiledFeatureSource
private lower_left: TiledFeatureSource
private lower_right: TiledFeatureSource
private readonly maxzoom: number;
private readonly maxzoom: number
private readonly options: TiledFeatureSourceOptions
private constructor(z: number, x: number, y: number, parent: TiledFeatureSource, options?: TiledFeatureSourceOptions) {
this.z = z;
this.x = x;
this.y = y;
private constructor(
z: number,
x: number,
y: number,
parent: TiledFeatureSource,
options?: TiledFeatureSourceOptions
) {
this.z = z
this.x = x
this.y = y
this.bbox = BBox.fromTile(z, x, y)
this.tileIndex = Tiles.tile_index(z, x, y)
this.name = `TiledFeatureSource(${z},${x},${y})`
this.parent = parent;
this.parent = parent
this.layer = options.layer
options = options ?? {}
this.maxFeatureCount = options?.maxFeatureCount ?? 250;
this.maxFeatureCount = options?.maxFeatureCount ?? 250
this.maxzoom = options.maxZoomLevel ?? 18
this.options = options;
this.options = options
if (parent === undefined) {
throw "Parent is not allowed to be undefined. Use null instead"
}
@ -55,50 +67,51 @@ export default class TiledFeatureSource implements Tiled, IndexedFeatureSource,
throw "Invalid root tile: z, x and y should all be null"
}
if (parent === null) {
this.root = this;
this.root = this
this.loadedTiles = new Map()
} else {
this.root = this.parent.root;
this.loadedTiles = this.root.loadedTiles;
this.root = this.parent.root
this.loadedTiles = this.root.loadedTiles
const i = Tiles.tile_index(z, x, y)
this.root.loadedTiles.set(i, this)
}
this.features = new UIEventSource<any[]>([])
this.containedIds = this.features.map(features => {
this.containedIds = this.features.map((features) => {
if (features === undefined) {
return undefined;
return undefined
}
return new Set(features.map(f => f.feature.properties.id))
return new Set(features.map((f) => f.feature.properties.id))
})
// We register this tile, but only when there is some data in it
if (this.options.registerTile !== undefined) {
this.features.addCallbackAndRunD(features => {
this.features.addCallbackAndRunD((features) => {
if (features.length === 0) {
return;
return
}
this.options.registerTile(this)
return true;
return true
})
}
}
public static createHierarchy(features: FeatureSource, options?: TiledFeatureSourceOptions): TiledFeatureSource {
public static createHierarchy(
features: FeatureSource,
options?: TiledFeatureSourceOptions
): TiledFeatureSource {
options = {
...options,
layer: features["layer"] ?? options.layer
layer: features["layer"] ?? options.layer,
}
const root = new TiledFeatureSource(0, 0, 0, null, options)
features.features?.addCallbackAndRunD(feats => root.addFeatures(feats))
return root;
features.features?.addCallbackAndRunD((feats) => root.addFeatures(feats))
return root
}
private isSplitNeeded(featureCount: number) {
if (this.upper_left !== undefined) {
// This tile has been split previously, so we keep on splitting
return true;
return true
}
if (this.z >= this.maxzoom) {
// We are not allowed to split any further
@ -111,7 +124,6 @@ export default class TiledFeatureSource implements Tiled, IndexedFeatureSource,
// To much features - we split
return featureCount > this.maxFeatureCount
}
/***
@ -120,21 +132,45 @@ export default class TiledFeatureSource implements Tiled, IndexedFeatureSource,
* @param features
* @private
*/
private addFeatures(features: { feature: any, freshness: Date }[]) {
private addFeatures(features: { feature: any; freshness: Date }[]) {
if (features === undefined || features.length === 0) {
return;
return
}
if (!this.isSplitNeeded(features.length)) {
this.features.setData(features)
return;
return
}
if (this.upper_left === undefined) {
this.upper_left = new TiledFeatureSource(this.z + 1, this.x * 2, this.y * 2, this, this.options)
this.upper_right = new TiledFeatureSource(this.z + 1, this.x * 2 + 1, this.y * 2, this, this.options)
this.lower_left = new TiledFeatureSource(this.z + 1, this.x * 2, this.y * 2 + 1, this, this.options)
this.lower_right = new TiledFeatureSource(this.z + 1, this.x * 2 + 1, this.y * 2 + 1, this, this.options)
this.upper_left = new TiledFeatureSource(
this.z + 1,
this.x * 2,
this.y * 2,
this,
this.options
)
this.upper_right = new TiledFeatureSource(
this.z + 1,
this.x * 2 + 1,
this.y * 2,
this,
this.options
)
this.lower_left = new TiledFeatureSource(
this.z + 1,
this.x * 2,
this.y * 2 + 1,
this,
this.options
)
this.lower_right = new TiledFeatureSource(
this.z + 1,
this.x * 2 + 1,
this.y * 2 + 1,
this,
this.options
)
}
const ulf = []
@ -147,7 +183,7 @@ export default class TiledFeatureSource implements Tiled, IndexedFeatureSource,
const bbox = BBox.get(feature.feature)
// There are a few strategies to deal with features that cross tile boundaries
if (this.options.noDuplicates) {
// Strategy 1: We put the feature into a somewhat matching tile
if (bbox.overlapsWith(this.upper_left.bbox)) {
@ -195,19 +231,18 @@ export default class TiledFeatureSource implements Tiled, IndexedFeatureSource,
this.lower_left.addFeatures(llf)
this.lower_right.addFeatures(lrf)
this.features.setData(overlapsboundary)
}
}
export interface TiledFeatureSourceOptions {
readonly maxFeatureCount?: number,
readonly maxZoomLevel?: number,
readonly minZoomLevel?: number,
readonly maxFeatureCount?: number
readonly maxZoomLevel?: number
readonly minZoomLevel?: number
/**
* IF minZoomLevel is set, and if a feature runs through a tile boundary, it would normally be duplicated.
* Setting 'dontEnforceMinZoomLevel' will assign to feature to some matching subtile.
*/
readonly noDuplicates?: boolean,
readonly registerTile?: (tile: TiledFeatureSource & FeatureSourceForLayer & Tiled) => void,
readonly noDuplicates?: boolean
readonly registerTile?: (tile: TiledFeatureSource & FeatureSourceForLayer & Tiled) => void
readonly layer?: FilteredLayer
}
}

View file

@ -1,17 +1,25 @@
import * as turf from '@turf/turf'
import {BBox} from "./BBox";
import * as turf from "@turf/turf"
import { BBox } from "./BBox"
import togpx from "togpx"
import Constants from "../Models/Constants";
import LayerConfig from "../Models/ThemeConfig/LayerConfig";
import {AllGeoJSON, booleanWithin, Coord, Feature, Geometry, MultiPolygon, Polygon, Properties} from "@turf/turf";
import Constants from "../Models/Constants"
import LayerConfig from "../Models/ThemeConfig/LayerConfig"
import {
AllGeoJSON,
booleanWithin,
Coord,
Feature,
Geometry,
MultiPolygon,
Polygon,
Properties,
} from "@turf/turf"
export class GeoOperations {
private static readonly _earthRadius = 6378137;
private static readonly _originShift = 2 * Math.PI * GeoOperations._earthRadius / 2;
private static readonly _earthRadius = 6378137
private static readonly _originShift = (2 * Math.PI * GeoOperations._earthRadius) / 2
static surfaceAreaInSqMeters(feature: any) {
return turf.area(feature);
return turf.area(feature)
}
/**
@ -19,10 +27,10 @@ export class GeoOperations {
* @param feature
*/
static centerpoint(feature: any) {
const newFeature = turf.center(feature);
newFeature.properties = feature.properties;
newFeature.id = feature.id;
return newFeature;
const newFeature = turf.center(feature)
newFeature.properties = feature.properties
newFeature.id = feature.id
return newFeature
}
/**
@ -30,7 +38,7 @@ export class GeoOperations {
* @param feature
*/
static centerpointCoordinates(feature: AllGeoJSON): [number, number] {
return <[number, number]>turf.center(feature).geometry.coordinates;
return <[number, number]>turf.center(feature).geometry.coordinates
}
/**
@ -39,7 +47,7 @@ export class GeoOperations {
* @param lonlat1
*/
static distanceBetween(lonlat0: [number, number], lonlat1: [number, number]) {
return turf.distance(lonlat0, lonlat1, {units: "meters"})
return turf.distance(lonlat0, lonlat1, { units: "meters" })
}
static convexHull(featureCollection, options: { concavity?: number }) {
@ -69,16 +77,17 @@ export class GeoOperations {
* const overlap0 = GeoOperations.calculateOverlap(line0, [polygon]);
* overlap.length // => 1
*/
static calculateOverlap(feature: any, otherFeatures: any[]): { feat: any, overlap: number }[] {
const featureBBox = BBox.get(feature);
const result: { feat: any, overlap: number }[] = [];
static calculateOverlap(feature: any, otherFeatures: any[]): { feat: any; overlap: number }[] {
const featureBBox = BBox.get(feature)
const result: { feat: any; overlap: number }[] = []
if (feature.geometry.type === "Point") {
const coor = feature.geometry.coordinates;
const coor = feature.geometry.coordinates
for (const otherFeature of otherFeatures) {
if (feature.properties.id !== undefined && feature.properties.id === otherFeature.properties.id) {
continue;
if (
feature.properties.id !== undefined &&
feature.properties.id === otherFeature.properties.id
) {
continue
}
if (otherFeature.geometry === undefined) {
@ -87,86 +96,105 @@ export class GeoOperations {
}
if (GeoOperations.inside(coor, otherFeature)) {
result.push({feat: otherFeature, overlap: undefined})
result.push({ feat: otherFeature, overlap: undefined })
}
}
return result;
return result
}
if (feature.geometry.type === "LineString") {
for (const otherFeature of otherFeatures) {
if (feature.properties.id !== undefined && feature.properties.id === otherFeature.properties.id) {
continue;
if (
feature.properties.id !== undefined &&
feature.properties.id === otherFeature.properties.id
) {
continue
}
const intersection = GeoOperations.calculateInstersection(feature, otherFeature, featureBBox)
const intersection = GeoOperations.calculateInstersection(
feature,
otherFeature,
featureBBox
)
if (intersection === null) {
continue
}
result.push({feat: otherFeature, overlap: intersection})
result.push({ feat: otherFeature, overlap: intersection })
}
return result;
return result
}
if (feature.geometry.type === "Polygon" || feature.geometry.type === "MultiPolygon") {
for (const otherFeature of otherFeatures) {
if (feature.properties.id !== undefined && feature.properties.id === otherFeature.properties.id) {
continue;
if (
feature.properties.id !== undefined &&
feature.properties.id === otherFeature.properties.id
) {
continue
}
if (otherFeature.geometry.type === "Point") {
if (this.inside(otherFeature, feature)) {
result.push({feat: otherFeature, overlap: undefined})
result.push({ feat: otherFeature, overlap: undefined })
}
continue;
continue
}
// Calculate the surface area of the intersection
const intersection = this.calculateInstersection(feature, otherFeature, featureBBox)
if (intersection === null) {
continue;
continue
}
result.push({feat: otherFeature, overlap: intersection})
result.push({ feat: otherFeature, overlap: intersection })
}
return result;
return result
}
console.error("Could not correctly calculate the overlap of ", feature, ": unsupported type")
return result;
console.error(
"Could not correctly calculate the overlap of ",
feature,
": unsupported type"
)
return result
}
/**
* Helper function which does the heavy lifting for 'inside'
*/
private static pointInPolygonCoordinates(x: number, y: number, coordinates: [number, number][][]) {
const inside = GeoOperations.pointWithinRing(x, y, /*This is the outer ring of the polygon */coordinates[0])
private static pointInPolygonCoordinates(
x: number,
y: number,
coordinates: [number, number][][]
) {
const inside = GeoOperations.pointWithinRing(
x,
y,
/*This is the outer ring of the polygon */ coordinates[0]
)
if (!inside) {
return false;
return false
}
for (let i = 1; i < coordinates.length; i++) {
const inHole = GeoOperations.pointWithinRing(x, y, coordinates[i] /* These are inner rings, aka holes*/)
const inHole = GeoOperations.pointWithinRing(
x,
y,
coordinates[i] /* These are inner rings, aka holes*/
)
if (inHole) {
return false;
return false
}
}
return true;
return true
}
/**
* Detect wether or not the given point is located in the feature
*
*
* // Should work with a normal polygon
* const polygon = {"type": "Feature","properties": {},"geometry": {"type": "Polygon","coordinates": [[[1.8017578124999998,50.401515322782366],[-3.1640625,46.255846818480315],[5.185546875,44.74673324024678],[1.8017578124999998,50.401515322782366]]]}};
* GeoOperations.inside([3.779296875, 48.777912755501845], polygon) // => false
* GeoOperations.inside([1.23046875, 47.60616304386874], polygon) // => true
*
*
* // should work with a multipolygon and detect holes
* const multiPolygon = {"type": "Feature", "properties": {},
* "geometry": {
@ -186,37 +214,32 @@ export class GeoOperations {
// http://www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/pnpoly.html
if (feature.geometry.type === "Point") {
return false;
return false
}
if (pointCoordinate.geometry !== undefined) {
pointCoordinate = pointCoordinate.geometry.coordinates
}
const x: number = pointCoordinate[0];
const y: number = pointCoordinate[1];
const x: number = pointCoordinate[0]
const y: number = pointCoordinate[1]
if (feature.geometry.type === "MultiPolygon") {
const coordinatess = feature.geometry.coordinates;
const coordinatess = feature.geometry.coordinates
for (const coordinates of coordinatess) {
const inThisPolygon = GeoOperations.pointInPolygonCoordinates(x, y, coordinates)
if (inThisPolygon) {
return true;
return true
}
}
return false;
return false
}
if (feature.geometry.type === "Polygon") {
return GeoOperations.pointInPolygonCoordinates(x, y, feature.geometry.coordinates)
}
throw "GeoOperations.inside: unsupported geometry type "+feature.geometry.type
throw "GeoOperations.inside: unsupported geometry type " + feature.geometry.type
}
static lengthInMeters(feature: any) {
@ -225,39 +248,24 @@ export class GeoOperations {
static buffer(feature: any, bufferSizeInMeter: number) {
return turf.buffer(feature, bufferSizeInMeter / 1000, {
units: 'kilometers'
units: "kilometers",
})
}
static bbox(feature: any) {
const [lon, lat, lon0, lat0] = turf.bbox(feature)
return {
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [
[
lon,
lat
],
[
lon0,
lat
],
[
lon0,
lat0
],
[
lon,
lat0
],
[
lon,
lat
],
]
}
type: "Feature",
geometry: {
type: "LineString",
coordinates: [
[lon, lat],
[lon0, lat],
[lon0, lat0],
[lon, lat0],
[lon, lat],
],
},
}
}
@ -273,18 +281,17 @@ export class GeoOperations {
*/
public static nearestPoint(way, point: [number, number]) {
if (way.geometry.type === "Polygon") {
way = {...way}
way.geometry = {...way.geometry}
way = { ...way }
way.geometry = { ...way.geometry }
way.geometry.type = "LineString"
way.geometry.coordinates = way.geometry.coordinates[0]
}
return turf.nearestPointOnLine(way, point, {units: "kilometers"});
return turf.nearestPointOnLine(way, point, { units: "kilometers" })
}
public static toCSV(features: any[]): string {
const headerValuesSeen = new Set<string>();
const headerValuesSeen = new Set<string>()
const headerValuesOrdered: string[] = []
function addH(key) {
@ -300,18 +307,17 @@ export class GeoOperations {
const lines: string[] = []
for (const feature of features) {
const properties = feature.properties;
const properties = feature.properties
for (const key in properties) {
if (!properties.hasOwnProperty(key)) {
continue;
continue
}
addH(key)
}
}
headerValuesOrdered.sort()
for (const feature of features) {
const properties = feature.properties;
const properties = feature.properties
let line = ""
for (const key of headerValuesOrdered) {
const value = properties[key]
@ -324,27 +330,27 @@ export class GeoOperations {
lines.push(line)
}
return headerValuesOrdered.map(v => JSON.stringify(v)).join(",") + "\n" + lines.join("\n")
return headerValuesOrdered.map((v) => JSON.stringify(v)).join(",") + "\n" + lines.join("\n")
}
//Converts given lat/lon in WGS84 Datum to XY in Spherical Mercator EPSG:900913
public static ConvertWgs84To900913(lonLat: [number, number]): [number, number] {
const lon = lonLat[0];
const lat = lonLat[1];
const x = lon * GeoOperations._originShift / 180;
let y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) / (Math.PI / 180);
y = y * GeoOperations._originShift / 180;
return [x, y];
const lon = lonLat[0]
const lat = lonLat[1]
const x = (lon * GeoOperations._originShift) / 180
let y = Math.log(Math.tan(((90 + lat) * Math.PI) / 360)) / (Math.PI / 180)
y = (y * GeoOperations._originShift) / 180
return [x, y]
}
//Converts XY point from (Spherical) Web Mercator EPSG:3785 (unofficially EPSG:900913) to lat/lon in WGS84 Datum
public static Convert900913ToWgs84(lonLat: [number, number]): [number, number] {
const lon = lonLat[0]
const lat = lonLat[1]
const x = 180 * lon / GeoOperations._originShift;
let y = 180 * lat / GeoOperations._originShift;
y = 180 / Math.PI * (2 * Math.atan(Math.exp(y * Math.PI / 180)) - Math.PI / 2);
return [x, y];
const x = (180 * lon) / GeoOperations._originShift
let y = (180 * lat) / GeoOperations._originShift
y = (180 / Math.PI) * (2 * Math.atan(Math.exp((y * Math.PI) / 180)) - Math.PI / 2)
return [x, y]
}
public static GeoJsonToWGS84(geojson) {
@ -360,10 +366,10 @@ export class GeoOperations {
public static SimplifyCoordinates(coordinates: [number, number][]) {
const newCoordinates = []
for (let i = 1; i < coordinates.length - 1; i++) {
const coordinate = coordinates[i];
const coordinate = coordinates[i]
const prev = coordinates[i - 1]
const next = coordinates[i + 1]
const b0 = turf.bearing(prev, coordinate, {final: true})
const b0 = turf.bearing(prev, coordinate, { final: true })
const b1 = turf.bearing(coordinate, next)
const diff = Math.abs(b1 - b0)
@ -373,27 +379,27 @@ export class GeoOperations {
newCoordinates.push(coordinate)
}
return newCoordinates
}
/**
* Calculates line intersection between two features.
*/
public static LineIntersections(feature, otherFeature): [number, number][] {
return turf.lineIntersect(feature, otherFeature).features.map(p => <[number, number]>p.geometry.coordinates)
return turf
.lineIntersect(feature, otherFeature)
.features.map((p) => <[number, number]>p.geometry.coordinates)
}
public static AsGpx(feature, generatedWithLayer?: LayerConfig) {
const metadata = {}
const tags = feature.properties
if (generatedWithLayer !== undefined) {
metadata["name"] = generatedWithLayer.title?.GetRenderValue(tags)?.Subs(tags)?.txt
metadata["desc"] = "Generated with MapComplete layer " + generatedWithLayer.id
if (tags._backend?.contains("openstreetmap")) {
metadata["copyright"] = "Data copyrighted by OpenStreetMap-contributors, freely available under ODbL. See https://www.openstreetmap.org/copyright"
metadata["copyright"] =
"Data copyrighted by OpenStreetMap-contributors, freely available under ODbL. See https://www.openstreetmap.org/copyright"
metadata["author"] = tags["_last_edit:contributor"]
metadata["link"] = "https://www.openstreetmap.org/" + tags.id
metadata["time"] = tags["_last_edit:timestamp"]
@ -404,18 +410,22 @@ export class GeoOperations {
return togpx(feature, {
creator: "MapComplete " + Constants.vNumber,
metadata
metadata,
})
}
public static IdentifieCommonSegments(coordinatess: [number, number][][]): {
originalIndex: number,
segmentShardWith: 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 }[] }
type edge = {
start: [number, number]
end: [number, number]
intermediate: [number, number][]
members: { index: number; isReversed: boolean }[]
}
// The strategy:
// 1. Index _all_ edges from _every_ linestring. Index them by starting key, gather which relations run over them
@ -425,12 +435,11 @@ export class GeoOperations {
const allEdgesByKey = new Map<string, edge>()
for (let index = 0; index < coordinatess.length; index++) {
const coordinates = coordinatess[index];
const coordinates = coordinatess[index]
for (let i = 0; i < coordinates.length - 1; i++) {
const c0 = coordinates[i];
const c0 = coordinates[i]
const c1 = coordinates[i + 1]
const isReversed = (c0[0] > c1[0]) || (c0[0] == c1[0] && c0[1] > c1[1])
const isReversed = c0[0] > c1[0] || (c0[0] == c1[0] && c0[1] > c1[1])
let key: string
if (isReversed) {
@ -438,40 +447,38 @@ export class GeoOperations {
} else {
key = "" + c0 + ";" + c1
}
const member = {index, isReversed}
const member = { index, isReversed }
if (allEdgesByKey.has(key)) {
allEdgesByKey.get(key).members.push(member)
continue
}
let edge: edge;
let edge: edge
if (!isReversed) {
edge = {
start: c0,
end: c1,
members: [member],
intermediate: []
intermediate: [],
}
} else {
edge = {
start: c1,
end: c0,
members: [member],
intermediate: []
intermediate: [],
}
}
allEdgesByKey.set(key, edge)
}
}
// Lets merge them back together!
let didMergeSomething = false;
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 + ""
@ -481,7 +488,6 @@ export class GeoOperations {
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) {
@ -504,7 +510,6 @@ export class GeoOperations {
// Allrigth, they are the same, lets mark this permanently
second.members = first.members
return true
}
do {
@ -524,9 +529,8 @@ export class GeoOperations {
continue
}
for (let i = 0; i < matchingEndEdges.length; i++) {
const endEdge = matchingEndEdges[i];
const endEdge = matchingEndEdges[i]
if (consumed.has(endEdge)) {
continue
@ -543,12 +547,11 @@ export class GeoOperations {
edge.end = endEdge.end
consumed.add(endEdge)
matchingEndEdges.splice(i, 1)
break;
break
}
}
allMergedEdges = allMergedEdges.filter(edge => !consumed.has(edge));
allMergedEdges = allMergedEdges.filter((edge) => !consumed.has(edge))
} while (didMergeSomething)
return []
@ -557,7 +560,7 @@ export class GeoOperations {
/**
* Removes points that do not contribute to the geometry from linestrings and the outer ring of polygons.
* Returs a new copy of the feature
*
*
* const feature = {"geometry": {"type": "Polygon","coordinates": [[[4.477944199999975,51.02783550000022],[4.477987899999996,51.027818800000034],[4.478004500000021,51.02783399999988],[4.478025499999962,51.02782489999994],[4.478079099999993,51.027873899999896],[4.47801040000006,51.027903799999955],[4.477964799999972,51.02785709999982],[4.477964699999964,51.02785690000006],[4.477944199999975,51.02783550000022]]]}}
* const copy = GeoOperations.removeOvernoding(feature)
* expect(copy.geometry.coordinates[0]).deep.equal([[4.477944199999975,51.02783550000022],[4.477987899999996,51.027818800000034],[4.478004500000021,51.02783399999988],[4.478025499999962,51.02782489999994],[4.478079099999993,51.027873899999896],[4.47801040000006,51.027903799999955],[4.477944199999975,51.02783550000022]])
@ -569,7 +572,7 @@ export class GeoOperations {
const copy = {
...feature,
geometry: {...feature.geometry}
geometry: { ...feature.geometry },
}
let coordinates: [number, number][]
if (feature.geometry.type === "LineString") {
@ -582,7 +585,7 @@ export class GeoOperations {
// inline replacement in the coordinates list
for (let i = coordinates.length - 2; i >= 1; i--) {
const coordinate = coordinates[i];
const coordinate = coordinates[i]
const nextCoordinate = coordinates[i + 1]
const prevCoordinate = coordinates[i - 1]
@ -610,30 +613,27 @@ export class GeoOperations {
// In case that the line is going south, e.g. bearingN = 179, bearingP = -179
coordinates.splice(i, 1)
}
}
return copy;
return copy
}
private static pointWithinRing(x: number, y: number, ring: [number, number][]) {
let inside = false;
let inside = false
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const coori = ring[i];
const coorj = ring[j];
const coori = ring[i]
const coorj = ring[j]
const xi = coori[0];
const yi = coori[1];
const xj = coorj[0];
const yj = coorj[1];
const xi = coori[0]
const yi = coori[1]
const xj = coorj[0]
const yj = coorj[1]
const intersect = ((yi > y) != (yj > y))
&& (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
const intersect = yi > y != yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi
if (intersect) {
inside = !inside;
inside = !inside
}
}
return inside;
return inside
}
/**
@ -642,46 +642,47 @@ export class GeoOperations {
* Returns 0 if both are linestrings
* Returns null if the features are not intersecting
*/
private static calculateInstersection(feature, otherFeature, featureBBox: BBox, otherFeatureBBox?: BBox): number {
private static calculateInstersection(
feature,
otherFeature,
featureBBox: BBox,
otherFeatureBBox?: BBox
): number {
if (feature.geometry.type === "LineString") {
otherFeatureBBox = otherFeatureBBox ?? BBox.get(otherFeature);
otherFeatureBBox = otherFeatureBBox ?? BBox.get(otherFeature)
const overlaps = featureBBox.overlapsWith(otherFeatureBBox)
if (!overlaps) {
return null;
return null
}
// Calculate the length of the intersection
let intersectionPoints = turf.lineIntersect(feature, otherFeature);
let intersectionPoints = turf.lineIntersect(feature, otherFeature)
if (intersectionPoints.features.length == 0) {
// No intersections.
// If one point is inside of the polygon, all points are
const coors = feature.geometry.coordinates;
const coors = feature.geometry.coordinates
const startCoor = coors[0]
if (this.inside(startCoor, otherFeature)) {
return this.lengthInMeters(feature)
}
return null;
return null
}
let intersectionPointsArray = intersectionPoints.features.map(d => {
let intersectionPointsArray = intersectionPoints.features.map((d) => {
return d.geometry.coordinates
});
})
if (otherFeature.geometry.type === "LineString") {
if (intersectionPointsArray.length > 0) {
return 0
}
return null;
return null
}
if (intersectionPointsArray.length == 1) {
// We need to add the start- or endpoint of the current feature, depending on which one is embedded
const coors = feature.geometry.coordinates;
const coors = feature.geometry.coordinates
const startCoor = coors[0]
if (this.inside(startCoor, otherFeature)) {
// The startpoint is embedded
@ -691,46 +692,50 @@ export class GeoOperations {
}
}
let intersection = turf.lineSlice(turf.point(intersectionPointsArray[0]), turf.point(intersectionPointsArray[1]), feature);
let intersection = turf.lineSlice(
turf.point(intersectionPointsArray[0]),
turf.point(intersectionPointsArray[1]),
feature
)
if (intersection == null) {
return null;
return null
}
const intersectionSize = turf.length(intersection); // in km
const intersectionSize = turf.length(intersection) // in km
return intersectionSize * 1000
}
if (feature.geometry.type === "Polygon" || feature.geometry.type === "MultiPolygon") {
const otherFeatureBBox = BBox.get(otherFeature);
const otherFeatureBBox = BBox.get(otherFeature)
const overlaps = featureBBox.overlapsWith(otherFeatureBBox)
if (!overlaps) {
return null;
return null
}
if (otherFeature.geometry.type === "LineString") {
return this.calculateInstersection(otherFeature, feature, otherFeatureBBox, featureBBox)
return this.calculateInstersection(
otherFeature,
feature,
otherFeatureBBox,
featureBBox
)
}
try {
const intersection = turf.intersect(feature, otherFeature);
const intersection = turf.intersect(feature, otherFeature)
if (intersection == null) {
return null;
return null
}
return turf.area(intersection); // in m²
return turf.area(intersection) // in m²
} catch (e) {
if (e.message === "Each LinearRing of a Polygon must have 4 or more Positions.") {
// WORKAROUND TIME!
// See https://github.com/Turfjs/turf/pull/2238
return null;
return null
}
throw e;
throw e
}
}
throw "CalculateIntersection fallthrough: can not calculate an intersection between features"
}
/**
@ -742,7 +747,7 @@ export class GeoOperations {
/**
* Returns 'true' if one feature contains the other feature
*
*
* const pond: Feature<Polygon, any> = {
* "type": "Feature",
* "properties": {"natural":"water","water":"pond"},
@ -769,9 +774,10 @@ export class GeoOperations {
* GeoOperations.completelyWithin(pond, park) // => true
* GeoOperations.completelyWithin(park, pond) // => false
*/
static completelyWithin(feature: Feature<Geometry, any>, possiblyEncloingFeature: Feature<Polygon | MultiPolygon, any>) : boolean {
return booleanWithin(feature, possiblyEncloingFeature);
static completelyWithin(
feature: Feature<Geometry, any>,
possiblyEncloingFeature: Feature<Polygon | MultiPolygon, any>
): boolean {
return booleanWithin(feature, possiblyEncloingFeature)
}
}

View file

@ -1,45 +1,52 @@
import {Mapillary} from "./Mapillary";
import {WikimediaImageProvider} from "./WikimediaImageProvider";
import {Imgur} from "./Imgur";
import GenericImageProvider from "./GenericImageProvider";
import {Store, UIEventSource} from "../UIEventSource";
import ImageProvider, {ProvidedImage} from "./ImageProvider";
import {WikidataImageProvider} from "./WikidataImageProvider";
import { Mapillary } from "./Mapillary"
import { WikimediaImageProvider } from "./WikimediaImageProvider"
import { Imgur } from "./Imgur"
import GenericImageProvider from "./GenericImageProvider"
import { Store, UIEventSource } from "../UIEventSource"
import ImageProvider, { ProvidedImage } from "./ImageProvider"
import { WikidataImageProvider } from "./WikidataImageProvider"
/**
* A generic 'from the interwebz' image picker, without attribution
*/
export default class AllImageProviders {
public static ImageAttributionSource: ImageProvider[] = [
Imgur.singleton,
Mapillary.singleton,
WikidataImageProvider.singleton,
WikimediaImageProvider.singleton,
new GenericImageProvider(
[].concat(...Imgur.defaultValuePrefix, ...WikimediaImageProvider.commonsPrefixes, ...Mapillary.valuePrefixes)
)
[].concat(
...Imgur.defaultValuePrefix,
...WikimediaImageProvider.commonsPrefixes,
...Mapillary.valuePrefixes
)
),
]
private static providersByName= {
"imgur": Imgur.singleton,
"mapillary": Mapillary.singleton,
"wikidata": WikidataImageProvider.singleton,
"wikimedia": WikimediaImageProvider.singleton
private static providersByName = {
imgur: Imgur.singleton,
mapillary: Mapillary.singleton,
wikidata: WikidataImageProvider.singleton,
wikimedia: WikimediaImageProvider.singleton,
}
public static byName(name: string){
public static byName(name: string) {
return AllImageProviders.providersByName[name.toLowerCase()]
}
public static defaultKeys = [].concat(AllImageProviders.ImageAttributionSource.map(provider => provider.defaultKeyPrefixes))
public static defaultKeys = [].concat(
AllImageProviders.ImageAttributionSource.map((provider) => provider.defaultKeyPrefixes)
)
private static _cache: Map<string, UIEventSource<ProvidedImage[]>> = new Map<string, UIEventSource<ProvidedImage[]>>()
private static _cache: Map<string, UIEventSource<ProvidedImage[]>> = new Map<
string,
UIEventSource<ProvidedImage[]>
>()
public static LoadImagesFor(tags: Store<any>, tagKey?: string[]): Store<ProvidedImage[]> {
if (tags.data.id === undefined) {
return undefined;
return undefined
}
const cacheKey = tags.data.id + tagKey
@ -48,23 +55,21 @@ export default class AllImageProviders {
return cached
}
const source = new UIEventSource([])
this._cache.set(cacheKey, source)
const allSources = []
for (const imageProvider of AllImageProviders.ImageAttributionSource) {
let prefixes = imageProvider.defaultKeyPrefixes
if (tagKey !== undefined) {
prefixes = tagKey
}
const singleSource = imageProvider.GetRelevantUrls(tags, {
prefixes: prefixes
prefixes: prefixes,
})
allSources.push(singleSource)
singleSource.addCallbackAndRunD(_ => {
const all: ProvidedImage[] = [].concat(...allSources.map(source => source.data))
singleSource.addCallbackAndRunD((_) => {
const all: ProvidedImage[] = [].concat(...allSources.map((source) => source.data))
const uniq = []
const seen = new Set<string>()
for (const img of all) {
@ -77,7 +82,6 @@ export default class AllImageProviders {
source.setData(uniq)
})
}
return source;
return source
}
}
}

View file

@ -1,18 +1,17 @@
import ImageProvider, {ProvidedImage} from "./ImageProvider";
import ImageProvider, { ProvidedImage } from "./ImageProvider"
export default class GenericImageProvider extends ImageProvider {
public defaultKeyPrefixes: string[] = ["image"];
public defaultKeyPrefixes: string[] = ["image"]
private readonly _valuePrefixBlacklist: string[];
private readonly _valuePrefixBlacklist: string[]
public constructor(valuePrefixBlacklist: string[]) {
super();
this._valuePrefixBlacklist = valuePrefixBlacklist;
super()
this._valuePrefixBlacklist = valuePrefixBlacklist
}
async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
if (this._valuePrefixBlacklist.some(prefix => value.startsWith(prefix))) {
if (this._valuePrefixBlacklist.some((prefix) => value.startsWith(prefix))) {
return []
}
@ -23,20 +22,20 @@ export default class GenericImageProvider extends ImageProvider {
return []
}
return [Promise.resolve({
key: key,
url: value,
provider: this
})]
return [
Promise.resolve({
key: key,
url: value,
provider: this,
}),
]
}
SourceIcon(backlinkSource?: string) {
return undefined;
return undefined
}
public DownloadAttribution(url: string) {
return undefined
}
}
}

View file

@ -1,50 +1,53 @@
import {Store, UIEventSource} from "../UIEventSource";
import BaseUIElement from "../../UI/BaseUIElement";
import {LicenseInfo} from "./LicenseInfo";
import {Utils} from "../../Utils";
import { Store, UIEventSource } from "../UIEventSource"
import BaseUIElement from "../../UI/BaseUIElement"
import { LicenseInfo } from "./LicenseInfo"
import { Utils } from "../../Utils"
export interface ProvidedImage {
url: string,
key: string,
url: string
key: string
provider: ImageProvider
}
export default abstract class ImageProvider {
public abstract readonly defaultKeyPrefixes: string[]
public abstract SourceIcon(backlinkSource?: string): BaseUIElement;
public abstract SourceIcon(backlinkSource?: string): BaseUIElement
/**
* Given a properies object, maps it onto _all_ the available pictures for this imageProvider
*/
public GetRelevantUrls(allTags: Store<any>, options?: {
prefixes?: string[]
}): UIEventSource<ProvidedImage[]> {
public GetRelevantUrls(
allTags: Store<any>,
options?: {
prefixes?: string[]
}
): UIEventSource<ProvidedImage[]> {
const prefixes = options?.prefixes ?? this.defaultKeyPrefixes
if (prefixes === undefined) {
throw "No `defaultKeyPrefixes` defined by this image provider"
}
const relevantUrls = new UIEventSource<{ url: string; key: string; provider: ImageProvider }[]>([])
const relevantUrls = new UIEventSource<
{ url: string; key: string; provider: ImageProvider }[]
>([])
const seenValues = new Set<string>()
allTags.addCallbackAndRunD(tags => {
allTags.addCallbackAndRunD((tags) => {
for (const key in tags) {
if (!prefixes.some(prefix => key.startsWith(prefix))) {
if (!prefixes.some((prefix) => key.startsWith(prefix))) {
continue
}
const values = Utils.NoEmpty(tags[key]?.split(";")?.map(v => v.trim()) ?? [])
const values = Utils.NoEmpty(tags[key]?.split(";")?.map((v) => v.trim()) ?? [])
for (const value of values) {
if (seenValues.has(value)) {
continue
}
seenValues.add(value)
this.ExtractUrls(key, value).then(promises => {
this.ExtractUrls(key, value).then((promises) => {
for (const promise of promises ?? []) {
if (promise === undefined) {
continue
}
promise.then(providedImage => {
promise.then((providedImage) => {
if (providedImage === undefined) {
return
}
@ -54,15 +57,12 @@ export default abstract class ImageProvider {
}
})
}
}
})
return relevantUrls
}
public abstract ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]>;
public abstract ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]>
public abstract DownloadAttribution(url: string): Promise<LicenseInfo>;
}
public abstract DownloadAttribution(url: string): Promise<LicenseInfo>
}

View file

@ -1,107 +1,112 @@
import $ from "jquery"
import ImageProvider, {ProvidedImage} from "./ImageProvider";
import BaseUIElement from "../../UI/BaseUIElement";
import {Utils} from "../../Utils";
import Constants from "../../Models/Constants";
import {LicenseInfo} from "./LicenseInfo";
import ImageProvider, { ProvidedImage } from "./ImageProvider"
import BaseUIElement from "../../UI/BaseUIElement"
import { Utils } from "../../Utils"
import Constants from "../../Models/Constants"
import { LicenseInfo } from "./LicenseInfo"
export class Imgur extends ImageProvider {
public static readonly defaultValuePrefix = ["https://i.imgur.com"]
public static readonly singleton = new Imgur();
public readonly defaultKeyPrefixes: string[] = ["image"];
public static readonly singleton = new Imgur()
public readonly defaultKeyPrefixes: string[] = ["image"]
private constructor() {
super();
super()
}
static uploadMultiple(
title: string, description: string, blobs: FileList,
handleSuccessfullUpload: ((imageURL: string) => Promise<void>),
allDone: (() => void),
onFail: ((reason: string) => void),
offset: number = 0) {
title: string,
description: string,
blobs: FileList,
handleSuccessfullUpload: (imageURL: string) => Promise<void>,
allDone: () => void,
onFail: (reason: string) => void,
offset: number = 0
) {
if (blobs.length == offset) {
allDone();
return;
allDone()
return
}
const blob = blobs.item(offset);
const self = this;
this.uploadImage(title, description, blob,
const blob = blobs.item(offset)
const self = this
this.uploadImage(
title,
description,
blob,
async (imageUrl) => {
await handleSuccessfullUpload(imageUrl);
await handleSuccessfullUpload(imageUrl)
self.uploadMultiple(
title, description, blobs,
title,
description,
blobs,
handleSuccessfullUpload,
allDone,
onFail,
offset + 1);
offset + 1
)
},
onFail
);
)
}
static uploadImage(title: string, description: string, blob: File,
handleSuccessfullUpload: ((imageURL: string) => Promise<void>),
onFail: (reason: string) => void) {
static uploadImage(
title: string,
description: string,
blob: File,
handleSuccessfullUpload: (imageURL: string) => Promise<void>,
onFail: (reason: string) => void
) {
const apiUrl = "https://api.imgur.com/3/image"
const apiKey = Constants.ImgurApiKey
const apiUrl = 'https://api.imgur.com/3/image';
const apiKey = Constants.ImgurApiKey;
const settings = {
async: true,
crossDomain: true,
processData: false,
contentType: false,
type: 'POST',
url: apiUrl,
headers: {
Authorization: 'Client-ID ' + apiKey,
Accept: 'application/json',
},
mimeType: 'multipart/form-data',
};
const formData = new FormData();
formData.append('image', blob);
formData.append("title", title);
const formData = new FormData()
formData.append("image", blob)
formData.append("title", title)
formData.append("description", description)
// @ts-ignore
settings.data = formData;
const settings: RequestInit = {
method: "POST",
body: formData,
redirect: "follow",
headers: new Headers({
Authorization: `Client-ID ${apiKey}`,
Accept: "application/json",
}),
}
// Response contains stringified JSON
// Image URL available at response.data.link
// @ts-ignore
$.ajax(settings).done(async function (response) {
response = JSON.parse(response);
await handleSuccessfullUpload(response.data.link);
}).fail((reason) => {
console.log("Uploading to IMGUR failed", reason);
// @ts-ignore
onFail(reason);
});
fetch(apiUrl, settings)
.then(async function (response) {
const content = await response.json()
await handleSuccessfullUpload(content.data.link)
})
.catch((reason) => {
console.log("Uploading to IMGUR failed", reason)
// @ts-ignore
onFail(reason)
})
}
SourceIcon(): BaseUIElement {
return undefined;
return undefined
}
public async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
if (Imgur.defaultValuePrefix.some(prefix => value.startsWith(prefix))) {
return [Promise.resolve({
url: value,
key: key,
provider: this
})]
if (Imgur.defaultValuePrefix.some((prefix) => value.startsWith(prefix))) {
return [
Promise.resolve({
url: value,
key: key,
provider: this,
}),
]
}
return []
}
/**
* Download the attribution from attribution
*
*
* const data = {"data":{"id":"I9t6B7B","title":"Station Knokke","description":"author:Pieter Vander Vennet\r\nlicense:CC-BY 4.0\r\nosmid:node\/9812712386","datetime":1655052078,"type":"image\/jpeg","animated":false,"width":2400,"height":1795,"size":910872,"views":2,"bandwidth":1821744,"vote":null,"favorite":false,"nsfw":false,"section":null,"account_url":null,"account_id":null,"is_ad":false,"in_most_viral":false,"has_sound":false,"tags":[],"ad_type":0,"ad_url":"","edited":"0","in_gallery":false,"link":"https:\/\/i.imgur.com\/I9t6B7B.jpg","ad_config":{"safeFlags":["not_in_gallery","share"],"highRiskFlags":[],"unsafeFlags":["sixth_mod_unsafe"],"wallUnsafeFlags":[],"showsAds":false,"showAdLevel":1}},"success":true,"status":200}
* Utils.injectJsonDownloadForTests("https://api.imgur.com/3/image/E0RuAK3", data)
* const licenseInfo = await Imgur.singleton.DownloadAttribution("https://i.imgur.com/E0RuAK3.jpg")
@ -110,29 +115,27 @@ export class Imgur extends ImageProvider {
* expected.artist = "Pieter Vander Vennet"
* licenseInfo // => expected
*/
public async DownloadAttribution (url: string) : Promise<LicenseInfo> {
const hash = url.substr("https://i.imgur.com/".length).split(".jpg")[0];
public async DownloadAttribution(url: string): Promise<LicenseInfo> {
const hash = url.substr("https://i.imgur.com/".length).split(".jpg")[0]
const apiUrl = 'https://api.imgur.com/3/image/' + hash;
const response = await Utils.downloadJsonCached(apiUrl, 365*24*60*60,
{Authorization: 'Client-ID ' + Constants.ImgurApiKey})
const apiUrl = "https://api.imgur.com/3/image/" + hash
const response = await Utils.downloadJsonCached(apiUrl, 365 * 24 * 60 * 60, {
Authorization: "Client-ID " + Constants.ImgurApiKey,
})
const descr: string = response.data.description ?? "";
const data: any = {};
const descr: string = response.data.description ?? ""
const data: any = {}
for (const tag of descr.split("\n")) {
const kv = tag.split(":");
const k = kv[0];
data[k] = kv[1]?.replace(/\r/g, "");
const kv = tag.split(":")
const k = kv[0]
data[k] = kv[1]?.replace(/\r/g, "")
}
const licenseInfo = new LicenseInfo()
const licenseInfo = new LicenseInfo();
licenseInfo.licenseShortName = data.license;
licenseInfo.artist = data.author;
licenseInfo.licenseShortName = data.license
licenseInfo.artist = data.author
return licenseInfo
}
}
}

View file

@ -1,16 +1,15 @@
import {UIEventSource} from "../UIEventSource";
import {Imgur} from "./Imgur";
import { UIEventSource } from "../UIEventSource"
import { Imgur } from "./Imgur"
export default class ImgurUploader {
public readonly queue: UIEventSource<string[]> = new UIEventSource<string[]>([]);
public readonly failed: UIEventSource<string[]> = new UIEventSource<string[]>([]);
public readonly success: UIEventSource<string[]> = new UIEventSource<string[]>([]);
public maxFileSizeInMegabytes = 10;
private readonly _handleSuccessUrl: (string) => Promise<void>;
public readonly queue: UIEventSource<string[]> = new UIEventSource<string[]>([])
public readonly failed: UIEventSource<string[]> = new UIEventSource<string[]>([])
public readonly success: UIEventSource<string[]> = new UIEventSource<string[]>([])
public maxFileSizeInMegabytes = 10
private readonly _handleSuccessUrl: (string) => Promise<void>
constructor(handleSuccessUrl: (string) => Promise<void>) {
this._handleSuccessUrl = handleSuccessUrl;
this._handleSuccessUrl = handleSuccessUrl
}
public uploadMany(title: string, description: string, files: FileList): void {
@ -19,25 +18,26 @@ export default class ImgurUploader {
}
this.queue.ping()
const self = this;
const self = this
this.queue.setData([...self.queue.data])
Imgur.uploadMultiple(title,
Imgur.uploadMultiple(
title,
description,
files,
async function (url) {
console.log("File saved at", url);
console.log("File saved at", url)
self.success.data.push(url)
self.success.ping();
await self._handleSuccessUrl(url);
self.success.ping()
await self._handleSuccessUrl(url)
},
function () {
console.log("All uploads completed");
console.log("All uploads completed")
},
function (failReason) {
console.log("Upload failed due to ", failReason)
self.failed.setData([...self.failed.data, failReason])
}
);
)
}
}
}

View file

@ -1,12 +1,12 @@
export class LicenseInfo {
title: string = ""
artist: string = "";
license: string = undefined;
licenseShortName: string = "";
usageTerms: string = "";
attributionRequired: boolean = false;
copyrighted: boolean = false;
credit: string = "";
description: string = "";
artist: string = ""
license: string = undefined
licenseShortName: string = ""
usageTerms: string = ""
attributionRequired: boolean = false
copyrighted: boolean = false
credit: string = ""
description: string = ""
informationLocation: URL = undefined
}
}

View file

@ -1,21 +1,26 @@
import ImageProvider, {ProvidedImage} from "./ImageProvider";
import BaseUIElement from "../../UI/BaseUIElement";
import Svg from "../../Svg";
import {Utils} from "../../Utils";
import {LicenseInfo} from "./LicenseInfo";
import Constants from "../../Models/Constants";
import ImageProvider, { ProvidedImage } from "./ImageProvider"
import BaseUIElement from "../../UI/BaseUIElement"
import Svg from "../../Svg"
import { Utils } from "../../Utils"
import { LicenseInfo } from "./LicenseInfo"
import Constants from "../../Models/Constants"
export class Mapillary extends ImageProvider {
public static readonly singleton = new Mapillary();
public static readonly singleton = new Mapillary()
private static readonly valuePrefix = "https://a.mapillary.com"
public static readonly valuePrefixes = [Mapillary.valuePrefix, "http://mapillary.com", "https://mapillary.com", "http://www.mapillary.com", "https://www.mapillary.com"]
public static readonly valuePrefixes = [
Mapillary.valuePrefix,
"http://mapillary.com",
"https://mapillary.com",
"http://www.mapillary.com",
"https://www.mapillary.com",
]
defaultKeyPrefixes = ["mapillary", "image"]
/**
* Indicates that this is the same URL
* Ignores 'stp' parameter
*
*
* const a = "https://scontent-bru2-1.xx.fbcdn.net/m1/v/t6/An8xm5SGLt20ETziNqzhhBd8b8S5GHLiIu8N6BbyqHFohFAQoaJJPG8i5yQiSwjYmEqXSfVeoCmpiyBJICEkQK98JOB21kkJoBS8VdhYa-Ty93lBnznQyesJBtKcb32foGut2Hgt10hEMWJbE3dDgA?stp=s1024x768&ccb=10-5&oh=00_AT-ZGTXHzihoaQYBILmEiAEKR64z_IWiTlcAYq_D7Ka0-Q&oe=6278C456&_nc_sid=122ab1"
* const b = "https://scontent-bru2-1.xx.fbcdn.net/m1/v/t6/An8xm5SGLt20ETziNqzhhBd8b8S5GHLiIu8N6BbyqHFohFAQoaJJPG8i5yQiSwjYmEqXSfVeoCmpiyBJICEkQK98JOB21kkJoBS8VdhYa-Ty93lBnznQyesJBtKcb32foGut2Hgt10hEMWJbE3dDgA?stp=s256x192&ccb=10-5&oh=00_AT9BZ1Rpc9zbY_uNu92A_4gj1joiy1b6VtgtLIu_7wh9Bg&oe=6278C456&_nc_sid=122ab1"
* Mapillary.sameUrl(a, b) => true
@ -28,9 +33,9 @@ export class Mapillary extends ImageProvider {
const aUrl = new URL(a)
const bUrl = new URL(b)
if (aUrl.host !== bUrl.host || aUrl.pathname !== bUrl.pathname) {
return false;
return false
}
let allSame = true;
let allSame = true
aUrl.searchParams.forEach((value, key) => {
if (key === "stp") {
// This is the key indicating the image size on mapillary; we ignore it
@ -41,20 +46,18 @@ export class Mapillary extends ImageProvider {
return
}
})
return allSame;
return allSame
} catch (e) {
console.debug("Could not compare ", a, "and", b, "due to", e)
}
return false;
return false
}
/**
* Returns the correct key for API v4.0
*/
private static ExtractKeyFromURL(value: string): number {
let key: string;
let key: string
const newApiFormat = value.match(/https?:\/\/www.mapillary.com\/app\/\?pKey=([0-9]*)/)
if (newApiFormat !== null) {
@ -62,7 +65,7 @@ export class Mapillary extends ImageProvider {
} else if (value.startsWith(Mapillary.valuePrefix)) {
key = value.substring(0, value.lastIndexOf("?")).substring(value.lastIndexOf("/") + 1)
} else if (value.match("[0-9]*")) {
key = value;
key = value
}
const keyAsNumber = Number(key)
@ -74,7 +77,7 @@ export class Mapillary extends ImageProvider {
}
SourceIcon(backlinkSource?: string): BaseUIElement {
return Svg.mapillary_svg();
return Svg.mapillary_svg()
}
async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
@ -83,26 +86,30 @@ export class Mapillary extends ImageProvider {
public async DownloadAttribution(url: string): Promise<LicenseInfo> {
const license = new LicenseInfo()
license.artist = "Contributor name unavailable";
license.license = "CC BY-SA 4.0";
license.artist = "Contributor name unavailable"
license.license = "CC BY-SA 4.0"
// license.license = "Creative Commons Attribution-ShareAlike 4.0 International License";
license.attributionRequired = true;
license.attributionRequired = true
return license
}
private async PrepareUrlAsync(key: string, value: string): Promise<ProvidedImage> {
const mapillaryId = Mapillary.ExtractKeyFromURL(value)
if (mapillaryId === undefined) {
return undefined;
return undefined
}
const metadataUrl = 'https://graph.mapillary.com/' + mapillaryId + '?fields=thumb_1024_url&&access_token=' + Constants.mapillary_client_token_v4;
const response = await Utils.downloadJsonCached(metadataUrl,60*60)
const url = <string>response["thumb_1024_url"];
const metadataUrl =
"https://graph.mapillary.com/" +
mapillaryId +
"?fields=thumb_1024_url&&access_token=" +
Constants.mapillary_client_token_v4
const response = await Utils.downloadJsonCached(metadataUrl, 60 * 60)
const url = <string>response["thumb_1024_url"]
return {
url: url,
provider: this,
key: key
key: key,
}
}
}
}

View file

@ -1,11 +1,10 @@
import ImageProvider, {ProvidedImage} from "./ImageProvider";
import BaseUIElement from "../../UI/BaseUIElement";
import Svg from "../../Svg";
import {WikimediaImageProvider} from "./WikimediaImageProvider";
import Wikidata from "../Web/Wikidata";
import ImageProvider, { ProvidedImage } from "./ImageProvider"
import BaseUIElement from "../../UI/BaseUIElement"
import Svg from "../../Svg"
import { WikimediaImageProvider } from "./WikimediaImageProvider"
import Wikidata from "../Web/Wikidata"
export class WikidataImageProvider extends ImageProvider {
public static readonly singleton = new WikidataImageProvider()
public readonly defaultKeyPrefixes = ["wikidata"]
@ -14,7 +13,7 @@ export class WikidataImageProvider extends ImageProvider {
}
public SourceIcon(backlinkSource?: string): BaseUIElement {
throw Svg.wikidata_svg();
throw Svg.wikidata_svg()
}
public async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
@ -39,7 +38,10 @@ export class WikidataImageProvider extends ImageProvider {
}
const commons = entity.commons
if (commons !== undefined && (commons.startsWith("Category:") || commons.startsWith("File:"))) {
if (
commons !== undefined &&
(commons.startsWith("Category:") || commons.startsWith("File:"))
) {
const promises = await WikimediaImageProvider.singleton.ExtractUrls(undefined, commons)
allImages.push(...promises)
}
@ -47,7 +49,6 @@ export class WikidataImageProvider extends ImageProvider {
}
public DownloadAttribution(url: string): Promise<any> {
throw new Error("Method not implemented; shouldn't be needed!");
throw new Error("Method not implemented; shouldn't be needed!")
}
}
}

View file

@ -1,45 +1,47 @@
import ImageProvider, {ProvidedImage} from "./ImageProvider";
import BaseUIElement from "../../UI/BaseUIElement";
import Svg from "../../Svg";
import Link from "../../UI/Base/Link";
import {Utils} from "../../Utils";
import {LicenseInfo} from "./LicenseInfo";
import Wikimedia from "../Web/Wikimedia";
import ImageProvider, { ProvidedImage } from "./ImageProvider"
import BaseUIElement from "../../UI/BaseUIElement"
import Svg from "../../Svg"
import Link from "../../UI/Base/Link"
import { Utils } from "../../Utils"
import { LicenseInfo } from "./LicenseInfo"
import Wikimedia from "../Web/Wikimedia"
/**
* This module provides endpoints for wikimedia and others
*/
export class WikimediaImageProvider extends ImageProvider {
public static readonly singleton = new WikimediaImageProvider();
public static readonly commonsPrefixes = ["https://commons.wikimedia.org/wiki/", "https://upload.wikimedia.org", "File:"]
public static readonly singleton = new WikimediaImageProvider()
public static readonly commonsPrefixes = [
"https://commons.wikimedia.org/wiki/",
"https://upload.wikimedia.org",
"File:",
]
private readonly commons_key = "wikimedia_commons"
public readonly defaultKeyPrefixes = [this.commons_key, "image"]
private constructor() {
super();
super()
}
private static ExtractFileName(url: string) {
if (!url.startsWith("http")) {
return url;
return url
}
const path = new URL(url).pathname
return path.substring(path.lastIndexOf("/") + 1);
return path.substring(path.lastIndexOf("/") + 1)
}
private static PrepareUrl(value: string): string {
if (value.toLowerCase().startsWith("https://commons.wikimedia.org/wiki/")) {
return value;
return value
}
return (`https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(value)}?width=500&height=400`)
return `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(
value
)}?width=500&height=400`
}
private static startsWithCommonsPrefix(value: string): boolean {
return WikimediaImageProvider.commonsPrefixes.some(prefix => value.startsWith(prefix))
return WikimediaImageProvider.commonsPrefixes.some((prefix) => value.startsWith(prefix))
}
private static removeCommonsPrefix(value: string): string {
@ -49,7 +51,7 @@ export class WikimediaImageProvider extends ImageProvider {
if (!value.startsWith("File:")) {
value = "File:" + value
}
return value;
return value
}
for (const prefix of WikimediaImageProvider.commonsPrefixes) {
@ -61,21 +63,20 @@ export class WikimediaImageProvider extends ImageProvider {
return part
}
}
return value;
return value
}
SourceIcon(backlink: string): BaseUIElement {
const img = Svg.wikimedia_commons_white_svg()
.SetStyle("width:2em;height: 2em");
const img = Svg.wikimedia_commons_white_svg().SetStyle("width:2em;height: 2em")
if (backlink === undefined) {
return img
}
return new Link(Svg.wikimedia_commons_white_img,
`https://commons.wikimedia.org/wiki/${backlink}`, true)
return new Link(
Svg.wikimedia_commons_white_img,
`https://commons.wikimedia.org/wiki/${backlink}`,
true
)
}
public PrepUrl(value: string): ProvidedImage {
@ -99,7 +100,9 @@ export class WikimediaImageProvider extends ImageProvider {
value = WikimediaImageProvider.removeCommonsPrefix(value)
if (value.startsWith("Category:")) {
const urls = await Wikimedia.GetCategoryContents(value)
return urls.filter(url => url.startsWith("File:")).map(image => Promise.resolve(this.UrlForImage(image)))
return urls
.filter((url) => url.startsWith("File:"))
.map((image) => Promise.resolve(this.UrlForImage(image)))
}
if (value.startsWith("File:")) {
return [Promise.resolve(this.UrlForImage(value))]
@ -116,24 +119,30 @@ export class WikimediaImageProvider extends ImageProvider {
filename = WikimediaImageProvider.ExtractFileName(filename)
if (filename === "") {
return undefined;
return undefined
}
const url = "https://en.wikipedia.org/w/" +
const url =
"https://en.wikipedia.org/w/" +
"api.php?action=query&prop=imageinfo&iiprop=extmetadata&" +
"titles=" + filename +
"&format=json&origin=*";
const data = await Utils.downloadJsonCached(url,365*24*60*60)
const licenseInfo = new LicenseInfo();
"titles=" +
filename +
"&format=json&origin=*"
const data = await Utils.downloadJsonCached(url, 365 * 24 * 60 * 60)
const licenseInfo = new LicenseInfo()
const pageInfo = data.query.pages[-1]
if (pageInfo === undefined) {
return undefined;
return undefined
}
const license = (pageInfo.imageinfo ?? [])[0]?.extmetadata;
const license = (pageInfo.imageinfo ?? [])[0]?.extmetadata
if (license === undefined) {
console.warn("The file", filename, "has no usable metedata or license attached... Please fix the license info file yourself!")
return undefined;
console.warn(
"The file",
filename,
"has no usable metedata or license attached... Please fix the license info file yourself!"
)
return undefined
}
let title = pageInfo.title
@ -145,26 +154,22 @@ export class WikimediaImageProvider extends ImageProvider {
}
licenseInfo.title = title
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;
licenseInfo.informationLocation = new URL("https://en.wikipedia.org/wiki/"+pageInfo.title)
return licenseInfo;
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
licenseInfo.informationLocation = new URL("https://en.wikipedia.org/wiki/" + pageInfo.title)
return licenseInfo
}
private UrlForImage(image: string): ProvidedImage {
if (!image.startsWith("File:")) {
image = "File:" + image
}
return {url: WikimediaImageProvider.PrepareUrl(image), key: undefined, provider: this}
return { url: WikimediaImageProvider.PrepareUrl(image), key: undefined, provider: this }
}
}

View file

@ -1,39 +1,39 @@
import Constants from "../Models/Constants";
import Constants from "../Models/Constants"
export default class Maproulette {
/**
* The API endpoint to use
*/
endpoint: string;
/**
* The API endpoint to use
*/
endpoint: string
/**
* The API key to use for all requests
*/
private apiKey: string;
/**
* The API key to use for all requests
*/
private apiKey: string
/**
* Creates a new Maproulette instance
* @param endpoint The API endpoint to use
*/
constructor(endpoint: string = "https://maproulette.org/api/v2") {
this.endpoint = endpoint;
this.apiKey = Constants.MaprouletteApiKey;
}
/**
* Close a task
* @param taskId The task to close
*/
async closeTask(taskId: number): Promise<void> {
const response = await fetch(`${this.endpoint}/task/${taskId}/1`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"apiKey": this.apiKey,
},
});
if (response.status !== 304) {
console.log(`Failed to close task: ${response.status}`);
/**
* Creates a new Maproulette instance
* @param endpoint The API endpoint to use
*/
constructor(endpoint: string = "https://maproulette.org/api/v2") {
this.endpoint = endpoint
this.apiKey = Constants.MaprouletteApiKey
}
/**
* Close a task
* @param taskId The task to close
*/
async closeTask(taskId: number): Promise<void> {
const response = await fetch(`${this.endpoint}/task/${taskId}/1`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
apiKey: this.apiKey,
},
})
if (response.status !== 304) {
console.log(`Failed to close task: ${response.status}`)
}
}
}
}

View file

@ -1,8 +1,7 @@
import SimpleMetaTaggers, {SimpleMetaTagger} from "./SimpleMetaTagger";
import {ExtraFuncParams, ExtraFunctions} from "./ExtraFunctions";
import LayerConfig from "../Models/ThemeConfig/LayerConfig";
import {ElementStorage} from "./ElementStorage";
import SimpleMetaTaggers, { SimpleMetaTagger } from "./SimpleMetaTagger"
import { ExtraFuncParams, ExtraFunctions } from "./ExtraFunctions"
import LayerConfig from "../Models/ThemeConfig/LayerConfig"
import { ElementStorage } from "./ElementStorage"
/**
* Metatagging adds various tags to the elements, e.g. lat, lon, surface area, ...
@ -10,10 +9,8 @@ import {ElementStorage} from "./ElementStorage";
* All metatags start with an underscore
*/
export default class MetaTagging {
private static errorPrintCount = 0;
private static readonly stopErrorOutputAt = 10;
private static errorPrintCount = 0
private static readonly stopErrorOutputAt = 10
private static retaggingFuncCache = new Map<string, ((feature: any) => void)[]>()
/**
@ -22,17 +19,19 @@ export default class MetaTagging {
*
* Returns true if at least one feature has changed properties
*/
public static addMetatags(features: { feature: any; freshness: Date }[],
params: ExtraFuncParams,
layer: LayerConfig,
state?: { allElements?: ElementStorage },
options?: {
includeDates?: true | boolean,
includeNonDates?: true | boolean,
evaluateStrict?: false | boolean
}): boolean {
public static addMetatags(
features: { feature: any; freshness: Date }[],
params: ExtraFuncParams,
layer: LayerConfig,
state?: { allElements?: ElementStorage },
options?: {
includeDates?: true | boolean
includeNonDates?: true | boolean
evaluateStrict?: false | boolean
}
): boolean {
if (features === undefined || features.length === 0) {
return;
return
}
console.log("Recalculating metatags...")
@ -52,51 +51,62 @@ export default class MetaTagging {
// The calculated functions - per layer - which add the new keys
const layerFuncs = this.createRetaggingFunc(layer, state)
let atLeastOneFeatureChanged = false;
let atLeastOneFeatureChanged = false
for (let i = 0; i < features.length; i++) {
const ff = features[i];
const ff = features[i]
const feature = ff.feature
const freshness = ff.freshness
let somethingChanged = false
let definedTags = new Set(Object.getOwnPropertyNames(feature.properties))
for (const metatag of metatagsToApply) {
try {
if (!metatag.keys.some(key => feature.properties[key] === undefined)) {
if (!metatag.keys.some((key) => feature.properties[key] === undefined)) {
// All keys are already defined, we probably already ran this one
continue
}
if (metatag.isLazy) {
if (!metatag.keys.some(key => !definedTags.has(key))) {
if (!metatag.keys.some((key) => !definedTags.has(key))) {
// All keys are defined - lets skip!
continue
}
somethingChanged = true;
somethingChanged = true
metatag.applyMetaTagsOnFeature(feature, freshness, layer, state)
if(options?.evaluateStrict){
if (options?.evaluateStrict) {
for (const key of metatag.keys) {
feature.properties[key]
}
}
} else {
const newValueAdded = metatag.applyMetaTagsOnFeature(feature, freshness, layer, state)
const newValueAdded = metatag.applyMetaTagsOnFeature(
feature,
freshness,
layer,
state
)
/* Note that the expression:
* `somethingChanged = newValueAdded || metatag.applyMetaTagsOnFeature(feature, freshness)`
* Is WRONG
*
* IF something changed is `true` due to an earlier run, it will short-circuit and _not_ evaluate the right hand of the OR,
* thus not running an update!
*/
* `somethingChanged = newValueAdded || metatag.applyMetaTagsOnFeature(feature, freshness)`
* Is WRONG
*
* IF something changed is `true` due to an earlier run, it will short-circuit and _not_ evaluate the right hand of the OR,
* thus not running an update!
*/
somethingChanged = newValueAdded || somethingChanged
}
} catch (e) {
console.error("Could not calculate metatag for ", metatag.keys.join(","), ":", e, e.stack)
console.error(
"Could not calculate metatag for ",
metatag.keys.join(","),
":",
e,
e.stack
)
}
}
if (layerFuncs !== undefined) {
let retaggingChanged = false;
let retaggingChanged = false
try {
retaggingChanged = layerFuncs(params, feature)
} catch (e) {
@ -113,42 +123,62 @@ export default class MetaTagging {
return atLeastOneFeatureChanged
}
private static createFunctionsForFeature(layerId: string, calculatedTags: [string, string, boolean][]): ((feature: any) => void)[] {
const functions: ((feature: any) => any)[] = [];
private static createFunctionsForFeature(
layerId: string,
calculatedTags: [string, string, boolean][]
): ((feature: any) => void)[] {
const functions: ((feature: any) => any)[] = []
for (const entry of calculatedTags) {
const key = entry[0]
const code = entry[1];
const code = entry[1]
const isStrict = entry[2]
if (code === undefined) {
continue;
continue
}
const calculateAndAssign: ((feat: any) => any) = (feat) => {
const calculateAndAssign: (feat: any) => any = (feat) => {
try {
let result = new Function("feat", "return " + code + ";")(feat);
let result = new Function("feat", "return " + code + ";")(feat)
if (result === "") {
result === undefined
}
if (result !== undefined && typeof result !== "string") {
// Make sure it is a string!
result = JSON.stringify(result);
result = JSON.stringify(result)
}
delete feat.properties[key]
feat.properties[key] = result;
feat.properties[key] = result
return result
} catch (e) {
if (MetaTagging.errorPrintCount < MetaTagging.stopErrorOutputAt) {
console.warn("Could not calculate a " + (isStrict ? "strict " : "") + " calculated tag for key " + key + " defined by " + code + " (in layer" + layerId + ") due to \n" + e + "\n. Are you the theme creator? Doublecheck your code. Note that the metatags might not be stable on new features", e, e.stack)
MetaTagging.errorPrintCount++;
console.warn(
"Could not calculate a " +
(isStrict ? "strict " : "") +
" calculated tag for key " +
key +
" defined by " +
code +
" (in layer" +
layerId +
") due to \n" +
e +
"\n. Are you the theme creator? Doublecheck your code. Note that the metatags might not be stable on new features",
e,
e.stack
)
MetaTagging.errorPrintCount++
if (MetaTagging.errorPrintCount == MetaTagging.stopErrorOutputAt) {
console.error("Got ", MetaTagging.stopErrorOutputAt, " errors calculating this metatagging - stopping output now")
console.error(
"Got ",
MetaTagging.stopErrorOutputAt,
" errors calculating this metatagging - stopping output now"
)
}
}
return undefined;
return undefined
}
}
if (isStrict) {
functions.push(calculateAndAssign)
continue
@ -162,15 +192,14 @@ export default class MetaTagging {
enumerable: false, // By setting this as not enumerable, the localTileSaver will _not_ calculate this
get: function () {
return calculateAndAssign(feature)
}
},
})
return undefined
}
functions.push(f)
}
return functions;
return functions
}
/**
@ -179,39 +208,37 @@ export default class MetaTagging {
* @param state
* @private
*/
private static createRetaggingFunc(layer: LayerConfig, state):
((params: ExtraFuncParams, feature: any) => boolean) {
const calculatedTags: [string, string, boolean][] = layer.calculatedTags;
private static createRetaggingFunc(
layer: LayerConfig,
state
): (params: ExtraFuncParams, feature: any) => boolean {
const calculatedTags: [string, string, boolean][] = layer.calculatedTags
if (calculatedTags === undefined || calculatedTags.length === 0) {
return undefined;
return undefined
}
let functions: ((feature: any) => void)[] = MetaTagging.retaggingFuncCache.get(layer.id);
let functions: ((feature: any) => void)[] = MetaTagging.retaggingFuncCache.get(layer.id)
if (functions === undefined) {
functions = MetaTagging.createFunctionsForFeature(layer.id, calculatedTags)
MetaTagging.retaggingFuncCache.set(layer.id, functions)
}
return (params: ExtraFuncParams, feature) => {
const tags = feature.properties
if (tags === undefined) {
return;
return
}
try {
ExtraFunctions.FullPatchFeature(params, feature);
ExtraFunctions.FullPatchFeature(params, feature)
for (const f of functions) {
f(feature);
f(feature)
}
state?.allElements?.getEventSourceById(feature.properties.id)?.ping();
state?.allElements?.getEventSourceById(feature.properties.id)?.ping()
} catch (e) {
console.error("Invalid syntax in calculated tags or some other error: ", e)
}
return true; // Something changed
return true // Something changed
}
}
}

View file

@ -1,18 +1,17 @@
import {OsmNode, OsmRelation, OsmWay} from "../OsmObject";
import { OsmNode, OsmRelation, OsmWay } from "../OsmObject"
/**
* Represents a single change to an object
*/
export interface ChangeDescription {
/**
* Metadata to be included in the changeset
*/
meta: {
/*
* The theme with which this changeset was made
*/
theme: string,
* The theme with which this changeset was made
*/
theme: string
/**
* The type of the change
*/
@ -20,22 +19,22 @@ export interface ChangeDescription {
/**
* THe motivation for the change, e.g. 'deleted because does not exist anymore'
*/
specialMotivation?: string,
specialMotivation?: string
/**
* Added by Changes.ts
*/
distanceToObject?: number
},
}
/**
* Identifier of the object
*/
type: "node" | "way" | "relation",
type: "node" | "way" | "relation"
/**
* Identifier of the object
* Negative for new objects
*/
id: number,
id: number
/**
* All changes to tags
@ -43,7 +42,7 @@ export interface ChangeDescription {
*
* Note that this list will only contain the _changes_ to the tags, not the full set of tags
*/
tags?: { k: string, v: string }[],
tags?: { k: string; v: string }[]
/**
* A change to the geometry:
@ -51,17 +50,20 @@ export interface ChangeDescription {
* 2) Change of way geometry
* 3) Change of relation members (untested)
*/
changes?: {
lat: number,
lon: number
} | {
/* Coordinates are only used for rendering. They should be LON, LAT
* */
coordinates: [number, number][]
nodes: number[],
} | {
members: { type: "node" | "way" | "relation", ref: number, role: string }[]
}
changes?:
| {
lat: number
lon: number
}
| {
/* Coordinates are only used for rendering. They should be LON, LAT
* */
coordinates: [number, number][]
nodes: number[]
}
| {
members: { type: "node" | "way" | "relation"; ref: number; role: string }[]
}
/*
Set to delete the object
@ -70,7 +72,6 @@ export interface ChangeDescription {
}
export class ChangeDescriptionTools {
/**
* Rewrites all the ids in a changeDescription
*
@ -111,7 +112,7 @@ export class ChangeDescriptionTools {
* const rewritten = ChangeDescriptionTools.rewriteIds(change, mapping)
* rewritten.id // => 789
* rewritten.changes["nodes"] // => [42,43,44, 68453]
*
*
* // should rewrite ids in relationship members
* const change = <ChangeDescription> {
* type: "way",
@ -130,44 +131,49 @@ export class ChangeDescriptionTools {
* rewritten.changes["members"] // => [{type: "way", ref: 42, role: "outer"},{type: "way", ref: 48, role: "outer"}]
*
*/
public static rewriteIds(change: ChangeDescription, mappings: Map<string, string>): ChangeDescription {
public static rewriteIds(
change: ChangeDescription,
mappings: Map<string, string>
): ChangeDescription {
const key = change.type + "/" + change.id
const wayHasChangedNode = ((change.changes ?? {})["nodes"] ?? []).some(id => mappings.has("node/" + id));
const relationHasChangedMembers = ((change.changes ?? {})["members"] ?? [])
.some((obj:{type: string, ref: number}) => mappings.has(obj.type+"/" + obj.ref));
const wayHasChangedNode = ((change.changes ?? {})["nodes"] ?? []).some((id) =>
mappings.has("node/" + id)
)
const relationHasChangedMembers = ((change.changes ?? {})["members"] ?? []).some(
(obj: { type: string; ref: number }) => mappings.has(obj.type + "/" + obj.ref)
)
const hasSomeChange = mappings.has(key)
|| wayHasChangedNode || relationHasChangedMembers
if(hasSomeChange){
change = {...change}
const hasSomeChange = mappings.has(key) || wayHasChangedNode || relationHasChangedMembers
if (hasSomeChange) {
change = { ...change }
}
if (mappings.has(key)) {
const [_, newId] = mappings.get(key).split("/")
change.id = Number.parseInt(newId)
}
if(wayHasChangedNode){
change.changes = {...change.changes}
change.changes["nodes"] = change.changes["nodes"].map(id => {
const key = "node/"+id
if(!mappings.has(key)){
if (wayHasChangedNode) {
change.changes = { ...change.changes }
change.changes["nodes"] = change.changes["nodes"].map((id) => {
const key = "node/" + id
if (!mappings.has(key)) {
return id
}
const [_, newId] = mappings.get(key).split("/")
return Number.parseInt(newId)
})
}
if(relationHasChangedMembers){
change.changes = {...change.changes}
if (relationHasChangedMembers) {
change.changes = { ...change.changes }
change.changes["members"] = change.changes["members"].map(
(obj:{type: string, ref: number}) => {
const key = obj.type+"/"+obj.ref;
if(!mappings.has(key)){
(obj: { type: string; ref: number }) => {
const key = obj.type + "/" + obj.ref
if (!mappings.has(key)) {
return obj
}
const [_, newId] = mappings.get(key).split("/")
return {...obj, ref: Number.parseInt(newId)}
return { ...obj, ref: Number.parseInt(newId) }
}
)
}
@ -193,4 +199,4 @@ export class ChangeDescriptionTools {
return r.asGeoJson().geometry
}
}
}
}

View file

@ -1,41 +1,44 @@
import {ChangeDescription} from "./ChangeDescription";
import OsmChangeAction from "./OsmChangeAction";
import {Changes} from "../Changes";
import { ChangeDescription } from "./ChangeDescription"
import OsmChangeAction from "./OsmChangeAction"
import { Changes } from "../Changes"
export default class ChangeLocationAction extends OsmChangeAction {
private readonly _id: number;
private readonly _newLonLat: [number, number];
private readonly _meta: { theme: string; reason: string };
private readonly _id: number
private readonly _newLonLat: [number, number]
private readonly _meta: { theme: string; reason: string }
constructor(id: string, newLonLat: [number, number], meta: {
theme: string,
reason: string
}) {
super(id, true);
constructor(
id: string,
newLonLat: [number, number],
meta: {
theme: string
reason: string
}
) {
super(id, true)
if (!id.startsWith("node/")) {
throw "Invalid ID: only 'node/number' is accepted"
}
this._id = Number(id.substring("node/".length))
this._newLonLat = newLonLat;
this._meta = meta;
this._newLonLat = newLonLat
this._meta = meta
}
protected async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const d: ChangeDescription = {
changes: {
lat: this._newLonLat[1],
lon: this._newLonLat[0]
lon: this._newLonLat[0],
},
type: "node",
id: this._id, meta: {
id: this._id,
meta: {
changeType: "move",
theme: this._meta.theme,
specialMotivation: this._meta.reason
}
specialMotivation: this._meta.reason,
},
}
return [d]
}
}
}

View file

@ -1,65 +1,77 @@
import OsmChangeAction from "./OsmChangeAction";
import {Changes} from "../Changes";
import {ChangeDescription} from "./ChangeDescription";
import {TagsFilter} from "../../Tags/TagsFilter";
import {OsmTags} from "../../../Models/OsmFeature";
import OsmChangeAction from "./OsmChangeAction"
import { Changes } from "../Changes"
import { ChangeDescription } from "./ChangeDescription"
import { TagsFilter } from "../../Tags/TagsFilter"
import { OsmTags } from "../../../Models/OsmFeature"
export default class ChangeTagAction extends OsmChangeAction {
private readonly _elementId: string;
private readonly _tagsFilter: TagsFilter;
private readonly _currentTags: Record<string, string> | OsmTags;
private readonly _meta: { theme: string, changeType: string };
private readonly _elementId: string
private readonly _tagsFilter: TagsFilter
private readonly _currentTags: Record<string, string> | OsmTags
private readonly _meta: { theme: string; changeType: string }
constructor(elementId: string,
tagsFilter: TagsFilter,
currentTags: Record<string, string>, meta: {
theme: string,
changeType: "answer" | "soft-delete" | "add-image" | string
}) {
super(elementId, true);
this._elementId = elementId;
this._tagsFilter = tagsFilter;
this._currentTags = currentTags;
this._meta = meta;
constructor(
elementId: string,
tagsFilter: TagsFilter,
currentTags: Record<string, string>,
meta: {
theme: string
changeType: "answer" | "soft-delete" | "add-image" | string
}
) {
super(elementId, true)
this._elementId = elementId
this._tagsFilter = tagsFilter
this._currentTags = currentTags
this._meta = meta
}
/**
* Doublechecks that no stupid values are added
*/
private static checkChange(kv: { k: string, v: string }): { k: string, v: string } {
const key = kv.k;
const value = kv.v;
private static checkChange(kv: { k: string; v: string }): { k: string; v: string } {
const key = kv.k
const value = kv.v
if (key === undefined || key === null) {
console.error("Invalid key:", key);
return undefined;
console.error("Invalid key:", key)
return undefined
}
if (value === undefined || value === null) {
console.error("Invalid value for ", key, ":", value);
return undefined;
console.error("Invalid value for ", key, ":", value)
return undefined
}
if (typeof value !== "string") {
console.error("Invalid value for ", key, "as it is not a string:", value)
return undefined;
return undefined
}
if (key.startsWith(" ") || value.startsWith(" ") || value.endsWith(" ") || key.endsWith(" ")) {
if (
key.startsWith(" ") ||
value.startsWith(" ") ||
value.endsWith(" ") ||
key.endsWith(" ")
) {
console.warn("Tag starts with or ends with a space - trimming anyway")
}
return {k: key.trim(), v: value.trim()};
return { k: key.trim(), v: value.trim() }
}
async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const changedTags: { k: string, v: string }[] = this._tagsFilter.asChange(this._currentTags).map(ChangeTagAction.checkChange)
const changedTags: { k: string; v: string }[] = this._tagsFilter
.asChange(this._currentTags)
.map(ChangeTagAction.checkChange)
const typeId = this._elementId.split("/")
const type = typeId[0]
const id = Number(typeId [1])
return [{
type: <"node" | "way" | "relation">type,
id: id,
tags: changedTags,
meta: this._meta
}]
const id = Number(typeId[1])
return [
{
type: <"node" | "way" | "relation">type,
id: id,
tags: changedTags,
meta: this._meta,
},
]
}
}
}

View file

@ -1,64 +1,69 @@
import {OsmCreateAction} from "./OsmChangeAction";
import {Tag} from "../../Tags/Tag";
import {Changes} from "../Changes";
import {ChangeDescription} from "./ChangeDescription";
import FeaturePipelineState from "../../State/FeaturePipelineState";
import FeatureSource from "../../FeatureSource/FeatureSource";
import CreateNewWayAction from "./CreateNewWayAction";
import CreateWayWithPointReuseAction, {MergePointConfig} from "./CreateWayWithPointReuseAction";
import {And} from "../../Tags/And";
import {TagUtils} from "../../Tags/TagUtils";
import { OsmCreateAction } from "./OsmChangeAction"
import { Tag } from "../../Tags/Tag"
import { Changes } from "../Changes"
import { ChangeDescription } from "./ChangeDescription"
import FeaturePipelineState from "../../State/FeaturePipelineState"
import FeatureSource from "../../FeatureSource/FeatureSource"
import CreateNewWayAction from "./CreateNewWayAction"
import CreateWayWithPointReuseAction, { MergePointConfig } from "./CreateWayWithPointReuseAction"
import { And } from "../../Tags/And"
import { TagUtils } from "../../Tags/TagUtils"
/**
* More or less the same as 'CreateNewWay', except that it'll try to reuse already existing points
*/
export default class CreateMultiPolygonWithPointReuseAction extends OsmCreateAction {
public newElementId: string = undefined;
public newElementIdNumber: number = undefined;
private readonly _tags: Tag[];
public newElementId: string = undefined
public newElementIdNumber: number = undefined
private readonly _tags: Tag[]
private readonly createOuterWay: CreateWayWithPointReuseAction
private readonly createInnerWays: CreateNewWayAction[]
private readonly geojsonPreview: any;
private readonly theme: string;
private readonly changeType: "import" | "create" | string;
private readonly geojsonPreview: any
private readonly theme: string
private readonly changeType: "import" | "create" | string
constructor(tags: Tag[],
outerRingCoordinates: [number, number][],
innerRingsCoordinates: [number, number][][],
state: FeaturePipelineState,
config: MergePointConfig[],
changeType: "import" | "create" | string
constructor(
tags: Tag[],
outerRingCoordinates: [number, number][],
innerRingsCoordinates: [number, number][][],
state: FeaturePipelineState,
config: MergePointConfig[],
changeType: "import" | "create" | string
) {
super(null, true);
this._tags = [...tags, new Tag("type", "multipolygon")];
this.changeType = changeType;
super(null, true)
this._tags = [...tags, new Tag("type", "multipolygon")]
this.changeType = changeType
this.theme = state?.layoutToUse?.id ?? ""
this.createOuterWay = new CreateWayWithPointReuseAction([], outerRingCoordinates, state, config)
this.createInnerWays = innerRingsCoordinates.map(ringCoordinates =>
new CreateNewWayAction([],
ringCoordinates.map(([lon, lat]) => ({lat, lon})),
{theme: state?.layoutToUse?.id}))
this.createOuterWay = new CreateWayWithPointReuseAction(
[],
outerRingCoordinates,
state,
config
)
this.createInnerWays = innerRingsCoordinates.map(
(ringCoordinates) =>
new CreateNewWayAction(
[],
ringCoordinates.map(([lon, lat]) => ({ lat, lon })),
{ theme: state?.layoutToUse?.id }
)
)
this.geojsonPreview = {
type: "Feature",
properties: TagUtils.changeAsProperties(new And(this._tags).asChange({})),
geometry: {
type: "Polygon",
coordinates: [
outerRingCoordinates,
...innerRingsCoordinates
]
}
coordinates: [outerRingCoordinates, ...innerRingsCoordinates],
},
}
}
public async getPreview(): Promise<FeatureSource> {
const outerPreview = await this.createOuterWay.getPreview()
outerPreview.features.data.push({
freshness: new Date(),
feature: this.geojsonPreview
feature: this.geojsonPreview,
})
return outerPreview
}
@ -66,13 +71,12 @@ export default class CreateMultiPolygonWithPointReuseAction extends OsmCreateAct
protected async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
console.log("Running CMPWPRA")
const descriptions: ChangeDescription[] = []
descriptions.push(...await this.createOuterWay.CreateChangeDescriptions(changes));
descriptions.push(...(await this.createOuterWay.CreateChangeDescriptions(changes)))
for (const innerWay of this.createInnerWays) {
descriptions.push(...await innerWay.CreateChangeDescriptions(changes))
descriptions.push(...(await innerWay.CreateChangeDescriptions(changes)))
}
this.newElementIdNumber = changes.getNewID();
this.newElementIdNumber = changes.getNewID()
this.newElementId = "relation/" + this.newElementIdNumber
descriptions.push({
type: "relation",
@ -80,24 +84,25 @@ export default class CreateMultiPolygonWithPointReuseAction extends OsmCreateAct
tags: new And(this._tags).asChange({}),
meta: {
theme: this.theme,
changeType: this.changeType
changeType: this.changeType,
},
changes: {
members: [
{
type: "way",
ref: this.createOuterWay.newElementIdNumber,
role: "outer"
role: "outer",
},
// @ts-ignore
...this.createInnerWays.map(a => ({type: "way", ref: a.newElementIdNumber, role: "inner"}))
]
}
...this.createInnerWays.map((a) => ({
type: "way",
ref: a.newElementIdNumber,
role: "inner",
})),
],
},
})
return descriptions
}
}
}

View file

@ -1,13 +1,12 @@
import {Tag} from "../../Tags/Tag";
import {OsmCreateAction} from "./OsmChangeAction";
import {Changes} from "../Changes";
import {ChangeDescription} from "./ChangeDescription";
import {And} from "../../Tags/And";
import {OsmWay} from "../OsmObject";
import {GeoOperations} from "../../GeoOperations";
import { Tag } from "../../Tags/Tag"
import { OsmCreateAction } from "./OsmChangeAction"
import { Changes } from "../Changes"
import { ChangeDescription } from "./ChangeDescription"
import { And } from "../../Tags/And"
import { OsmWay } from "../OsmObject"
import { GeoOperations } from "../../GeoOperations"
export default class CreateNewNodeAction extends OsmCreateAction {
/**
* Maps previously created points onto their assigned ID, to reuse the point if uplaoded
* "lat,lon" --> id
@ -15,46 +14,47 @@ export default class CreateNewNodeAction extends OsmCreateAction {
private static readonly previouslyCreatedPoints = new Map<string, number>()
public newElementId: string = undefined
public newElementIdNumber: number = undefined
private readonly _basicTags: Tag[];
private readonly _lat: number;
private readonly _lon: number;
private readonly _snapOnto: OsmWay;
private readonly _reusePointDistance: number;
private meta: { changeType: "create" | "import"; theme: string; specialMotivation?: string };
private readonly _reusePreviouslyCreatedPoint: boolean;
private readonly _basicTags: Tag[]
private readonly _lat: number
private readonly _lon: number
private readonly _snapOnto: OsmWay
private readonly _reusePointDistance: number
private meta: { changeType: "create" | "import"; theme: string; specialMotivation?: string }
private readonly _reusePreviouslyCreatedPoint: boolean
constructor(basicTags: Tag[],
lat: number, lon: number,
options: {
allowReuseOfPreviouslyCreatedPoints?: boolean,
snapOnto?: OsmWay,
reusePointWithinMeters?: number,
theme: string,
changeType: "create" | "import" | null,
specialMotivation?: string
}) {
constructor(
basicTags: Tag[],
lat: number,
lon: number,
options: {
allowReuseOfPreviouslyCreatedPoints?: boolean
snapOnto?: OsmWay
reusePointWithinMeters?: number
theme: string
changeType: "create" | "import" | null
specialMotivation?: string
}
) {
super(null, basicTags !== undefined && basicTags.length > 0)
this._basicTags = basicTags;
this._lat = lat;
this._lon = lon;
this._basicTags = basicTags
this._lat = lat
this._lon = lon
if (lat === undefined || lon === undefined) {
throw "Lat or lon are undefined!"
}
this._snapOnto = options?.snapOnto;
this._snapOnto = options?.snapOnto
this._reusePointDistance = options?.reusePointWithinMeters ?? 1
this._reusePreviouslyCreatedPoint = options?.allowReuseOfPreviouslyCreatedPoints ?? (basicTags.length === 0)
this._reusePreviouslyCreatedPoint =
options?.allowReuseOfPreviouslyCreatedPoints ?? basicTags.length === 0
this.meta = {
theme: options.theme,
changeType: options.changeType,
specialMotivation: options.specialMotivation
specialMotivation: options.specialMotivation,
}
}
async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
if (this._reusePreviouslyCreatedPoint) {
const key = this._lat + "," + this._lon
const prev = CreateNewNodeAction.previouslyCreatedPoints
if (prev.has(key)) {
@ -64,17 +64,23 @@ export default class CreateNewNodeAction extends OsmCreateAction {
}
}
const id = changes.getNewID()
const properties = {
id: "node/" + id
id: "node/" + id,
}
this.setElementId(id)
for (const kv of this._basicTags) {
if (typeof kv.value !== "string") {
throw "Invalid value: don't use non-string value in a preset. The tag "+kv.key+"="+kv.value+" is not a string, the value is a "+typeof kv.value
throw (
"Invalid value: don't use non-string value in a preset. The tag " +
kv.key +
"=" +
kv.value +
" is not a string, the value is a " +
typeof kv.value
)
}
properties[kv.key] = kv.value;
properties[kv.key] = kv.value
}
const newPointChange: ChangeDescription = {
@ -83,32 +89,31 @@ export default class CreateNewNodeAction extends OsmCreateAction {
id: id,
changes: {
lat: this._lat,
lon: this._lon
lon: this._lon,
},
meta: this.meta
meta: this.meta,
}
if (this._snapOnto === undefined) {
return [newPointChange]
}
// Project the point onto the way
console.log("Snapping a node onto an existing way...")
const geojson = this._snapOnto.asGeoJson()
const projected = GeoOperations.nearestPoint(geojson, [this._lon, this._lat])
const projectedCoor= <[number, number]>projected.geometry.coordinates
const projectedCoor = <[number, number]>projected.geometry.coordinates
const index = projected.properties.index
// We check that it isn't close to an already existing point
let reusedPointId = undefined;
let outerring : [number,number][];
if(geojson.geometry.type === "LineString"){
outerring = <[number, number][]> geojson.geometry.coordinates
}else if(geojson.geometry.type === "Polygon"){
outerring =<[number, number][]> geojson.geometry.coordinates[0]
let reusedPointId = undefined
let outerring: [number, number][]
if (geojson.geometry.type === "LineString") {
outerring = <[number, number][]>geojson.geometry.coordinates
} else if (geojson.geometry.type === "Polygon") {
outerring = <[number, number][]>geojson.geometry.coordinates[0]
}
const prev= outerring[index]
const prev = outerring[index]
if (GeoOperations.distanceBetween(prev, projectedCoor) < this._reusePointDistance) {
// We reuse this point instead!
reusedPointId = this._snapOnto.nodes[index]
@ -120,20 +125,24 @@ export default class CreateNewNodeAction extends OsmCreateAction {
}
if (reusedPointId !== undefined) {
this.setElementId(reusedPointId)
return [{
tags: new And(this._basicTags).asChange(properties),
type: "node",
id: reusedPointId,
meta: this.meta
}]
return [
{
tags: new And(this._basicTags).asChange(properties),
type: "node",
id: reusedPointId,
meta: this.meta,
},
]
}
const locations = [...this._snapOnto.coordinates.map(([lat, lon]) =><[number,number]> [lon, lat])]
const locations = [
...this._snapOnto.coordinates.map(([lat, lon]) => <[number, number]>[lon, lat]),
]
const ids = [...this._snapOnto.nodes]
locations.splice(index + 1, 0, [this._lon, this._lat])
ids.splice(index + 1, 0, id)
// Allright, we have to insert a new point in the way
return [
newPointChange,
@ -142,15 +151,15 @@ export default class CreateNewNodeAction extends OsmCreateAction {
id: this._snapOnto.id,
changes: {
coordinates: locations,
nodes: ids
nodes: ids,
},
meta: this.meta
}
meta: this.meta,
},
]
}
private setElementId(id: number) {
this.newElementIdNumber = id;
this.newElementIdNumber = id
this.newElementId = "node/" + id
if (!this._reusePreviouslyCreatedPoint) {
return
@ -158,6 +167,4 @@ export default class CreateNewNodeAction extends OsmCreateAction {
const key = this._lat + "," + this._lon
CreateNewNodeAction.previouslyCreatedPoints.set(key, id)
}
}
}

View file

@ -1,19 +1,18 @@
import {ChangeDescription} from "./ChangeDescription";
import {OsmCreateAction} from "./OsmChangeAction";
import {Changes} from "../Changes";
import {Tag} from "../../Tags/Tag";
import CreateNewNodeAction from "./CreateNewNodeAction";
import {And} from "../../Tags/And";
import { ChangeDescription } from "./ChangeDescription"
import { OsmCreateAction } from "./OsmChangeAction"
import { Changes } from "../Changes"
import { Tag } from "../../Tags/Tag"
import CreateNewNodeAction from "./CreateNewNodeAction"
import { And } from "../../Tags/And"
export default class CreateNewWayAction extends OsmCreateAction {
public newElementId: string = undefined
public newElementIdNumber: number = undefined;
private readonly coordinates: ({ nodeId?: number, lat: number, lon: number })[];
private readonly tags: Tag[];
public newElementIdNumber: number = undefined
private readonly coordinates: { nodeId?: number; lat: number; lon: number }[]
private readonly tags: Tag[]
private readonly _options: {
theme: string
};
}
/***
* Creates a new way to upload to OSM
@ -21,33 +20,44 @@ export default class CreateNewWayAction extends OsmCreateAction {
* @param coordinates: the coordinates. Might have a nodeId, in this case, this node will be used
* @param options
*/
constructor(tags: Tag[], coordinates: ({ nodeId?: number, lat: number, lon: number })[],
options: {
theme: string
}) {
constructor(
tags: Tag[],
coordinates: { nodeId?: number; lat: number; lon: number }[],
options: {
theme: string
}
) {
super(null, true)
this.coordinates = [];
this.coordinates = []
for (const coordinate of coordinates) {
/* The 'PointReuseAction' is a bit buggy and might generate duplicate ids.
We filter those here, as the CreateWayWithPointReuseAction delegates the actual creation to here.
Filtering here also prevents similar bugs in other actions
*/
if(this.coordinates.length > 0 && coordinate.nodeId !== undefined && this.coordinates[this.coordinates.length - 1].nodeId === coordinate.nodeId){
if (
this.coordinates.length > 0 &&
coordinate.nodeId !== undefined &&
this.coordinates[this.coordinates.length - 1].nodeId === coordinate.nodeId
) {
// This is a duplicate id
console.warn("Skipping a node in createWay to avoid a duplicate node:", coordinate,"\nThe previous coordinates are: ", this.coordinates)
console.warn(
"Skipping a node in createWay to avoid a duplicate node:",
coordinate,
"\nThe previous coordinates are: ",
this.coordinates
)
continue
}
this.coordinates.push(coordinate)
}
this.tags = tags;
this._options = options;
this.tags = tags
this._options = options
}
public async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const newElements: ChangeDescription[] = []
const pointIds: number[] = []
@ -60,16 +70,15 @@ export default class CreateNewWayAction extends OsmCreateAction {
const newPoint = new CreateNewNodeAction([], coordinate.lat, coordinate.lon, {
allowReuseOfPreviouslyCreatedPoints: true,
changeType: null,
theme: this._options.theme
theme: this._options.theme,
})
newElements.push(...await newPoint.CreateChangeDescriptions(changes))
newElements.push(...(await newPoint.CreateChangeDescriptions(changes)))
pointIds.push(newPoint.newElementIdNumber)
}
// We have all created (or reused) all the points!
// Time to create the actual way
const id = changes.getNewID()
this.newElementIdNumber = id
const newWay = <ChangeDescription>{
@ -77,18 +86,16 @@ export default class CreateNewWayAction extends OsmCreateAction {
type: "way",
meta: {
theme: this._options.theme,
changeType: "import"
changeType: "import",
},
tags: new And(this.tags).asChange({}),
changes: {
nodes: pointIds,
coordinates: this.coordinates.map(c => [c.lon, c.lat])
}
coordinates: this.coordinates.map((c) => [c.lon, c.lat]),
},
}
newElements.push(newWay)
this.newElementId = "way/" + id
return newElements
}
}
}

View file

@ -1,20 +1,19 @@
import {OsmCreateAction} from "./OsmChangeAction";
import {Tag} from "../../Tags/Tag";
import {Changes} from "../Changes";
import {ChangeDescription} from "./ChangeDescription";
import FeaturePipelineState from "../../State/FeaturePipelineState";
import {BBox} from "../../BBox";
import {TagsFilter} from "../../Tags/TagsFilter";
import {GeoOperations} from "../../GeoOperations";
import FeatureSource from "../../FeatureSource/FeatureSource";
import StaticFeatureSource from "../../FeatureSource/Sources/StaticFeatureSource";
import CreateNewNodeAction from "./CreateNewNodeAction";
import CreateNewWayAction from "./CreateNewWayAction";
import { OsmCreateAction } from "./OsmChangeAction"
import { Tag } from "../../Tags/Tag"
import { Changes } from "../Changes"
import { ChangeDescription } from "./ChangeDescription"
import FeaturePipelineState from "../../State/FeaturePipelineState"
import { BBox } from "../../BBox"
import { TagsFilter } from "../../Tags/TagsFilter"
import { GeoOperations } from "../../GeoOperations"
import FeatureSource from "../../FeatureSource/FeatureSource"
import StaticFeatureSource from "../../FeatureSource/Sources/StaticFeatureSource"
import CreateNewNodeAction from "./CreateNewNodeAction"
import CreateNewWayAction from "./CreateNewWayAction"
export interface MergePointConfig {
withinRangeOfM: number,
ifMatches: TagsFilter,
withinRangeOfM: number
ifMatches: TagsFilter
mode: "reuse_osm_point" | "move_osm_point"
}
@ -33,12 +32,12 @@ interface CoordinateInfo {
/**
* The new coordinate
*/
lngLat: [number, number],
lngLat: [number, number]
/**
* If set: indicates that this point is identical to an earlier point in the way and that that point should be used.
* This is especially needed in closed ways, where the last CoordinateInfo will have '0' as identicalTo
*/
identicalTo?: number,
identicalTo?: number
/**
* Information about the closebyNode which might be reused
*/
@ -46,8 +45,8 @@ interface CoordinateInfo {
/**
* Distance in meters between the target coordinate and this candidate coordinate
*/
d: number,
node: any,
d: number
node: any
config: MergePointConfig
}[]
}
@ -56,54 +55,55 @@ interface CoordinateInfo {
* More or less the same as 'CreateNewWay', except that it'll try to reuse already existing points
*/
export default class CreateWayWithPointReuseAction extends OsmCreateAction {
public newElementId: string = undefined;
public newElementId: string = undefined
public newElementIdNumber: number = undefined
private readonly _tags: Tag[];
private readonly _tags: Tag[]
/**
* lngLat-coordinates
* @private
*/
private _coordinateInfo: CoordinateInfo[];
private _state: FeaturePipelineState;
private _config: MergePointConfig[];
private _coordinateInfo: CoordinateInfo[]
private _state: FeaturePipelineState
private _config: MergePointConfig[]
constructor(tags: Tag[],
coordinates: [number, number][],
state: FeaturePipelineState,
config: MergePointConfig[]
constructor(
tags: Tag[],
coordinates: [number, number][],
state: FeaturePipelineState,
config: MergePointConfig[]
) {
super(null, true);
this._tags = tags;
this._state = state;
this._config = config;
super(null, true)
this._tags = tags
this._state = state
this._config = config
// The main logic of this class: the coordinateInfo contains all the changes
this._coordinateInfo = this.CalculateClosebyNodes(coordinates);
this._coordinateInfo = this.CalculateClosebyNodes(coordinates)
}
public async getPreview(): Promise<FeatureSource> {
const features = []
let geometryMoved = false;
let geometryMoved = false
for (let i = 0; i < this._coordinateInfo.length; i++) {
const coordinateInfo = this._coordinateInfo[i];
const coordinateInfo = this._coordinateInfo[i]
if (coordinateInfo.identicalTo !== undefined) {
continue
}
if (coordinateInfo.closebyNodes === undefined || coordinateInfo.closebyNodes.length === 0) {
if (
coordinateInfo.closebyNodes === undefined ||
coordinateInfo.closebyNodes.length === 0
) {
const newPoint = {
type: "Feature",
properties: {
"newpoint": "yes",
id: "new-geometry-with-reuse-" + i
newpoint: "yes",
id: "new-geometry-with-reuse-" + i,
},
geometry: {
type: "Point",
coordinates: coordinateInfo.lngLat
}
};
coordinates: coordinateInfo.lngLat,
},
}
features.push(newPoint)
continue
}
@ -113,18 +113,20 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction {
const moveDescription = {
type: "Feature",
properties: {
"move": "yes",
move: "yes",
"osm-id": reusedPoint.node.properties.id,
"id": "new-geometry-move-existing" + i,
"distance": GeoOperations.distanceBetween(coordinateInfo.lngLat, reusedPoint.node.geometry.coordinates)
id: "new-geometry-move-existing" + i,
distance: GeoOperations.distanceBetween(
coordinateInfo.lngLat,
reusedPoint.node.geometry.coordinates
),
},
geometry: {
type: "LineString",
coordinates: [reusedPoint.node.geometry.coordinates, coordinateInfo.lngLat]
}
coordinates: [reusedPoint.node.geometry.coordinates, coordinateInfo.lngLat],
},
}
features.push(moveDescription)
} else {
// The geometry is moved, the point is reused
geometryMoved = true
@ -132,22 +134,24 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction {
const reuseDescription = {
type: "Feature",
properties: {
"move": "no",
move: "no",
"osm-id": reusedPoint.node.properties.id,
"id": "new-geometry-reuse-existing" + i,
"distance": GeoOperations.distanceBetween(coordinateInfo.lngLat, reusedPoint.node.geometry.coordinates)
id: "new-geometry-reuse-existing" + i,
distance: GeoOperations.distanceBetween(
coordinateInfo.lngLat,
reusedPoint.node.geometry.coordinates
),
},
geometry: {
type: "LineString",
coordinates: [coordinateInfo.lngLat, reusedPoint.node.geometry.coordinates]
}
coordinates: [coordinateInfo.lngLat, reusedPoint.node.geometry.coordinates],
},
}
features.push(reuseDescription)
}
}
if (geometryMoved) {
const coords: [number, number][] = []
for (const info of this._coordinateInfo) {
if (info.identicalTo !== undefined) {
@ -166,21 +170,19 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction {
} else {
coords.push(info.lngLat)
}
}
const newGeometry = {
type: "Feature",
properties: {
"resulting-geometry": "yes",
"id": "new-geometry"
id: "new-geometry",
},
geometry: {
type: "LineString",
coordinates: coords
}
coordinates: coords,
},
}
features.push(newGeometry)
}
return StaticFeatureSource.fromGeojson(features)
}
@ -188,7 +190,7 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction {
public async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const theme = this._state?.layoutToUse?.id
const allChanges: ChangeDescription[] = []
const nodeIdsToUse: { lat: number, lon: number, nodeId?: number }[] = []
const nodeIdsToUse: { lat: number; lon: number; nodeId?: number }[] = []
for (let i = 0; i < this._coordinateInfo.length; i++) {
const info = this._coordinateInfo[i]
const lat = info.lngLat[1]
@ -202,17 +204,17 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction {
const newNodeAction = new CreateNewNodeAction([], lat, lon, {
allowReuseOfPreviouslyCreatedPoints: true,
changeType: null,
theme
theme,
})
allChanges.push(...(await newNodeAction.CreateChangeDescriptions(changes)))
nodeIdsToUse.push({
lat, lon,
nodeId: newNodeAction.newElementIdNumber
lat,
lon,
nodeId: newNodeAction.newElementIdNumber,
})
continue
}
const closestPoint = info.closebyNodes[0]
@ -222,20 +224,20 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction {
type: "node",
id,
changes: {
lat, lon
lat,
lon,
},
meta: {
theme,
changeType: null
}
changeType: null,
},
})
}
nodeIdsToUse.push({lat, lon, nodeId: id})
nodeIdsToUse.push({ lat, lon, nodeId: id })
}
const newWay = new CreateNewWayAction(this._tags, nodeIdsToUse, {
theme
theme,
})
allChanges.push(...(await newWay.CreateChangeDescriptions(changes)))
@ -248,27 +250,26 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction {
* Calculates the main changes.
*/
private CalculateClosebyNodes(coordinates: [number, number][]): CoordinateInfo[] {
const bbox = new BBox(coordinates)
const state = this._state
const allNodes = [].concat(...state?.featurePipeline?.GetFeaturesWithin("type_node", bbox.pad(1.2))??[])
const maxDistance = Math.max(...this._config.map(c => c.withinRangeOfM))
const allNodes = [].concat(
...(state?.featurePipeline?.GetFeaturesWithin("type_node", bbox.pad(1.2)) ?? [])
)
const maxDistance = Math.max(...this._config.map((c) => c.withinRangeOfM))
// Init coordianteinfo with undefined but the same length as coordinates
const coordinateInfo: {
lngLat: [number, number],
identicalTo?: number,
lngLat: [number, number]
identicalTo?: number
closebyNodes?: {
d: number,
node: any,
d: number
node: any
config: MergePointConfig
}[]
}[] = coordinates.map(_ => undefined)
}[] = coordinates.map((_) => undefined)
// First loop: gather all information...
for (let i = 0; i < coordinates.length; i++) {
if (coordinateInfo[i] !== undefined) {
// Already seen, probably a duplicate coordinate
continue
@ -282,9 +283,9 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction {
if (GeoOperations.distanceBetween(coor, coordinates[j]) < 0.1) {
coordinateInfo[j] = {
lngLat: coor,
identicalTo: i
identicalTo: i,
}
break;
break
}
}
@ -292,8 +293,8 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction {
// Lets search applicable points and determine the merge mode
const closebyNodes: {
d: number,
node: any,
d: number
node: any
config: MergePointConfig
}[] = []
for (const node of allNodes) {
@ -310,7 +311,7 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction {
if (!config.ifMatches.matchesProperties(node.properties)) {
continue
}
closebyNodes.push({node, d, config})
closebyNodes.push({ node, d, config })
}
}
@ -322,18 +323,15 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction {
coordinateInfo[i] = {
identicalTo: undefined,
lngLat: coor,
closebyNodes
closebyNodes,
}
}
// Second loop: figure out which point moves where without creating conflicts
let conflictFree = true;
let conflictFree = true
do {
conflictFree = true;
conflictFree = true
for (let i = 0; i < coordinateInfo.length; i++) {
const coorInfo = coordinateInfo[i]
if (coorInfo.identicalTo !== undefined) {
continue
@ -366,8 +364,6 @@ export default class CreateWayWithPointReuseAction extends OsmCreateAction {
}
} while (!conflictFree)
return coordinateInfo
}
}
}

View file

@ -1,61 +1,61 @@
import {OsmObject} from "../OsmObject";
import OsmChangeAction from "./OsmChangeAction";
import {Changes} from "../Changes";
import {ChangeDescription} from "./ChangeDescription";
import ChangeTagAction from "./ChangeTagAction";
import {TagsFilter} from "../../Tags/TagsFilter";
import {And} from "../../Tags/And";
import {Tag} from "../../Tags/Tag";
import { OsmObject } from "../OsmObject"
import OsmChangeAction from "./OsmChangeAction"
import { Changes } from "../Changes"
import { ChangeDescription } from "./ChangeDescription"
import ChangeTagAction from "./ChangeTagAction"
import { TagsFilter } from "../../Tags/TagsFilter"
import { And } from "../../Tags/And"
import { Tag } from "../../Tags/Tag"
export default class DeleteAction extends OsmChangeAction {
private readonly _softDeletionTags: TagsFilter;
private readonly _softDeletionTags: TagsFilter
private readonly meta: {
theme: string,
specialMotivation: string,
theme: string
specialMotivation: string
changeType: "deletion"
};
private readonly _id: string;
private _hardDelete: boolean;
}
private readonly _id: string
private _hardDelete: boolean
constructor(id: string,
softDeletionTags: TagsFilter,
meta: {
theme: string,
specialMotivation: string
},
hardDelete: boolean) {
constructor(
id: string,
softDeletionTags: TagsFilter,
meta: {
theme: string
specialMotivation: string
},
hardDelete: boolean
) {
super(id, true)
this._id = id;
this._hardDelete = hardDelete;
this.meta = {...meta, changeType: "deletion"};
this._softDeletionTags = new And([softDeletionTags,
new Tag("fixme", `A mapcomplete user marked this feature to be deleted (${meta.specialMotivation})`)
]);
this._id = id
this._hardDelete = hardDelete
this.meta = { ...meta, changeType: "deletion" }
this._softDeletionTags = new And([
softDeletionTags,
new Tag(
"fixme",
`A mapcomplete user marked this feature to be deleted (${meta.specialMotivation})`
),
])
}
public async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const osmObject = await OsmObject.DownloadObjectAsync(this._id)
if (this._hardDelete) {
return [{
meta: this.meta,
doDelete: true,
type: osmObject.type,
id: osmObject.id,
}]
} else {
return await new ChangeTagAction(
this._id, this._softDeletionTags, osmObject.tags,
return [
{
...this.meta,
changeType: "soft-delete"
}
).CreateChangeDescriptions(changes)
meta: this.meta,
doDelete: true,
type: osmObject.type,
id: osmObject.id,
},
]
} else {
return await new ChangeTagAction(this._id, this._softDeletionTags, osmObject.tags, {
...this.meta,
changeType: "soft-delete",
}).CreateChangeDescriptions(changes)
}
}
}
}

View file

@ -2,22 +2,21 @@
* An action is a change to the OSM-database
* It will generate some new/modified/deleted objects, which are all bundled by the 'changes'-object
*/
import {Changes} from "../Changes";
import {ChangeDescription} from "./ChangeDescription";
import { Changes } from "../Changes"
import { ChangeDescription } from "./ChangeDescription"
export default abstract class OsmChangeAction {
public readonly trackStatistics: boolean;
public readonly trackStatistics: boolean
/**
* The ID of the object that is the center of this change.
* Null if the action creates a new object (at initialization)
* Undefined if such an id does not make sense
*/
public readonly mainObjectId: string;
public readonly mainObjectId: string
private isUsed = false
constructor(mainObjectId: string, trackStatistics: boolean = true) {
this.trackStatistics = trackStatistics;
this.trackStatistics = trackStatistics
this.mainObjectId = mainObjectId
}
@ -25,7 +24,7 @@ export default abstract class OsmChangeAction {
if (this.isUsed) {
throw "This ChangeAction is already used"
}
this.isUsed = true;
this.isUsed = true
return this.CreateChangeDescriptions(changes)
}
@ -33,8 +32,6 @@ export default abstract class OsmChangeAction {
}
export abstract class OsmCreateAction extends OsmChangeAction {
public newElementId: string
public newElementIdNumber: number
}

View file

@ -1,24 +1,24 @@
import OsmChangeAction from "./OsmChangeAction";
import {Changes} from "../Changes";
import {ChangeDescription} from "./ChangeDescription";
import {OsmObject, OsmRelation, OsmWay} from "../OsmObject";
import OsmChangeAction from "./OsmChangeAction"
import { Changes } from "../Changes"
import { ChangeDescription } from "./ChangeDescription"
import { OsmObject, OsmRelation, OsmWay } from "../OsmObject"
export interface RelationSplitInput {
relation: OsmRelation,
originalWayId: number,
allWayIdsInOrder: number[],
originalNodes: number[],
relation: OsmRelation
originalWayId: number
allWayIdsInOrder: number[]
originalNodes: number[]
allWaysNodesInOrder: number[][]
}
abstract class AbstractRelationSplitHandler extends OsmChangeAction {
protected readonly _input: RelationSplitInput;
protected readonly _theme: string;
protected readonly _input: RelationSplitInput
protected readonly _theme: string
constructor(input: RelationSplitInput, theme: string) {
super("relation/" + input.relation.id, false)
this._input = input;
this._theme = theme;
this._input = input
this._theme = theme
}
/**
@ -44,7 +44,7 @@ abstract class AbstractRelationSplitHandler extends OsmChangeAction {
if (member.type === "relation") {
return undefined
}
return undefined;
return undefined
}
}
@ -52,7 +52,6 @@ abstract class AbstractRelationSplitHandler extends OsmChangeAction {
* When a way is split and this way is part of a relation, the relation should be updated too to have the new segment if relevant.
*/
export default class RelationSplitHandler extends AbstractRelationSplitHandler {
constructor(input: RelationSplitInput, theme: string) {
super(input, theme)
}
@ -60,38 +59,43 @@ export default class RelationSplitHandler extends AbstractRelationSplitHandler {
async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
if (this._input.relation.tags["type"] === "restriction") {
// This is a turn restriction
return new TurnRestrictionRSH(this._input, this._theme).CreateChangeDescriptions(changes)
return new TurnRestrictionRSH(this._input, this._theme).CreateChangeDescriptions(
changes
)
}
return new InPlaceReplacedmentRTSH(this._input, this._theme).CreateChangeDescriptions(changes)
return new InPlaceReplacedmentRTSH(this._input, this._theme).CreateChangeDescriptions(
changes
)
}
}
export class TurnRestrictionRSH extends AbstractRelationSplitHandler {
constructor(input: RelationSplitInput, theme: string) {
super(input, theme);
super(input, theme)
}
public async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const relation = this._input.relation
const members = relation.members
const selfMembers = members.filter(m => m.type === "way" && m.ref === this._input.originalWayId)
const selfMembers = members.filter(
(m) => m.type === "way" && m.ref === this._input.originalWayId
)
if (selfMembers.length > 1) {
console.warn("Detected a turn restriction where this way has multiple occurances. This is an error")
console.warn(
"Detected a turn restriction where this way has multiple occurances. This is an error"
)
}
const selfMember = selfMembers[0]
if (selfMember.role === "via") {
// A via way can be replaced in place
return new InPlaceReplacedmentRTSH(this._input, this._theme).CreateChangeDescriptions(changes);
return new InPlaceReplacedmentRTSH(this._input, this._theme).CreateChangeDescriptions(
changes
)
}
// We have to keep only the way with a common point with the rest of the relation
// Let's figure out which member is neighbouring our way
@ -102,11 +106,12 @@ export class TurnRestrictionRSH extends AbstractRelationSplitHandler {
let commonPoint = commonStartPoint ?? commonEndPoint
// Let's select the way to keep
const idToKeep: { id: number } = this._input.allWaysNodesInOrder.map((nodes, i) => ({
nodes: nodes,
id: this._input.allWayIdsInOrder[i]
}))
.filter(nodesId => {
const idToKeep: { id: number } = this._input.allWaysNodesInOrder
.map((nodes, i) => ({
nodes: nodes,
id: this._input.allWayIdsInOrder[i],
}))
.filter((nodesId) => {
const nds = nodesId.nodes
return nds[0] == commonPoint || nds[nds.length - 1] == commonPoint
})[0]
@ -123,36 +128,34 @@ export class TurnRestrictionRSH extends AbstractRelationSplitHandler {
}
const newMembers: {
ref: number,
type: "way" | "node" | "relation",
ref: number
type: "way" | "node" | "relation"
role: string
} [] = relation.members.map(m => {
}[] = relation.members.map((m) => {
if (m.type === "way" && m.ref === originalWayId) {
return {
ref: idToKeep.id,
type: "way",
role: m.role
role: m.role,
}
}
return m
})
return [
{
type: "relation",
id: relation.id,
changes: {
members: newMembers
members: newMembers,
},
meta: {
theme: this._theme,
changeType: "relation-fix:turn_restriction"
}
}
];
changeType: "relation-fix:turn_restriction",
},
},
]
}
}
/**
@ -163,26 +166,24 @@ export class TurnRestrictionRSH extends AbstractRelationSplitHandler {
* Note that the feature might appear multiple times.
*/
export class InPlaceReplacedmentRTSH extends AbstractRelationSplitHandler {
constructor(input: RelationSplitInput, theme: string) {
super(input, theme);
super(input, theme)
}
async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const wayId = this._input.originalWayId
const relation = this._input.relation
const members = relation.members
const originalNodes = this._input.originalNodes;
const originalNodes = this._input.originalNodes
const firstNode = originalNodes[0]
const lastNode = originalNodes[originalNodes.length - 1]
const newMembers: { type: "node" | "way" | "relation", ref: number, role: string }[] = []
const newMembers: { type: "node" | "way" | "relation"; ref: number; role: string }[] = []
for (let i = 0; i < members.length; i++) {
const member = members[i];
const member = members[i]
if (member.type !== "way" || member.ref !== wayId) {
newMembers.push(member)
continue;
continue
}
const nodeIdBefore = await this.targetNodeAt(i - 1, false)
@ -197,10 +198,10 @@ export class InPlaceReplacedmentRTSH extends AbstractRelationSplitHandler {
newMembers.push({
ref: wId,
type: "way",
role: member.role
role: member.role,
})
}
continue;
continue
}
const firstNodeMatchesRev = nodeIdBefore === undefined || nodeIdBefore === lastNode
@ -209,14 +210,14 @@ export class InPlaceReplacedmentRTSH extends AbstractRelationSplitHandler {
// We (probably) have a reversed situation, backward situation
for (let i1 = this._input.allWayIdsInOrder.length - 1; i1 >= 0; i1--) {
// Iterate BACKWARDS
const wId = this._input.allWayIdsInOrder[i1];
const wId = this._input.allWayIdsInOrder[i1]
newMembers.push({
ref: wId,
type: "way",
role: member.role
role: member.role,
})
}
continue;
continue
}
// Euhm, allright... Something weird is going on, but let's not care too much
@ -225,21 +226,21 @@ export class InPlaceReplacedmentRTSH extends AbstractRelationSplitHandler {
newMembers.push({
ref: wId,
type: "way",
role: member.role
role: member.role,
})
}
}
return [{
id: relation.id,
type: "relation",
changes: {members: newMembers},
meta: {
changeType: "relation-fix",
theme: this._theme
}
}];
return [
{
id: relation.id,
type: "relation",
changes: { members: newMembers },
meta: {
changeType: "relation-fix",
theme: this._theme,
},
},
]
}
}
}

View file

@ -1,59 +1,59 @@
import OsmChangeAction from "./OsmChangeAction";
import {Changes} from "../Changes";
import {ChangeDescription} from "./ChangeDescription";
import {Tag} from "../../Tags/Tag";
import FeatureSource from "../../FeatureSource/FeatureSource";
import {OsmNode, OsmObject, OsmWay} from "../OsmObject";
import {GeoOperations} from "../../GeoOperations";
import StaticFeatureSource from "../../FeatureSource/Sources/StaticFeatureSource";
import CreateNewNodeAction from "./CreateNewNodeAction";
import ChangeTagAction from "./ChangeTagAction";
import {And} from "../../Tags/And";
import {Utils} from "../../../Utils";
import {OsmConnection} from "../OsmConnection";
import {Feature} from "@turf/turf";
import FeaturePipeline from "../../FeatureSource/FeaturePipeline";
import OsmChangeAction from "./OsmChangeAction"
import { Changes } from "../Changes"
import { ChangeDescription } from "./ChangeDescription"
import { Tag } from "../../Tags/Tag"
import FeatureSource from "../../FeatureSource/FeatureSource"
import { OsmNode, OsmObject, OsmWay } from "../OsmObject"
import { GeoOperations } from "../../GeoOperations"
import StaticFeatureSource from "../../FeatureSource/Sources/StaticFeatureSource"
import CreateNewNodeAction from "./CreateNewNodeAction"
import ChangeTagAction from "./ChangeTagAction"
import { And } from "../../Tags/And"
import { Utils } from "../../../Utils"
import { OsmConnection } from "../OsmConnection"
import { Feature } from "@turf/turf"
import FeaturePipeline from "../../FeatureSource/FeaturePipeline"
export default class ReplaceGeometryAction extends OsmChangeAction {
/**
* The target feature - mostly used for the metadata
*/
private readonly feature: any;
private readonly feature: any
private readonly state: {
osmConnection: OsmConnection,
osmConnection: OsmConnection
featurePipeline: FeaturePipeline
};
private readonly wayToReplaceId: string;
private readonly theme: string;
}
private readonly wayToReplaceId: string
private readonly theme: string
/**
* The target coordinates that should end up in OpenStreetMap.
* This is identical to either this.feature.geometry.coordinates or -in case of a polygon- feature.geometry.coordinates[0]
* Format: [lon, lat]
*/
private readonly targetCoordinates: [number, number][];
private readonly targetCoordinates: [number, number][]
/**
* If a target coordinate is close to another target coordinate, 'identicalTo' will point to the first index.
*/
private readonly identicalTo: number[]
private readonly newTags: Tag[] | undefined;
private readonly newTags: Tag[] | undefined
constructor(
state: {
osmConnection: OsmConnection,
osmConnection: OsmConnection
featurePipeline: FeaturePipeline
},
feature: any,
wayToReplaceId: string,
options: {
theme: string,
theme: string
newTags?: Tag[]
}
) {
super(wayToReplaceId, false);
this.state = state;
this.feature = feature;
this.wayToReplaceId = wayToReplaceId;
this.theme = options.theme;
super(wayToReplaceId, false)
this.state = state
this.feature = feature
this.wayToReplaceId = wayToReplaceId
this.theme = options.theme
const geom = this.feature.geometry
let coordinates: [number, number][]
@ -64,7 +64,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
}
this.targetCoordinates = coordinates
this.identicalTo = coordinates.map(_ => undefined)
this.identicalTo = coordinates.map((_) => undefined)
for (let i = 0; i < coordinates.length; i++) {
if (this.identicalTo[i] !== undefined) {
@ -82,7 +82,8 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
// noinspection JSUnusedGlobalSymbols
public async getPreview(): Promise<FeatureSource> {
const {closestIds, allNodesById, detachedNodes, reprojectedNodes} = await this.GetClosestIds();
const { closestIds, allNodesById, detachedNodes, reprojectedNodes } =
await this.GetClosestIds()
const preview: Feature[] = closestIds.map((newId, i) => {
if (this.identicalTo[i] !== undefined) {
return undefined
@ -92,75 +93,73 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
return {
type: "Feature",
properties: {
"newpoint": "yes",
"id": "replace-geometry-move-" + i,
newpoint: "yes",
id: "replace-geometry-move-" + i,
},
geometry: {
type: "Point",
coordinates: this.targetCoordinates[i]
}
};
coordinates: this.targetCoordinates[i],
},
}
}
const origNode = allNodesById.get(newId);
const origNode = allNodesById.get(newId)
return {
type: "Feature",
properties: {
"move": "yes",
move: "yes",
"osm-id": newId,
"id": "replace-geometry-move-" + i,
"original-node-tags": JSON.stringify(origNode.tags)
id: "replace-geometry-move-" + i,
"original-node-tags": JSON.stringify(origNode.tags),
},
geometry: {
type: "LineString",
coordinates: [[origNode.lon, origNode.lat], this.targetCoordinates[i]]
}
};
coordinates: [[origNode.lon, origNode.lat], this.targetCoordinates[i]],
},
}
})
reprojectedNodes.forEach(({newLat, newLon, nodeId}) => {
const origNode = allNodesById.get(nodeId);
const feature : Feature = {
reprojectedNodes.forEach(({ newLat, newLon, nodeId }) => {
const origNode = allNodesById.get(nodeId)
const feature: Feature = {
type: "Feature",
properties: {
"move": "yes",
"reprojection": "yes",
move: "yes",
reprojection: "yes",
"osm-id": nodeId,
"id": "replace-geometry-reproject-" + nodeId,
"original-node-tags": JSON.stringify(origNode.tags)
id: "replace-geometry-reproject-" + nodeId,
"original-node-tags": JSON.stringify(origNode.tags),
},
geometry: {
type: "LineString",
coordinates: [[origNode.lon, origNode.lat], [newLon, newLat]]
}
};
coordinates: [
[origNode.lon, origNode.lat],
[newLon, newLat],
],
},
}
preview.push(feature)
})
detachedNodes.forEach(({reason}, id) => {
const origNode = allNodesById.get(id);
const feature : Feature = {
detachedNodes.forEach(({ reason }, id) => {
const origNode = allNodesById.get(id)
const feature: Feature = {
type: "Feature",
properties: {
"detach": "yes",
"id": "replace-geometry-detach-" + id,
detach: "yes",
id: "replace-geometry-detach-" + id,
"detach-reason": reason,
"original-node-tags": JSON.stringify(origNode.tags)
"original-node-tags": JSON.stringify(origNode.tags),
},
geometry: {
type: "Point",
coordinates: [origNode.lon, origNode.lat]
}
};
coordinates: [origNode.lon, origNode.lat],
},
}
preview.push(feature)
})
return StaticFeatureSource.fromGeojson(Utils.NoNull(preview))
}
/**
@ -170,45 +169,52 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
*
*/
public async GetClosestIds(): Promise<{
// A list of the same length as targetCoordinates, containing which OSM-point to move. If undefined, a new point will be created
closestIds: number[],
allNodesById: Map<number, OsmNode>,
osmWay: OsmWay,
detachedNodes: Map<number, {
reason: string,
hasTags: boolean
}>,
reprojectedNodes: Map<number, {
/*Move the node with this ID into the way as extra node, as it has some relation with the original object*/
projectAfterIndex: number,
distance: number,
newLat: number,
newLon: number,
nodeId: number
}>
closestIds: number[]
allNodesById: Map<number, OsmNode>
osmWay: OsmWay
detachedNodes: Map<
number,
{
reason: string
hasTags: boolean
}
>
reprojectedNodes: Map<
number,
{
/*Move the node with this ID into the way as extra node, as it has some relation with the original object*/
projectAfterIndex: number
distance: number
newLat: number
newLon: number
nodeId: number
}
>
}> {
// TODO FIXME: if a new point has to be created, snap to already existing ways
const nodeDb = this.state.featurePipeline.fullNodeDatabase;
const nodeDb = this.state.featurePipeline.fullNodeDatabase
if (nodeDb === undefined) {
throw "PANIC: replaceGeometryAction needs the FullNodeDatabase, which is undefined. This should be initialized by having the 'type_node'-layer enabled in your theme. (NB: the replacebutton has type_node as dependency)"
}
const self = this;
let parsed: OsmObject[];
const self = this
let parsed: OsmObject[]
{
// Gather the needed OsmObjects
const splitted = this.wayToReplaceId.split("/");
const type = splitted[0];
const idN = Number(splitted[1]);
const splitted = this.wayToReplaceId.split("/")
const type = splitted[0]
const idN = Number(splitted[1])
if (idN < 0 || type !== "way") {
throw "Invalid ID to conflate: " + this.wayToReplaceId
}
const url = `${this.state.osmConnection?._oauth_config?.url ?? "https://openstreetmap.org"}/api/0.6/${this.wayToReplaceId}/full`;
const url = `${
this.state.osmConnection?._oauth_config?.url ?? "https://openstreetmap.org"
}/api/0.6/${this.wayToReplaceId}/full`
const rawData = await Utils.downloadJsonCached(url, 1000)
parsed = OsmObject.ParseObjects(rawData.elements);
parsed = OsmObject.ParseObjects(rawData.elements)
}
const allNodes = parsed.filter(o => o.type === "node")
const allNodes = parsed.filter((o) => o.type === "node")
const osmWay = <OsmWay>parsed[parsed.length - 1]
if (osmWay.type !== "way") {
throw "WEIRD: expected an OSM-way as last element here!"
@ -228,38 +234,42 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
*
* The Replace-geometry action should try its best to honour these. Some 'wiggling' is allowed (e.g. moving an entrance a bit), but these relations should not be broken.l
*/
const distances = new Map<number /* osmId*/,
const distances = new Map<
number /* osmId*/,
/** target coordinate index --> distance (or undefined if a duplicate)*/
number[]>();
number[]
>()
const nodeInfo = new Map<number /* osmId*/, {
distances: number[],
// Part of some other way then the one that should be replaced
partOfWay: boolean,
hasTags: boolean
}>()
const nodeInfo = new Map<
number /* osmId*/,
{
distances: number[]
// Part of some other way then the one that should be replaced
partOfWay: boolean
hasTags: boolean
}
>()
for (const node of allNodes) {
const parentWays = nodeDb.GetParentWays(node.id)
if (parentWays === undefined) {
throw "PANIC: the way to replace has a node which has no parents at all. Is it deleted in the meantime?"
}
const parentWayIds = parentWays.data.map(w => w.type + "/" + w.id)
const parentWayIds = parentWays.data.map((w) => w.type + "/" + w.id)
const idIndex = parentWayIds.indexOf(this.wayToReplaceId)
if (idIndex < 0) {
throw "PANIC: the way to replace has a node, which is _not_ part of this was according to the node..."
}
parentWayIds.splice(idIndex, 1)
const partOfSomeWay = parentWayIds.length > 0
const hasTags = Object.keys(node.tags).length > 1;
const hasTags = Object.keys(node.tags).length > 1
const nodeDistances = this.targetCoordinates.map(_ => undefined)
const nodeDistances = this.targetCoordinates.map((_) => undefined)
for (let i = 0; i < this.targetCoordinates.length; i++) {
if (this.identicalTo[i] !== undefined) {
continue;
continue
}
const targetCoordinate = this.targetCoordinates[i];
const targetCoordinate = this.targetCoordinates[i]
const cp = node.centerpoint()
const d = GeoOperations.distanceBetween(targetCoordinate, [cp[1], cp[0]])
if (d > 25) {
@ -268,37 +278,39 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
}
if (d < 3 || !(hasTags || partOfSomeWay)) {
// If there is some relation: cap the move distance to 3m
nodeDistances[i] = d;
nodeDistances[i] = d
}
}
distances.set(node.id, nodeDistances)
nodeInfo.set(node.id, {
distances: nodeDistances,
partOfWay: partOfSomeWay,
hasTags
hasTags,
})
}
const closestIds = this.targetCoordinates.map(_ => undefined)
const unusedIds = new Map<number, {
reason: string,
hasTags: boolean
}>();
const closestIds = this.targetCoordinates.map((_) => undefined)
const unusedIds = new Map<
number,
{
reason: string
hasTags: boolean
}
>()
{
// Search best merge candidate
/**
* Then, we search the node that has to move the least distance and add this as mapping.
* We do this until no points are left
*/
let candidate: number;
let moveDistance: number;
let candidate: number
let moveDistance: number
/**
* The list of nodes that are _not_ used anymore, typically if there are less targetCoordinates then source coordinates
*/
do {
candidate = undefined;
moveDistance = Infinity;
candidate = undefined
moveDistance = Infinity
distances.forEach((distances, nodeId) => {
const minDist = Math.min(...Utils.NoNull(distances))
if (moveDistance > minDist) {
@ -310,14 +322,14 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
if (candidate !== undefined) {
// We found a candidate... Search the corresponding target id:
let targetId: number = undefined;
let targetId: number = undefined
let lowestDistance = Number.MAX_VALUE
let nodeDistances = distances.get(candidate)
for (let i = 0; i < nodeDistances.length; i++) {
const d = nodeDistances[i]
if (d !== undefined && d < lowestDistance) {
lowestDistance = d;
targetId = i;
lowestDistance = d
targetId = i
}
}
@ -330,14 +342,14 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
closestIds[targetId] = candidate
// To indicate that this targetCoordinate is taken, we remove them from the distances matrix
distances.forEach(dists => {
distances.forEach((dists) => {
dists[targetId] = undefined
})
} else {
// Seems like all the targetCoordinates have found a source point
unusedIds.set(candidate, {
reason: "Unused by new way",
hasTags: nodeInfo.get(candidate).hasTags
hasTags: nodeInfo.get(candidate).hasTags,
})
}
}
@ -348,18 +360,21 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
distances.forEach((_, nodeId) => {
unusedIds.set(nodeId, {
reason: "Unused by new way",
hasTags: nodeInfo.get(nodeId).hasTags
hasTags: nodeInfo.get(nodeId).hasTags,
})
})
const reprojectedNodes = new Map<number, {
/*Move the node with this ID into the way as extra node, as it has some relation with the original object*/
projectAfterIndex: number,
distance: number,
newLat: number,
newLon: number,
nodeId: number
}>();
const reprojectedNodes = new Map<
number,
{
/*Move the node with this ID into the way as extra node, as it has some relation with the original object*/
projectAfterIndex: number
distance: number
newLat: number
newLon: number
nodeId: number
}
>()
{
// Lets check the unused ids: can they be detached or do they signify some relation with the object?
unusedIds.forEach(({}, id) => {
@ -379,36 +394,32 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
properties: {},
geometry: {
type: "LineString",
coordinates: self.targetCoordinates
}
};
const projected = GeoOperations.nearestPoint(
way, [node.lon, node.lat]
)
coordinates: self.targetCoordinates,
},
}
const projected = GeoOperations.nearestPoint(way, [node.lon, node.lat])
reprojectedNodes.set(id, {
newLon: projected.geometry.coordinates[0],
newLat: projected.geometry.coordinates[1],
projectAfterIndex: projected.properties.index,
distance: projected.properties.dist,
nodeId: id
nodeId: id,
})
})
reprojectedNodes.forEach((_, nodeId) => unusedIds.delete(nodeId))
}
return {closestIds, allNodesById, osmWay, detachedNodes: unusedIds, reprojectedNodes};
return { closestIds, allNodesById, osmWay, detachedNodes: unusedIds, reprojectedNodes }
}
protected async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const nodeDb = this.state.featurePipeline.fullNodeDatabase;
const nodeDb = this.state.featurePipeline.fullNodeDatabase
if (nodeDb === undefined) {
throw "PANIC: replaceGeometryAction needs the FullNodeDatabase, which is undefined. This should be initialized by having the 'type_node'-layer enabled in your theme. (NB: the replacebutton has type_node as dependency)"
}
const {closestIds, osmWay, detachedNodes, reprojectedNodes} = await this.GetClosestIds()
const { closestIds, osmWay, detachedNodes, reprojectedNodes } = await this.GetClosestIds()
const allChanges: ChangeDescription[] = []
const actualIdsToUse: number[] = []
for (let i = 0; i < closestIds.length; i++) {
@ -417,47 +428,43 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
actualIdsToUse.push(actualIdsToUse[j])
continue
}
const closestId = closestIds[i];
const closestId = closestIds[i]
const [lon, lat] = this.targetCoordinates[i]
if (closestId === undefined) {
const newNodeAction = new CreateNewNodeAction(
[],
lat, lon,
{
allowReuseOfPreviouslyCreatedPoints: true,
theme: this.theme, changeType: null
})
const newNodeAction = new CreateNewNodeAction([], lat, lon, {
allowReuseOfPreviouslyCreatedPoints: true,
theme: this.theme,
changeType: null,
})
const changeDescr = await newNodeAction.CreateChangeDescriptions(changes)
allChanges.push(...changeDescr)
actualIdsToUse.push(newNodeAction.newElementIdNumber)
} else {
const change = <ChangeDescription>{
id: closestId,
type: "node",
meta: {
theme: this.theme,
changeType: "move"
changeType: "move",
},
changes: {lon, lat}
changes: { lon, lat },
}
actualIdsToUse.push(closestId)
allChanges.push(change)
}
}
if (this.newTags !== undefined && this.newTags.length > 0) {
const addExtraTags = new ChangeTagAction(
this.wayToReplaceId,
new And(this.newTags),
osmWay.tags, {
osmWay.tags,
{
theme: this.theme,
changeType: "conflation"
changeType: "conflation",
}
)
allChanges.push(...await addExtraTags.CreateChangeDescriptions(changes))
allChanges.push(...(await addExtraTags.CreateChangeDescriptions(changes)))
}
const newCoordinates = [...this.targetCoordinates]
@ -468,13 +475,11 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
const proj = Array.from(reprojectedNodes.values())
proj.sort((a, b) => {
// Sort descending
const diff = b.projectAfterIndex - a.projectAfterIndex;
const diff = b.projectAfterIndex - a.projectAfterIndex
if (diff !== 0) {
return diff
}
return b.distance - a.distance;
return b.distance - a.distance
})
for (const reprojectedNode of proj) {
@ -483,13 +488,20 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
type: "node",
meta: {
theme: this.theme,
changeType: "move"
changeType: "move",
},
changes: {lon: reprojectedNode.newLon, lat: reprojectedNode.newLat}
changes: { lon: reprojectedNode.newLon, lat: reprojectedNode.newLat },
}
allChanges.push(change)
actualIdsToUse.splice(reprojectedNode.projectAfterIndex + 1, 0, reprojectedNode.nodeId)
newCoordinates.splice(reprojectedNode.projectAfterIndex + 1, 0, [reprojectedNode.newLon, reprojectedNode.newLat])
actualIdsToUse.splice(
reprojectedNode.projectAfterIndex + 1,
0,
reprojectedNode.nodeId
)
newCoordinates.splice(reprojectedNode.projectAfterIndex + 1, 0, [
reprojectedNode.newLon,
reprojectedNode.newLat,
])
}
}
@ -499,42 +511,46 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
id: osmWay.id,
changes: {
nodes: actualIdsToUse,
coordinates: newCoordinates
coordinates: newCoordinates,
},
meta: {
theme: this.theme,
changeType: "conflation"
}
changeType: "conflation",
},
})
// Some nodes might need to be deleted
if (detachedNodes.size > 0) {
detachedNodes.forEach(({hasTags, reason}, nodeId) => {
detachedNodes.forEach(({ hasTags, reason }, nodeId) => {
const parentWays = nodeDb.GetParentWays(nodeId)
const index = parentWays.data.map(w => w.id).indexOf(osmWay.id)
const index = parentWays.data.map((w) => w.id).indexOf(osmWay.id)
if (index < 0) {
console.error("ReplaceGeometryAction is trying to detach node " + nodeId + ", but it isn't listed as being part of way " + osmWay.id)
return;
console.error(
"ReplaceGeometryAction is trying to detach node " +
nodeId +
", but it isn't listed as being part of way " +
osmWay.id
)
return
}
// We detachted this node - so we unregister
parentWays.data.splice(index, 1)
parentWays.ping();
parentWays.ping()
if (hasTags) {
// Has tags: we leave this node alone
return;
return
}
if (parentWays.data.length != 0) {
// Still part of other ways: we leave this node alone!
return;
return
}
console.log("Removing node " + nodeId, "as it isn't needed anymore by any way")
allChanges.push({
meta: {
theme: this.theme,
changeType: "delete"
changeType: "delete",
},
doDelete: true,
type: "node",
@ -545,6 +561,4 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
return allChanges
}
}
}

View file

@ -1,21 +1,21 @@
import {OsmObject, OsmWay} from "../OsmObject";
import {Changes} from "../Changes";
import {GeoOperations} from "../../GeoOperations";
import OsmChangeAction from "./OsmChangeAction";
import {ChangeDescription} from "./ChangeDescription";
import RelationSplitHandler from "./RelationSplitHandler";
import { OsmObject, OsmWay } from "../OsmObject"
import { Changes } from "../Changes"
import { GeoOperations } from "../../GeoOperations"
import OsmChangeAction from "./OsmChangeAction"
import { ChangeDescription } from "./ChangeDescription"
import RelationSplitHandler from "./RelationSplitHandler"
interface SplitInfo {
originalIndex?: number, // or negative for new elements
lngLat: [number, number],
originalIndex?: number // or negative for new elements
lngLat: [number, number]
doSplit: boolean
}
export default class SplitAction extends OsmChangeAction {
private readonly wayId: string;
private readonly _splitPointsCoordinates: [number, number] []// lon, lat
private _meta: { theme: string, changeType: "split" };
private _toleranceInMeters: number;
private readonly wayId: string
private readonly _splitPointsCoordinates: [number, number][] // lon, lat
private _meta: { theme: string; changeType: "split" }
private _toleranceInMeters: number
/**
* Create a changedescription for splitting a point.
@ -25,12 +25,17 @@ export default class SplitAction extends OsmChangeAction {
* @param meta
* @param toleranceInMeters: if a splitpoint closer then this amount of meters to an existing point, the existing point will be used to split the line instead of a new point
*/
constructor(wayId: string, splitPointCoordinates: [number, number][], meta: { theme: string }, toleranceInMeters = 5) {
constructor(
wayId: string,
splitPointCoordinates: [number, number][],
meta: { theme: string },
toleranceInMeters = 5
) {
super(wayId, true)
this.wayId = wayId;
this.wayId = wayId
this._splitPointsCoordinates = splitPointCoordinates
this._toleranceInMeters = toleranceInMeters;
this._meta = {...meta, changeType: "split"};
this._toleranceInMeters = toleranceInMeters
this._meta = { ...meta, changeType: "split" }
}
private static SegmentSplitInfo(splitInfo: SplitInfo[]): SplitInfo[][] {
@ -47,16 +52,16 @@ export default class SplitAction extends OsmChangeAction {
}
}
wayParts.push(currentPart)
return wayParts.filter(wp => wp.length > 0)
return wayParts.filter((wp) => wp.length > 0)
}
async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const originalElement = <OsmWay>await OsmObject.DownloadObjectAsync(this.wayId)
const originalNodes = originalElement.nodes;
const originalNodes = originalElement.nodes
// First, calculate splitpoints and remove points close to one another
const splitInfo = this.CalculateSplitCoordinates(originalElement, this._toleranceInMeters)
// Now we have a list with e.g.
// Now we have a list with e.g.
// [ { originalIndex: 0}, {originalIndex: 1, doSplit: true}, {originalIndex: 2}, {originalIndex: undefined, doSplit: true}, {originalIndex: 3}]
// Lets change 'originalIndex' to the actual node id first (or assign a new id if needed):
@ -64,19 +69,19 @@ export default class SplitAction extends OsmChangeAction {
if (element.originalIndex >= 0) {
element.originalIndex = originalElement.nodes[element.originalIndex]
} else {
element.originalIndex = changes.getNewID();
element.originalIndex = changes.getNewID()
}
}
// Next up is creating actual parts from this
const wayParts: SplitInfo[][] = SplitAction.SegmentSplitInfo(splitInfo);
const wayParts: SplitInfo[][] = SplitAction.SegmentSplitInfo(splitInfo)
// Allright! At this point, we have our new ways!
// Which one is the longest of them (and can keep the id)?
let longest = undefined;
let longest = undefined
for (const wayPart of wayParts) {
if (longest === undefined) {
longest = wayPart;
longest = wayPart
continue
}
if (wayPart.length > longest.length) {
@ -88,16 +93,16 @@ export default class SplitAction extends OsmChangeAction {
// Let's create the new points as needed
for (const element of splitInfo) {
if (element.originalIndex >= 0) {
continue;
continue
}
changeDescription.push({
type: "node",
id: element.originalIndex,
changes: {
lon: element.lngLat[0],
lat: element.lngLat[1]
lat: element.lngLat[1],
},
meta: this._meta
meta: this._meta,
})
}
@ -107,24 +112,23 @@ export default class SplitAction extends OsmChangeAction {
const allWaysNodesInOrder: number[][] = []
// Lets create OsmWays based on them
for (const wayPart of wayParts) {
let isOriginal = wayPart === longest
if (isOriginal) {
// We change the actual element!
const nodeIds = wayPart.map(p => p.originalIndex)
const nodeIds = wayPart.map((p) => p.originalIndex)
changeDescription.push({
type: "way",
id: originalElement.id,
changes: {
coordinates: wayPart.map(p => p.lngLat),
nodes: nodeIds
coordinates: wayPart.map((p) => p.lngLat),
nodes: nodeIds,
},
meta: this._meta
meta: this._meta,
})
allWayIdsInOrder.push(originalElement.id)
allWaysNodesInOrder.push(nodeIds)
} else {
let id = changes.getNewID();
let id = changes.getNewID()
// Copy the tags from the original object onto the new
const kv = []
for (const k in originalElement.tags) {
@ -132,20 +136,20 @@ export default class SplitAction extends OsmChangeAction {
continue
}
if (k.startsWith("_") || k === "id") {
continue;
continue
}
kv.push({k: k, v: originalElement.tags[k]})
kv.push({ k: k, v: originalElement.tags[k] })
}
const nodeIds = wayPart.map(p => p.originalIndex)
const nodeIds = wayPart.map((p) => p.originalIndex)
changeDescription.push({
type: "way",
id: id,
tags: kv,
changes: {
coordinates: wayPart.map(p => p.lngLat),
nodes: nodeIds
coordinates: wayPart.map((p) => p.lngLat),
nodes: nodeIds,
},
meta: this._meta
meta: this._meta,
})
allWayIdsInOrder.push(id)
@ -157,13 +161,16 @@ export default class SplitAction extends OsmChangeAction {
// At least, the order of the ways is identical, so we can keep the same roles
const relations = await OsmObject.DownloadReferencingRelations(this.wayId)
for (const relation of relations) {
const changDescrs = await new RelationSplitHandler({
relation: relation,
allWayIdsInOrder: allWayIdsInOrder,
originalNodes: originalNodes,
allWaysNodesInOrder: allWaysNodesInOrder,
originalWayId: originalElement.id,
}, this._meta.theme).CreateChangeDescriptions(changes)
const changDescrs = await new RelationSplitHandler(
{
relation: relation,
allWayIdsInOrder: allWayIdsInOrder,
originalNodes: originalNodes,
allWaysNodesInOrder: allWaysNodesInOrder,
originalWayId: originalElement.id,
},
this._meta.theme
).CreateChangeDescriptions(changes)
changeDescription.push(...changDescrs)
}
@ -180,48 +187,47 @@ export default class SplitAction extends OsmChangeAction {
private CalculateSplitCoordinates(osmWay: OsmWay, toleranceInM = 5): SplitInfo[] {
const wayGeoJson = osmWay.asGeoJson()
// Should be [lon, lat][]
const originalPoints: [number, number][] = osmWay.coordinates.map(c => [c[1], c[0]])
const originalPoints: [number, number][] = osmWay.coordinates.map((c) => [c[1], c[0]])
const allPoints: {
// lon, lat
coordinates: [number, number],
isSplitPoint: boolean,
originalIndex?: number, // Original index
dist: number, // Distance from the nearest point on the original line
coordinates: [number, number]
isSplitPoint: boolean
originalIndex?: number // Original index
dist: number // Distance from the nearest point on the original line
location: number // Distance from the start of the way
}[] = this._splitPointsCoordinates.map(c => {
}[] = this._splitPointsCoordinates.map((c) => {
// From the turf.js docs:
// The properties object will contain three values:
// The properties object will contain three values:
// - `index`: closest point was found on nth line part,
// - `dist`: distance between pt and the closest point,
// - `dist`: distance between pt and the closest point,
// `location`: distance along the line between start and the closest point.
let projected = GeoOperations.nearestPoint(wayGeoJson, c)
// c is lon lat
return ({
return {
coordinates: c,
isSplitPoint: true,
dist: projected.properties.dist,
location: projected.properties.location
});
location: projected.properties.location,
}
})
// We have a bunch of coordinates here: [ [lon, lon], [lat, lon], ...] ...
// We project them onto the line (which should yield pretty much the same point and add them to allPoints
for (let i = 0; i < originalPoints.length; i++) {
let originalPoint = originalPoints[i];
let originalPoint = originalPoints[i]
let projected = GeoOperations.nearestPoint(wayGeoJson, originalPoint)
allPoints.push({
coordinates: originalPoint,
isSplitPoint: false,
location: projected.properties.location,
originalIndex: i,
dist: projected.properties.dist
dist: projected.properties.dist,
})
}
// At this point, we have a list of both the split point and the old points, with some properties to discriminate between them
// We sort this list so that the new points are at the same location
allPoints.sort((a, b) => a.location - b.location)
for (let i = allPoints.length - 2; i >= 1; i--) {
// We 'merge' points with already existing nodes if they are close enough to avoid closeby elements
@ -244,7 +250,7 @@ export default class SplitAction extends OsmChangeAction {
if (distToNext * 1000 > toleranceInM && distToPrev * 1000 > toleranceInM) {
// Both are too far away to mark them as the split point
continue;
continue
}
let closest = nextPoint
@ -256,9 +262,8 @@ export default class SplitAction extends OsmChangeAction {
// We can not split on the first or last points...
continue
}
closest.isSplitPoint = true;
closest.isSplitPoint = true
allPoints.splice(i, 1)
}
const splitInfo: SplitInfo[] = []
@ -267,19 +272,17 @@ export default class SplitAction extends OsmChangeAction {
for (const p of allPoints) {
let index = p.originalIndex
if (index === undefined) {
index = nextId;
nextId--;
index = nextId
nextId--
}
const splitInfoElement = {
originalIndex: index,
lngLat: p.coordinates,
doSplit: p.isSplitPoint
doSplit: p.isSplitPoint,
}
splitInfo.push(splitInfoElement)
}
return splitInfo
}
}

View file

@ -1,106 +1,110 @@
import {OsmNode, OsmObject, OsmRelation, OsmWay} from "./OsmObject";
import {UIEventSource} from "../UIEventSource";
import Constants from "../../Models/Constants";
import OsmChangeAction from "./Actions/OsmChangeAction";
import {ChangeDescription, ChangeDescriptionTools} from "./Actions/ChangeDescription";
import {Utils} from "../../Utils";
import {LocalStorageSource} from "../Web/LocalStorageSource";
import SimpleMetaTagger from "../SimpleMetaTagger";
import CreateNewNodeAction from "./Actions/CreateNewNodeAction";
import FeatureSource from "../FeatureSource/FeatureSource";
import {ElementStorage} from "../ElementStorage";
import {GeoLocationPointProperties} from "../Actors/GeoLocationHandler";
import {GeoOperations} from "../GeoOperations";
import {ChangesetHandler, ChangesetTag} from "./ChangesetHandler";
import {OsmConnection} from "./OsmConnection";
import { OsmNode, OsmObject, OsmRelation, OsmWay } from "./OsmObject"
import { UIEventSource } from "../UIEventSource"
import Constants from "../../Models/Constants"
import OsmChangeAction from "./Actions/OsmChangeAction"
import { ChangeDescription, ChangeDescriptionTools } from "./Actions/ChangeDescription"
import { Utils } from "../../Utils"
import { LocalStorageSource } from "../Web/LocalStorageSource"
import SimpleMetaTagger from "../SimpleMetaTagger"
import CreateNewNodeAction from "./Actions/CreateNewNodeAction"
import FeatureSource from "../FeatureSource/FeatureSource"
import { ElementStorage } from "../ElementStorage"
import { GeoLocationPointProperties } from "../Actors/GeoLocationHandler"
import { GeoOperations } from "../GeoOperations"
import { ChangesetHandler, ChangesetTag } from "./ChangesetHandler"
import { OsmConnection } from "./OsmConnection"
/**
* Handles all changes made to OSM.
* Needs an authenticator via OsmConnection
*/
export class Changes {
public readonly name = "Newly added features"
/**
* All the newly created features as featureSource + all the modified features
*/
public features = new UIEventSource<{ feature: any, freshness: Date }[]>([]);
public readonly pendingChanges: UIEventSource<ChangeDescription[]> = LocalStorageSource.GetParsed<ChangeDescription[]>("pending-changes", [])
public features = new UIEventSource<{ feature: any; freshness: Date }[]>([])
public readonly pendingChanges: UIEventSource<ChangeDescription[]> =
LocalStorageSource.GetParsed<ChangeDescription[]>("pending-changes", [])
public readonly allChanges = new UIEventSource<ChangeDescription[]>(undefined)
public readonly state: { allElements: ElementStorage; osmConnection: OsmConnection }
public readonly extraComment: UIEventSource<string> = new UIEventSource(undefined)
private historicalUserLocations: FeatureSource
private _nextId: number = -1; // Newly assigned ID's are negative
private readonly isUploading = new UIEventSource(false);
private _nextId: number = -1 // Newly assigned ID's are negative
private readonly isUploading = new UIEventSource(false)
private readonly previouslyCreated: OsmObject[] = []
private readonly _leftRightSensitive: boolean;
private _changesetHandler: ChangesetHandler;
private readonly _leftRightSensitive: boolean
private _changesetHandler: ChangesetHandler
constructor(
state?: {
allElements: ElementStorage,
allElements: ElementStorage
osmConnection: OsmConnection
},
leftRightSensitive: boolean = false) {
this._leftRightSensitive = leftRightSensitive;
leftRightSensitive: boolean = false
) {
this._leftRightSensitive = leftRightSensitive
// We keep track of all changes just as well
this.allChanges.setData([...this.pendingChanges.data])
// If a pending change contains a negative ID, we save that
this._nextId = Math.min(-1, ...this.pendingChanges.data?.map(pch => pch.id) ?? [])
this.state = state;
this._changesetHandler = state?.osmConnection?.CreateChangesetHandler(state.allElements, this)
this._nextId = Math.min(-1, ...(this.pendingChanges.data?.map((pch) => pch.id) ?? []))
this.state = state
this._changesetHandler = state?.osmConnection?.CreateChangesetHandler(
state.allElements,
this
)
// Note: a changeset might be reused which was opened just before and might have already used some ids
// This doesn't matter however, as the '-1' is per piecewise upload, not global per changeset
}
static createChangesetFor(csId: string,
allChanges: {
modifiedObjects: OsmObject[],
newObjects: OsmObject[],
deletedObjects: OsmObject[]
}): string {
static createChangesetFor(
csId: string,
allChanges: {
modifiedObjects: OsmObject[]
newObjects: OsmObject[]
deletedObjects: OsmObject[]
}
): string {
const changedElements = allChanges.modifiedObjects ?? []
const newElements = allChanges.newObjects ?? []
const deletedElements = allChanges.deletedObjects ?? []
let changes = `<osmChange version='0.6' generator='Mapcomplete ${Constants.vNumber}'>`;
let changes = `<osmChange version='0.6' generator='Mapcomplete ${Constants.vNumber}'>`
if (newElements.length > 0) {
changes +=
"\n<create>\n" +
newElements.map(e => e.ChangesetXML(csId)).join("\n") +
"</create>";
newElements.map((e) => e.ChangesetXML(csId)).join("\n") +
"</create>"
}
if (changedElements.length > 0) {
changes +=
"\n<modify>\n" +
changedElements.map(e => e.ChangesetXML(csId)).join("\n") +
"\n</modify>";
changedElements.map((e) => e.ChangesetXML(csId)).join("\n") +
"\n</modify>"
}
if (deletedElements.length > 0) {
changes +=
"\n<delete>\n" +
deletedElements.map(e => e.ChangesetXML(csId)).join("\n") +
deletedElements.map((e) => e.ChangesetXML(csId)).join("\n") +
"\n</delete>"
}
changes += "</osmChange>";
return changes;
changes += "</osmChange>"
return changes
}
private static GetNeededIds(changes: ChangeDescription[]) {
return Utils.Dedup(changes.filter(c => c.id >= 0)
.map(c => c.type + "/" + c.id))
return Utils.Dedup(changes.filter((c) => c.id >= 0).map((c) => c.type + "/" + c.id))
}
/**
* Returns a new ID and updates the value for the next ID
*/
public getNewID() {
return this._nextId--;
return this._nextId--
}
/**
@ -109,64 +113,71 @@ export class Changes {
*/
public async flushChanges(flushreason: string = undefined): Promise<void> {
if (this.pendingChanges.data.length === 0) {
return;
return
}
if (this.isUploading.data) {
console.log("Is already uploading... Abort")
return;
return
}
console.log("Uploading changes due to: ", flushreason)
this.isUploading.setData(true)
try {
const csNumber = await this.flushChangesAsync()
this.isUploading.setData(false)
console.log("Changes flushed. Your changeset is " + csNumber);
console.log("Changes flushed. Your changeset is " + csNumber)
} catch (e) {
this.isUploading.setData(false)
console.error("Flushing changes failed due to", e);
console.error("Flushing changes failed due to", e)
}
}
public async applyAction(action: OsmChangeAction): Promise<void> {
const changeDescriptions = await action.Perform(this)
changeDescriptions[0].meta.distanceToObject = this.calculateDistanceToChanges(action, changeDescriptions)
changeDescriptions[0].meta.distanceToObject = this.calculateDistanceToChanges(
action,
changeDescriptions
)
this.applyChanges(changeDescriptions)
}
public applyChanges(changes: ChangeDescription[]) {
console.log("Received changes:", changes)
this.pendingChanges.data.push(...changes);
this.pendingChanges.ping();
this.pendingChanges.data.push(...changes)
this.pendingChanges.ping()
this.allChanges.data.push(...changes)
this.allChanges.ping()
}
private calculateDistanceToChanges(change: OsmChangeAction, changeDescriptions: ChangeDescription[]) {
private calculateDistanceToChanges(
change: OsmChangeAction,
changeDescriptions: ChangeDescription[]
) {
const locations = this.historicalUserLocations?.features?.data
if (locations === undefined) {
// No state loaded or no locations -> we can't calculate...
return;
return
}
if (!change.trackStatistics) {
// Probably irrelevant, such as a new helper node
return;
return
}
const now = new Date()
const recentLocationPoints = locations.map(ff => ff.feature)
.filter(feat => feat.geometry.type === "Point")
.filter(feat => {
const visitTime = new Date((<GeoLocationPointProperties><any>feat.properties).date)
const recentLocationPoints = locations
.map((ff) => ff.feature)
.filter((feat) => feat.geometry.type === "Point")
.filter((feat) => {
const visitTime = new Date(
(<GeoLocationPointProperties>(<any>feat.properties)).date
)
// In seconds
const diff = (now.getTime() - visitTime.getTime()) / 1000
return diff < Constants.nearbyVisitTime;
return diff < Constants.nearbyVisitTime
})
if (recentLocationPoints.length === 0) {
// Probably no GPS enabled/no fix
return;
// Probably no GPS enabled/no fix
return
}
// The applicable points, contain information in their properties about location, time and GPS accuracy
@ -182,7 +193,10 @@ export class Changes {
}
for (const changeDescription of changeDescriptions) {
const chng: { lat: number, lon: number } | { coordinates: [number, number][] } | { members } = changeDescription.changes
const chng:
| { lat: number; lon: number }
| { coordinates: [number, number][] }
| { members } = changeDescription.changes
if (chng === undefined) {
continue
}
@ -194,61 +208,85 @@ export class Changes {
}
}
return Math.min(...changedObjectCoordinates.map(coor =>
Math.min(...recentLocationPoints.map(gpsPoint => {
const otherCoor = GeoOperations.centerpointCoordinates(gpsPoint)
return GeoOperations.distanceBetween(coor, otherCoor)
}))
))
return Math.min(
...changedObjectCoordinates.map((coor) =>
Math.min(
...recentLocationPoints.map((gpsPoint) => {
const otherCoor = GeoOperations.centerpointCoordinates(gpsPoint)
return GeoOperations.distanceBetween(coor, otherCoor)
})
)
)
)
}
/**
* UPload the selected changes to OSM.
* Returns 'true' if successfull and if they can be removed
*/
private async flushSelectChanges(pending: ChangeDescription[], openChangeset: UIEventSource<number>): Promise<boolean> {
const self = this;
private async flushSelectChanges(
pending: ChangeDescription[],
openChangeset: UIEventSource<number>
): Promise<boolean> {
const self = this
const neededIds = Changes.GetNeededIds(pending)
const osmObjects = Utils.NoNull(await Promise.all(neededIds.map(async id =>
OsmObject.DownloadObjectAsync(id).catch(e => {
console.error("Could not download OSM-object", id, " dropping it from the changes ("+e+")")
pending = pending.filter(ch => ch.type + "/" + ch.id !== id)
return undefined;
}))));
const osmObjects = Utils.NoNull(
await Promise.all(
neededIds.map(async (id) =>
OsmObject.DownloadObjectAsync(id).catch((e) => {
console.error(
"Could not download OSM-object",
id,
" dropping it from the changes (" + e + ")"
)
pending = pending.filter((ch) => ch.type + "/" + ch.id !== id)
return undefined
})
)
)
)
if (this._leftRightSensitive) {
osmObjects.forEach(obj => SimpleMetaTagger.removeBothTagging(obj.tags))
osmObjects.forEach((obj) => SimpleMetaTagger.removeBothTagging(obj.tags))
}
console.log("Got the fresh objects!", osmObjects, "pending: ", pending)
if(pending.length == 0){
if (pending.length == 0) {
console.log("No pending changes...")
return true;
return true
}
const perType = Array.from(
Utils.Hist(pending.filter(descr => descr.meta.changeType !== undefined && descr.meta.changeType !== null)
.map(descr => descr.meta.changeType)), ([key, count]) => (
{
key: key,
value: count,
aggregate: true
}))
const motivations = pending.filter(descr => descr.meta.specialMotivation !== undefined)
.map(descr => ({
Utils.Hist(
pending
.filter(
(descr) =>
descr.meta.changeType !== undefined && descr.meta.changeType !== null
)
.map((descr) => descr.meta.changeType)
),
([key, count]) => ({
key: key,
value: count,
aggregate: true,
})
)
const motivations = pending
.filter((descr) => descr.meta.specialMotivation !== undefined)
.map((descr) => ({
key: descr.meta.changeType + ":" + descr.type + "/" + descr.id,
value: descr.meta.specialMotivation
value: descr.meta.specialMotivation,
}))
const distances = Utils.NoNull(pending.map(descr => descr.meta.distanceToObject));
const distances = Utils.NoNull(pending.map((descr) => descr.meta.distanceToObject))
distances.sort((a, b) => a - b)
const perBinCount = Constants.distanceToChangeObjectBins.map(_ => 0)
const perBinCount = Constants.distanceToChangeObjectBins.map((_) => 0)
let j = 0;
let j = 0
const maxDistances = Constants.distanceToChangeObjectBins
for (let i = 0; i < maxDistances.length; i++) {
const maxDistance = maxDistances[i];
const maxDistance = maxDistances[i]
// distances is sorted in ascending order, so as soon as one is to big, all the resting elements will be bigger too
while (j < distances.length && distances[j] < maxDistance) {
perBinCount[i]++
@ -256,21 +294,23 @@ export class Changes {
}
}
const perBinMessage = Utils.NoNull(perBinCount.map((count, i) => {
if (count === 0) {
return undefined
}
const maxD = maxDistances[i]
let key = `change_within_${maxD}m`
if (maxD === Number.MAX_VALUE) {
key = `change_over_${maxDistances[i - 1]}m`
}
return {
key,
value: count,
aggregate: true
}
}))
const perBinMessage = Utils.NoNull(
perBinCount.map((count, i) => {
if (count === 0) {
return undefined
}
const maxD = maxDistances[i]
let key = `change_within_${maxD}m`
if (maxD === Number.MAX_VALUE) {
key = `change_over_${maxDistances[i - 1]}m`
}
return {
key,
value: count,
aggregate: true,
}
})
)
// This method is only called with changedescriptions for this theme
const theme = pending[0].meta.theme
@ -279,46 +319,47 @@ export class Changes {
comment += "\n\n" + this.extraComment.data
}
const metatags: ChangesetTag[] = [{
key: "comment",
value: comment
},
const metatags: ChangesetTag[] = [
{
key: "comment",
value: comment,
},
{
key: "theme",
value: theme
value: theme,
},
...perType,
...motivations,
...perBinMessage
...perBinMessage,
]
await this._changesetHandler.UploadChangeset(
(csId, remappings) =>{
if(remappings.size > 0){
(csId, remappings) => {
if (remappings.size > 0) {
console.log("Rewriting pending changes from", pending, "with", remappings)
pending = pending.map(ch => ChangeDescriptionTools.rewriteIds(ch, remappings))
pending = pending.map((ch) => ChangeDescriptionTools.rewriteIds(ch, remappings))
console.log("Result is", pending)
}
const changes: {
newObjects: OsmObject[],
newObjects: OsmObject[]
modifiedObjects: OsmObject[]
deletedObjects: OsmObject[]
} = self.CreateChangesetObjects(pending, osmObjects)
return Changes.createChangesetFor("" + csId, changes)
return Changes.createChangesetFor("" + csId, changes)
},
metatags,
openChangeset
)
console.log("Upload successfull!")
return true;
return true
}
private async flushChangesAsync(): Promise<void> {
const self = this;
const self = this
try {
// At last, we build the changeset and upload
const pending = self.pendingChanges.data;
const pending = self.pendingChanges.data
const pendingPerTheme = new Map<string, ChangeDescription[]>()
for (const changeDescription of pending) {
@ -329,50 +370,62 @@ export class Changes {
pendingPerTheme.get(theme).push(changeDescription)
}
const successes = await Promise.all(Array.from(pendingPerTheme,
async ([theme, pendingChanges]) => {
const successes = await Promise.all(
Array.from(pendingPerTheme, async ([theme, pendingChanges]) => {
try {
const openChangeset = this.state.osmConnection.GetPreference("current-open-changeset-" + theme).sync(
str => {
const n = Number(str);
if (isNaN(n)) {
return undefined
}
return n
}, [], n => "" + n
);
console.log("Using current-open-changeset-" + theme + " from the preferences, got " + openChangeset.data)
const openChangeset = this.state.osmConnection
.GetPreference("current-open-changeset-" + theme)
.sync(
(str) => {
const n = Number(str)
if (isNaN(n)) {
return undefined
}
return n
},
[],
(n) => "" + n
)
console.log(
"Using current-open-changeset-" +
theme +
" from the preferences, got " +
openChangeset.data
)
return await self.flushSelectChanges(pendingChanges, openChangeset);
return await self.flushSelectChanges(pendingChanges, openChangeset)
} catch (e) {
console.error("Could not upload some changes:", e)
return false
}
}))
})
)
if (!successes.some(s => s == false)) {
if (!successes.some((s) => s == false)) {
// All changes successfull, we clear the data!
this.pendingChanges.setData([]);
this.pendingChanges.setData([])
}
} catch (e) {
console.error("Could not handle changes - probably an old, pending changeset in localstorage with an invalid format; erasing those", e)
console.error(
"Could not handle changes - probably an old, pending changeset in localstorage with an invalid format; erasing those",
e
)
self.pendingChanges.setData([])
} finally {
self.isUploading.setData(false)
}
}
public CreateChangesetObjects(changes: ChangeDescription[], downloadedOsmObjects: OsmObject[]): {
newObjects: OsmObject[],
public CreateChangesetObjects(
changes: ChangeDescription[],
downloadedOsmObjects: OsmObject[]
): {
newObjects: OsmObject[]
modifiedObjects: OsmObject[]
deletedObjects: OsmObject[]
} {
const objects: Map<string, OsmObject> = new Map<string, OsmObject>()
const states: Map<string, "unchanged" | "created" | "modified" | "deleted"> = new Map();
const states: Map<string, "unchanged" | "created" | "modified" | "deleted"> = new Map()
for (const o of downloadedOsmObjects) {
objects.set(o.type + "/" + o.id, o)
@ -385,7 +438,7 @@ export class Changes {
}
for (const change of changes) {
let changed = false;
let changed = false
const id = change.type + "/" + change.id
if (!objects.has(id)) {
// The object hasn't been seen before, so it doesn't exist yet and is newly created by its very definition
@ -400,24 +453,24 @@ export class Changes {
// This is a new object that should be created
states.set(id, "created")
console.log("Creating object for changeDescription", change)
let osmObj: OsmObject = undefined;
let osmObj: OsmObject = undefined
switch (change.type) {
case "node":
const n = new OsmNode(change.id)
n.lat = change.changes["lat"]
n.lon = change.changes["lon"]
osmObj = n
break;
break
case "way":
const w = new OsmWay(change.id)
w.nodes = change.changes["nodes"]
osmObj = w
break;
break
case "relation":
const r = new OsmRelation(change.id)
r.members = change.changes["members"]
osmObj = r
break;
break
}
if (osmObj === undefined) {
throw "Hmm? This is a bug"
@ -442,55 +495,57 @@ export class Changes {
let v = kv.v
if (v === "") {
v = undefined;
v = undefined
}
const oldV = obj.tags[k]
if (oldV === v) {
continue;
continue
}
obj.tags[k] = v;
changed = true;
obj.tags[k] = v
changed = true
}
if (change.changes !== undefined) {
switch (change.type) {
case "node":
// @ts-ignore
const nlat = change.changes.lat;
const nlat = change.changes.lat
// @ts-ignore
const nlon = change.changes.lon;
const nlon = change.changes.lon
const n = <OsmNode>obj
if (n.lat !== nlat || n.lon !== nlon) {
n.lat = nlat;
n.lon = nlon;
changed = true;
n.lat = nlat
n.lon = nlon
changed = true
}
break;
break
case "way":
const nnodes = change.changes["nodes"]
const w = <OsmWay>obj
if (!Utils.Identical(nnodes, w.nodes)) {
w.nodes = nnodes
changed = true;
changed = true
}
break;
break
case "relation":
const nmembers: { type: "node" | "way" | "relation", ref: number, role: string }[] = change.changes["members"]
const nmembers: {
type: "node" | "way" | "relation"
ref: number
role: string
}[] = change.changes["members"]
const r = <OsmRelation>obj
if (!Utils.Identical(nmembers, r.members, (a, b) => {
return a.role === b.role && a.type === b.type && a.ref === b.ref
})) {
r.members = nmembers;
changed = true;
if (
!Utils.Identical(nmembers, r.members, (a, b) => {
return a.role === b.role && a.type === b.type && a.ref === b.ref
})
) {
r.members = nmembers
changed = true
}
break;
break
}
}
if (changed && states.get(id) === "unchanged") {
@ -498,15 +553,13 @@ export class Changes {
}
}
const result = {
newObjects: [],
modifiedObjects: [],
deletedObjects: []
deletedObjects: [],
}
objects.forEach((v, id) => {
const state = states.get(id)
if (state === "created") {
result.newObjects.push(v)
@ -517,14 +570,21 @@ export class Changes {
if (state === "deleted") {
result.deletedObjects.push(v)
}
})
console.debug("Calculated the pending changes: ", result.newObjects.length, "new; ", result.modifiedObjects.length, "modified;", result.deletedObjects, "deleted")
console.debug(
"Calculated the pending changes: ",
result.newObjects.length,
"new; ",
result.modifiedObjects.length,
"modified;",
result.deletedObjects,
"deleted"
)
return result
}
public setHistoricalUserLocations(locations: FeatureSource ){
public setHistoricalUserLocations(locations: FeatureSource) {
this.historicalUserLocations = locations
}
}
}

View file

@ -1,28 +1,26 @@
import escapeHtml from "escape-html";
import UserDetails, {OsmConnection} from "./OsmConnection";
import {UIEventSource} from "../UIEventSource";
import {ElementStorage} from "../ElementStorage";
import Locale from "../../UI/i18n/Locale";
import Constants from "../../Models/Constants";
import {Changes} from "./Changes";
import {Utils} from "../../Utils";
import escapeHtml from "escape-html"
import UserDetails, { OsmConnection } from "./OsmConnection"
import { UIEventSource } from "../UIEventSource"
import { ElementStorage } from "../ElementStorage"
import Locale from "../../UI/i18n/Locale"
import Constants from "../../Models/Constants"
import { Changes } from "./Changes"
import { Utils } from "../../Utils"
export interface ChangesetTag {
key: string,
value: string | number,
key: string
value: string | number
aggregate?: boolean
}
export class ChangesetHandler {
private readonly allElements: ElementStorage;
private osmConnection: OsmConnection;
private readonly changes: Changes;
private readonly _dryRun: UIEventSource<boolean>;
private readonly userDetails: UIEventSource<UserDetails>;
private readonly auth: any;
private readonly backend: string;
private readonly allElements: ElementStorage
private osmConnection: OsmConnection
private readonly changes: Changes
private readonly _dryRun: UIEventSource<boolean>
private readonly userDetails: UIEventSource<UserDetails>
private readonly auth: any
private readonly backend: string
/**
* Contains previously rewritten IDs
@ -30,7 +28,6 @@ export class ChangesetHandler {
*/
private readonly _remappings = new Map<string, string>()
/**
* Use 'osmConnection.CreateChangesetHandler' instead
* @param dryRun
@ -39,36 +36,36 @@ export class ChangesetHandler {
* @param changes
* @param auth
*/
constructor(dryRun: UIEventSource<boolean>,
osmConnection: OsmConnection,
allElements: ElementStorage,
changes: Changes,
auth) {
this.osmConnection = osmConnection;
this.allElements = allElements;
this.changes = changes;
this._dryRun = dryRun;
this.userDetails = osmConnection.userDetails;
constructor(
dryRun: UIEventSource<boolean>,
osmConnection: OsmConnection,
allElements: ElementStorage,
changes: Changes,
auth
) {
this.osmConnection = osmConnection
this.allElements = allElements
this.changes = changes
this._dryRun = dryRun
this.userDetails = osmConnection.userDetails
this.backend = osmConnection._oauth_config.url
this.auth = auth;
this.auth = auth
if (dryRun) {
console.log("DRYRUN ENABLED");
console.log("DRYRUN ENABLED")
}
}
/**
* Creates a new list which contains every key at most once
*
*
* ChangesetHandler.removeDuplicateMetaTags([{key: "k", value: "v"}, {key: "k0", value: "v0"}, {key: "k", value:"v"}] // => [{key: "k", value: "v"}, {key: "k0", value: "v0"}]
*/
public static removeDuplicateMetaTags(extraMetaTags: ChangesetTag[]): ChangesetTag[]{
const r : ChangesetTag[] = []
public static removeDuplicateMetaTags(extraMetaTags: ChangesetTag[]): ChangesetTag[] {
const r: ChangesetTag[] = []
const seen = new Set<string>()
for (const extraMetaTag of extraMetaTags) {
if(seen.has(extraMetaTag.key)){
if (seen.has(extraMetaTag.key)) {
continue
}
r.push(extraMetaTag)
@ -86,7 +83,7 @@ export class ChangesetHandler {
* @private
*/
static rewriteMetaTags(extraMetaTags: ChangesetTag[], rewriteIds: Map<string, string>) {
let hasChange = false;
let hasChange = false
for (const tag of extraMetaTags) {
const match = tag.key.match(/^([a-zA-Z0-9_]+):(node\/-[0-9])$/)
if (match == null) {
@ -115,40 +112,48 @@ export class ChangesetHandler {
public async UploadChangeset(
generateChangeXML: (csid: number, remappings: Map<string, string>) => string,
extraMetaTags: ChangesetTag[],
openChangeset: UIEventSource<number>): Promise<void> {
if (!extraMetaTags.some(tag => tag.key === "comment") || !extraMetaTags.some(tag => tag.key === "theme")) {
openChangeset: UIEventSource<number>
): Promise<void> {
if (
!extraMetaTags.some((tag) => tag.key === "comment") ||
!extraMetaTags.some((tag) => tag.key === "theme")
) {
throw "The meta tags should at least contain a `comment` and a `theme`"
}
extraMetaTags = [...extraMetaTags, ...this.defaultChangesetTags()]
extraMetaTags = ChangesetHandler.removeDuplicateMetaTags(extraMetaTags)
if (this.userDetails.data.csCount == 0) {
// The user became a contributor!
this.userDetails.data.csCount = 1;
this.userDetails.ping();
this.userDetails.data.csCount = 1
this.userDetails.ping()
}
if (this._dryRun.data) {
const changesetXML = generateChangeXML(123456, this._remappings);
const changesetXML = generateChangeXML(123456, this._remappings)
console.log("Metatags are", extraMetaTags)
console.log(changesetXML);
return;
console.log(changesetXML)
return
}
if (openChangeset.data === undefined) {
// We have to open a new changeset
try {
const csId = await this.OpenChangeset(extraMetaTags)
openChangeset.setData(csId);
const changeset = generateChangeXML(csId, this._remappings);
console.trace("Opened a new changeset (openChangeset.data is undefined):", changeset);
openChangeset.setData(csId)
const changeset = generateChangeXML(csId, this._remappings)
console.trace(
"Opened a new changeset (openChangeset.data is undefined):",
changeset
)
const changes = await this.UploadChange(csId, changeset)
const hasSpecialMotivationChanges = ChangesetHandler.rewriteMetaTags(extraMetaTags, changes)
if(hasSpecialMotivationChanges){
const hasSpecialMotivationChanges = ChangesetHandler.rewriteMetaTags(
extraMetaTags,
changes
)
if (hasSpecialMotivationChanges) {
// At this point, 'extraMetaTags' will have changed - we need to set the tags again
this.UpdateTags(csId, extraMetaTags)
}
} catch (e) {
console.error("Could not open/upload changeset due to ", e)
openChangeset.setData(undefined)
@ -156,29 +161,32 @@ export class ChangesetHandler {
} else {
// There still exists an open changeset (or at least we hope so)
// Let's check!
const csId = openChangeset.data;
const csId = openChangeset.data
try {
const oldChangesetMeta = await this.GetChangesetMeta(csId)
if (!oldChangesetMeta.open) {
// Mark the CS as closed...
console.log("Could not fetch the metadata from the already open changeset")
openChangeset.setData(undefined);
// ... and try again. As the cs is closed, no recursive loop can exist
openChangeset.setData(undefined)
// ... and try again. As the cs is closed, no recursive loop can exist
await this.UploadChangeset(generateChangeXML, extraMetaTags, openChangeset)
return;
return
}
const rewritings = await this.UploadChange(
csId,
generateChangeXML(csId, this._remappings))
generateChangeXML(csId, this._remappings)
)
const rewrittenTags = this.RewriteTagsOf(extraMetaTags, rewritings, oldChangesetMeta)
const rewrittenTags = this.RewriteTagsOf(
extraMetaTags,
rewritings,
oldChangesetMeta
)
await this.UpdateTags(csId, rewrittenTags)
} catch (e) {
console.warn("Could not upload, changeset is probably closed: ", e);
openChangeset.setData(undefined);
console.warn("Could not upload, changeset is probably closed: ", e)
openChangeset.setData(undefined)
}
}
}
@ -190,17 +198,17 @@ export class ChangesetHandler {
* @param rewriteIds: the mapping of ids
* @param oldChangesetMeta: the metadata-object of the already existing changeset
*/
public RewriteTagsOf(extraMetaTags: ChangesetTag[],
rewriteIds: Map<string, string>,
oldChangesetMeta: {
open: boolean,
id: number
uid: number, // User ID
changes_count: number,
tags: any
}) : ChangesetTag[] {
public RewriteTagsOf(
extraMetaTags: ChangesetTag[],
rewriteIds: Map<string, string>,
oldChangesetMeta: {
open: boolean
id: number
uid: number // User ID
changes_count: number
tags: any
}
): ChangesetTag[] {
// Note: extraMetaTags is where all the tags are collected into
// same as 'extraMetaTag', but indexed
@ -221,7 +229,7 @@ export class ChangesetHandler {
if (newMetaTag === undefined) {
extraMetaTags.push({
key: key,
value: oldCsTags[key]
value: oldCsTags[key],
})
continue
}
@ -242,10 +250,8 @@ export class ChangesetHandler {
}
}
ChangesetHandler.rewriteMetaTags(extraMetaTags, rewriteIds)
return extraMetaTags
}
/**
@ -255,28 +261,28 @@ export class ChangesetHandler {
* @private
*/
private static parseIdRewrite(node: any, type: string): [string, string] {
const oldId = parseInt(node.attributes.old_id.value);
const oldId = parseInt(node.attributes.old_id.value)
if (node.attributes.new_id === undefined) {
return [type+"/"+oldId, undefined];
return [type + "/" + oldId, undefined]
}
const newId = parseInt(node.attributes.new_id.value);
const newId = parseInt(node.attributes.new_id.value)
// The actual mapping
const result: [string, string] = [type + "/" + oldId, type + "/" + newId]
if(oldId === newId){
return undefined;
if (oldId === newId) {
return undefined
}
return result;
return result
}
/**
* Given a diff-result XML of the form
* Given a diff-result XML of the form
* <diffResult version="0.6">
* <node old_id="-1" new_id="9650458521" new_version="1"/>
* <way old_id="-2" new_id="1050127772" new_version="1"/>
* </diffResult>,
* will:
*
*
* - create a mapping `{'node/-1' --> "node/9650458521", 'way/-2' --> "way/9650458521"}
* - Call this.changes.registerIdRewrites
* - Call handleIdRewrites as needed
@ -284,9 +290,9 @@ export class ChangesetHandler {
* @private
*/
private parseUploadChangesetResponse(response: XMLDocument): Map<string, string> {
const nodes = response.getElementsByTagName("node");
const mappings : [string, string][]= []
const nodes = response.getElementsByTagName("node")
const mappings: [string, string][] = []
for (const node of Array.from(nodes)) {
const mapping = ChangesetHandler.parseIdRewrite(node, "node")
if (mapping !== undefined) {
@ -294,7 +300,7 @@ export class ChangesetHandler {
}
}
const ways = response.getElementsByTagName("way");
const ways = response.getElementsByTagName("way")
for (const way of Array.from(ways)) {
const mapping = ChangesetHandler.parseIdRewrite(way, "way")
if (mapping !== undefined) {
@ -303,40 +309,41 @@ export class ChangesetHandler {
}
for (const mapping of mappings) {
const [oldId, newId] = mapping
this.allElements.addAlias(oldId, newId);
if(newId !== undefined) {
this.allElements.addAlias(oldId, newId)
if (newId !== undefined) {
this._remappings.set(mapping[0], mapping[1])
}
}
return new Map<string, string>(mappings)
}
private async CloseChangeset(changesetId: number = undefined): Promise<void> {
const self = this
return new Promise<void>(function (resolve, reject) {
if (changesetId === undefined) {
return;
return
}
self.auth.xhr({
method: 'PUT',
path: '/api/0.6/changeset/' + changesetId + '/close',
}, function (err, response) {
if (response == null) {
console.log("err", err);
self.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)
resolve()
}
console.log("Closed changeset ", changesetId)
resolve()
});
)
})
}
async GetChangesetMeta(csId: number): Promise<{
id: number,
open: boolean,
uid: number,
changes_count: number,
id: number
open: boolean
uid: number
changes_count: number
tags: any
}> {
const url = `${this.backend}/api/0.6/changeset/${csId}`
@ -344,47 +351,59 @@ export class ChangesetHandler {
return csData.elements[0]
}
/**
* Puts the specified tags onto the changesets as they are.
* This method will erase previously set tags
*/
private async UpdateTags(
csId: number,
tags: ChangesetTag[]) {
private async UpdateTags(csId: number, tags: ChangesetTag[]) {
tags = ChangesetHandler.removeDuplicateMetaTags(tags)
const self = this;
const self = this
return new Promise<string>(function (resolve, reject) {
tags = Utils.NoNull(tags).filter(
(tag) =>
tag.key !== undefined &&
tag.value !== undefined &&
tag.key !== "" &&
tag.value !== ""
)
const metadata = tags.map((kv) => `<tag k="${kv.key}" v="${escapeHtml(kv.value)}"/>`)
tags = Utils.NoNull(tags).filter(tag => tag.key !== undefined && tag.value !== undefined && tag.key !== "" && tag.value !== "")
const metadata = tags.map(kv => `<tag k="${kv.key}" v="${escapeHtml(kv.value)}"/>`)
self.auth.xhr({
method: 'PUT',
path: '/api/0.6/changeset/' + csId,
options: {header: {'Content-Type': 'text/xml'}},
content: [`<osm><changeset>`,
metadata,
`</changeset></osm>`].join("")
}, function (err, response) {
if (response === undefined) {
console.error("Updating the tags of changeset "+csId+" failed:", err);
reject(err)
} else {
resolve(response);
self.auth.xhr(
{
method: "PUT",
path: "/api/0.6/changeset/" + csId,
options: { header: { "Content-Type": "text/xml" } },
content: [`<osm><changeset>`, metadata, `</changeset></osm>`].join(""),
},
function (err, response) {
if (response === undefined) {
console.error("Updating the tags of changeset " + csId + " failed:", err)
reject(err)
} else {
resolve(response)
}
}
});
)
})
}
private defaultChangesetTags() : ChangesetTag[]{
return [ ["created_by", `MapComplete ${Constants.vNumber}`],
private defaultChangesetTags(): ChangesetTag[] {
return [
["created_by", `MapComplete ${Constants.vNumber}`],
["locale", Locale.language.data],
["host", `${window.location.origin}${window.location.pathname}`],
["source", this.changes.state["currentUserLocation"]?.features?.data?.length > 0 ? "survey" : undefined],
["imagery", this.changes.state["backgroundLayer"]?.data?.id]].map(([key, value]) => ({
key, value, aggretage: false
[
"source",
this.changes.state["currentUserLocation"]?.features?.data?.length > 0
? "survey"
: undefined,
],
["imagery", this.changes.state["backgroundLayer"]?.data?.id],
].map(([key, value]) => ({
key,
value,
aggretage: false,
}))
}
@ -394,61 +413,57 @@ export class ChangesetHandler {
* @constructor
* @private
*/
private OpenChangeset(
changesetTags: ChangesetTag[]
): Promise<number> {
const self = this;
private OpenChangeset(changesetTags: ChangesetTag[]): Promise<number> {
const self = this
return new Promise<number>(function (resolve, reject) {
const metadata = changesetTags.map(cstag => [cstag.key, cstag.value])
.filter(kv => (kv[1] ?? "") !== "")
.map(kv => `<tag k="${kv[0]}" v="${escapeHtml(kv[1])}"/>`)
const metadata = changesetTags
.map((cstag) => [cstag.key, cstag.value])
.filter((kv) => (kv[1] ?? "") !== "")
.map((kv) => `<tag k="${kv[0]}" v="${escapeHtml(kv[1])}"/>`)
.join("\n")
self.auth.xhr({
method: 'PUT',
path: '/api/0.6/changeset/create',
options: {header: {'Content-Type': 'text/xml'}},
content: [`<osm><changeset>`,
metadata,
`</changeset></osm>`].join("")
}, function (err, response) {
if (response === undefined) {
console.error("Opening a changeset failed:", err);
reject(err)
} else {
resolve(Number(response));
self.auth.xhr(
{
method: "PUT",
path: "/api/0.6/changeset/create",
options: { header: { "Content-Type": "text/xml" } },
content: [`<osm><changeset>`, metadata, `</changeset></osm>`].join(""),
},
function (err, response) {
if (response === undefined) {
console.error("Opening a changeset failed:", err)
reject(err)
} else {
resolve(Number(response))
}
}
});
)
})
}
/**
* Upload a changesetXML
*/
private UploadChange(changesetId: number,
changesetXML: string): Promise<Map<string, string>> {
const self = this;
private UploadChange(changesetId: number, changesetXML: string): Promise<Map<string, string>> {
const self = this
return new Promise(function (resolve, reject) {
self.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.error("Uploading an actual change failed", err);
reject(err);
self.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.error("Uploading an actual change failed", err)
reject(err)
}
const changes = self.parseUploadChangesetResponse(response)
console.log("Uploaded changeset ", changesetId)
resolve(changes)
}
const changes = self.parseUploadChangesetResponse(response);
console.log("Uploaded changeset ", changesetId);
resolve(changes);
});
)
})
}
}

View file

@ -1,23 +1,27 @@
import State from "../../State";
import {Utils} from "../../Utils";
import {BBox} from "../BBox";
import State from "../../State"
import { Utils } from "../../Utils"
import { BBox } from "../BBox"
export interface GeoCodeResult {
display_name: string,
lat: number, lon: number, boundingbox: number[],
osm_type: "node" | "way" | "relation",
display_name: string
lat: number
lon: number
boundingbox: number[]
osm_type: "node" | "way" | "relation"
osm_id: string
}
export class Geocoding {
private static readonly host = "https://nominatim.openstreetmap.org/search?";
private static readonly host = "https://nominatim.openstreetmap.org/search?"
static async Search(query: string): Promise<GeoCodeResult[]> {
const b = State?.state?.currentBounds?.data ?? BBox.global;
const url = Geocoding.host + "format=json&limit=1&viewbox=" +
const b = State?.state?.currentBounds?.data ?? BBox.global
const url =
Geocoding.host +
"format=json&limit=1&viewbox=" +
`${b.getEast()},${b.getNorth()},${b.getWest()},${b.getSouth()}` +
"&accept-language=nl&q=" + query;
return Utils.downloadJson(url)
"&accept-language=nl&q=" +
query
return Utils.downloadJson(url)
}
}

View file

@ -1,153 +1,161 @@
import osmAuth from "osm-auth";
import {Store, Stores, UIEventSource} from "../UIEventSource";
import {OsmPreferences} from "./OsmPreferences";
import {ChangesetHandler} from "./ChangesetHandler";
import {ElementStorage} from "../ElementStorage";
import Svg from "../../Svg";
import Img from "../../UI/Base/Img";
import {Utils} from "../../Utils";
import {OsmObject} from "./OsmObject";
import {Changes} from "./Changes";
import osmAuth from "osm-auth"
import { Store, Stores, UIEventSource } from "../UIEventSource"
import { OsmPreferences } from "./OsmPreferences"
import { ChangesetHandler } from "./ChangesetHandler"
import { ElementStorage } from "../ElementStorage"
import Svg from "../../Svg"
import Img from "../../UI/Base/Img"
import { Utils } from "../../Utils"
import { OsmObject } from "./OsmObject"
import { Changes } from "./Changes"
export default class UserDetails {
public loggedIn = false;
public name = "Not logged in";
public uid: number;
public csCount = 0;
public img: string;
public unreadMessages = 0;
public totalMessages = 0;
home: { lon: number; lat: number };
public backend: string;
public loggedIn = false
public name = "Not logged in"
public uid: number
public csCount = 0
public img: string
public unreadMessages = 0
public totalMessages = 0
home: { lon: number; lat: number }
public backend: string
constructor(backend: string) {
this.backend = backend;
this.backend = backend
}
}
export class OsmConnection {
public static readonly oauth_configs = {
"osm": {
oauth_consumer_key: 'hivV7ec2o49Two8g9h8Is1VIiVOgxQ1iYexCbvem',
oauth_secret: 'wDBRTCem0vxD7txrg1y6p5r8nvmz8tAhET7zDASI',
url: "https://www.openstreetmap.org"
osm: {
oauth_consumer_key: "hivV7ec2o49Two8g9h8Is1VIiVOgxQ1iYexCbvem",
oauth_secret: "wDBRTCem0vxD7txrg1y6p5r8nvmz8tAhET7zDASI",
url: "https://www.openstreetmap.org",
},
"osm-test": {
oauth_consumer_key: 'Zgr7EoKb93uwPv2EOFkIlf3n9NLwj5wbyfjZMhz2',
oauth_secret: '3am1i1sykHDMZ66SGq4wI2Z7cJMKgzneCHp3nctn',
url: "https://master.apis.dev.openstreetmap.org"
}
oauth_consumer_key: "Zgr7EoKb93uwPv2EOFkIlf3n9NLwj5wbyfjZMhz2",
oauth_secret: "3am1i1sykHDMZ66SGq4wI2Z7cJMKgzneCHp3nctn",
url: "https://master.apis.dev.openstreetmap.org",
},
}
public auth;
public userDetails: UIEventSource<UserDetails>;
public auth
public userDetails: UIEventSource<UserDetails>
public isLoggedIn: Store<boolean>
public loadingStatus = new UIEventSource<"not-attempted" | "loading" | "error" | "logged-in">("not-attempted")
public preferencesHandler: OsmPreferences;
public loadingStatus = new UIEventSource<"not-attempted" | "loading" | "error" | "logged-in">(
"not-attempted"
)
public preferencesHandler: OsmPreferences
public readonly _oauth_config: {
oauth_consumer_key: string,
oauth_secret: string,
oauth_consumer_key: string
oauth_secret: string
url: string
};
private readonly _dryRun: UIEventSource<boolean>;
private fakeUser: boolean;
private _onLoggedIn: ((userDetails: UserDetails) => void)[] = [];
private readonly _iframeMode: Boolean | boolean;
private readonly _singlePage: boolean;
private isChecking = false;
}
private readonly _dryRun: UIEventSource<boolean>
private fakeUser: boolean
private _onLoggedIn: ((userDetails: UserDetails) => void)[] = []
private readonly _iframeMode: Boolean | boolean
private readonly _singlePage: boolean
private isChecking = false
constructor(options: {
dryRun?: UIEventSource<boolean>,
fakeUser?: false | boolean,
oauth_token?: UIEventSource<string>,
// Used to keep multiple changesets open and to write to the correct changeset
singlePage?: boolean,
osmConfiguration?: "osm" | "osm-test",
attemptLogin?: true | boolean
}
) {
this.fakeUser = options.fakeUser ?? false;
this._singlePage = options.singlePage ?? true;
this._oauth_config = OsmConnection.oauth_configs[options.osmConfiguration ?? 'osm'] ?? OsmConnection.oauth_configs.osm;
dryRun?: UIEventSource<boolean>
fakeUser?: false | boolean
oauth_token?: UIEventSource<string>
// Used to keep multiple changesets open and to write to the correct changeset
singlePage?: boolean
osmConfiguration?: "osm" | "osm-test"
attemptLogin?: true | boolean
}) {
this.fakeUser = options.fakeUser ?? false
this._singlePage = options.singlePage ?? true
this._oauth_config =
OsmConnection.oauth_configs[options.osmConfiguration ?? "osm"] ??
OsmConnection.oauth_configs.osm
console.debug("Using backend", this._oauth_config.url)
OsmObject.SetBackendUrl(this._oauth_config.url + "/")
this._iframeMode = Utils.runningFromConsole ? false : window !== window.top;
this._iframeMode = Utils.runningFromConsole ? false : window !== window.top
this.userDetails = new UIEventSource<UserDetails>(new UserDetails(this._oauth_config.url), "userDetails");
this.userDetails = new UIEventSource<UserDetails>(
new UserDetails(this._oauth_config.url),
"userDetails"
)
if (options.fakeUser) {
const ud = this.userDetails.data;
const ud = this.userDetails.data
ud.csCount = 5678
ud.loggedIn = true;
ud.loggedIn = true
ud.unreadMessages = 0
ud.name = "Fake user"
ud.totalMessages = 42;
ud.totalMessages = 42
}
const self = this;
this.isLoggedIn = this.userDetails.map(user => user.loggedIn);
this.isLoggedIn.addCallback(isLoggedIn => {
const self = this
this.isLoggedIn = this.userDetails.map((user) => user.loggedIn)
this.isLoggedIn.addCallback((isLoggedIn) => {
if (self.userDetails.data.loggedIn == false && isLoggedIn == true) {
// We have an inconsistency: the userdetails say we _didn't_ log in, but this actor says we do
// This means someone attempted to toggle this; so we attempt to login!
self.AttemptLogin()
}
});
this._dryRun = options.dryRun ?? new UIEventSource<boolean>(false);
})
this.updateAuthObject();
this._dryRun = options.dryRun ?? new UIEventSource<boolean>(false)
this.preferencesHandler = new OsmPreferences(this.auth, this);
this.updateAuthObject()
this.preferencesHandler = new OsmPreferences(this.auth, this)
if (options.oauth_token?.data !== undefined) {
console.log(options.oauth_token.data)
const self = this;
this.auth.bootstrapToken(options.oauth_token.data,
const self = this
this.auth.bootstrapToken(
options.oauth_token.data,
(x) => {
console.log("Called back: ", x)
self.AttemptLogin();
}, this.auth);
options.oauth_token.setData(undefined);
self.AttemptLogin()
},
this.auth
)
options.oauth_token.setData(undefined)
}
if (this.auth.authenticated() && (options.attemptLogin !== false)) {
this.AttemptLogin(); // Also updates the user badge
if (this.auth.authenticated() && options.attemptLogin !== false) {
this.AttemptLogin() // Also updates the user badge
} else {
console.log("Not authenticated");
console.log("Not authenticated")
}
}
public CreateChangesetHandler(allElements: ElementStorage, changes: Changes){
return new ChangesetHandler(this._dryRun, this, allElements, changes, this.auth);
public CreateChangesetHandler(allElements: ElementStorage, changes: Changes) {
return new ChangesetHandler(this._dryRun, this, allElements, changes, this.auth)
}
public GetPreference(key: string, defaultValue: string = undefined, prefix: string = "mapcomplete-"): UIEventSource<string> {
return this.preferencesHandler.GetPreference(key, defaultValue, prefix);
public GetPreference(
key: string,
defaultValue: string = undefined,
prefix: string = "mapcomplete-"
): UIEventSource<string> {
return this.preferencesHandler.GetPreference(key, defaultValue, prefix)
}
public GetLongPreference(key: string, prefix: string = "mapcomplete-"): UIEventSource<string> {
return this.preferencesHandler.GetLongPreference(key, prefix);
return this.preferencesHandler.GetLongPreference(key, prefix)
}
public OnLoggedIn(action: (userDetails: UserDetails) => void) {
this._onLoggedIn.push(action);
this._onLoggedIn.push(action)
}
public LogOut() {
this.auth.logout();
this.userDetails.data.loggedIn = false;
this.userDetails.data.csCount = 0;
this.userDetails.data.name = "";
this.userDetails.ping();
this.auth.logout()
this.userDetails.data.loggedIn = false
this.userDetails.data.csCount = 0
this.userDetails.data.name = ""
this.userDetails.ping()
console.log("Logged out")
this.loadingStatus.setData("not-attempted")
}
public Backend(): string {
return this._oauth_config.url;
return this._oauth_config.url
}
public AttemptLogin() {
@ -155,76 +163,81 @@ export class OsmConnection {
if (this.fakeUser) {
this.loadingStatus.setData("logged-in")
console.log("AttemptLogin called, but ignored as fakeUser is set")
return;
return
}
const self = this;
console.log("Trying to log in...");
this.updateAuthObject();
this.auth.xhr({
method: 'GET',
path: '/api/0.6/user/details'
}, function (err, details) {
if (err != null) {
console.log(err);
self.loadingStatus.setData("error")
if (err.status == 401) {
console.log("Clearing tokens...")
// Not authorized - our token probably got revoked
// Reset all the tokens
const tokens = [
"https://www.openstreetmap.orgoauth_request_token_secret",
"https://www.openstreetmap.orgoauth_token",
"https://www.openstreetmap.orgoauth_token_secret"]
tokens.forEach(token => localStorage.removeItem(token))
const self = this
console.log("Trying to log in...")
this.updateAuthObject()
this.auth.xhr(
{
method: "GET",
path: "/api/0.6/user/details",
},
function (err, details) {
if (err != null) {
console.log(err)
self.loadingStatus.setData("error")
if (err.status == 401) {
console.log("Clearing tokens...")
// Not authorized - our token probably got revoked
// Reset all the tokens
const tokens = [
"https://www.openstreetmap.orgoauth_request_token_secret",
"https://www.openstreetmap.orgoauth_token",
"https://www.openstreetmap.orgoauth_token_secret",
]
tokens.forEach((token) => localStorage.removeItem(token))
}
return
}
return;
if (details == null) {
self.loadingStatus.setData("error")
return
}
self.CheckForMessagesContinuously()
// details is an XML DOM of user details
let userInfo = details.getElementsByTagName("user")[0]
// let moreDetails = new DOMParser().parseFromString(userInfo.innerHTML, "text/xml");
let data = self.userDetails.data
data.loggedIn = true
console.log("Login completed, userinfo is ", userInfo)
data.name = userInfo.getAttribute("display_name")
data.uid = Number(userInfo.getAttribute("id"))
data.csCount = userInfo.getElementsByTagName("changesets")[0].getAttribute("count")
data.img = undefined
const imgEl = userInfo.getElementsByTagName("img")
if (imgEl !== undefined && imgEl[0] !== undefined) {
data.img = imgEl[0].getAttribute("href")
}
data.img = data.img ?? Img.AsData(Svg.osm_logo)
const homeEl = userInfo.getElementsByTagName("home")
if (homeEl !== undefined && homeEl[0] !== undefined) {
const lat = parseFloat(homeEl[0].getAttribute("lat"))
const lon = parseFloat(homeEl[0].getAttribute("lon"))
data.home = { lat: lat, lon: lon }
}
self.loadingStatus.setData("logged-in")
const messages = userInfo
.getElementsByTagName("messages")[0]
.getElementsByTagName("received")[0]
data.unreadMessages = parseInt(messages.getAttribute("unread"))
data.totalMessages = parseInt(messages.getAttribute("count"))
self.userDetails.ping()
for (const action of self._onLoggedIn) {
action(self.userDetails.data)
}
self._onLoggedIn = []
}
if (details == null) {
self.loadingStatus.setData("error")
return;
}
self.CheckForMessagesContinuously();
// details is an XML DOM of user details
let userInfo = details.getElementsByTagName("user")[0];
// let moreDetails = new DOMParser().parseFromString(userInfo.innerHTML, "text/xml");
let data = self.userDetails.data;
data.loggedIn = true;
console.log("Login completed, userinfo is ", userInfo);
data.name = userInfo.getAttribute('display_name');
data.uid = Number(userInfo.getAttribute("id"))
data.csCount = userInfo.getElementsByTagName("changesets")[0].getAttribute("count");
data.img = undefined;
const imgEl = userInfo.getElementsByTagName("img");
if (imgEl !== undefined && imgEl[0] !== undefined) {
data.img = imgEl[0].getAttribute("href");
}
data.img = data.img ?? Img.AsData(Svg.osm_logo);
const homeEl = userInfo.getElementsByTagName("home");
if (homeEl !== undefined && homeEl[0] !== undefined) {
const lat = parseFloat(homeEl[0].getAttribute("lat"));
const lon = parseFloat(homeEl[0].getAttribute("lon"));
data.home = {lat: lat, lon: lon};
}
self.loadingStatus.setData("logged-in")
const messages = userInfo.getElementsByTagName("messages")[0].getElementsByTagName("received")[0];
data.unreadMessages = parseInt(messages.getAttribute("unread"));
data.totalMessages = parseInt(messages.getAttribute("count"));
self.userDetails.ping();
for (const action of self._onLoggedIn) {
action(self.userDetails.data);
}
self._onLoggedIn = [];
});
)
}
public closeNote(id: number | string, text?: string): Promise<void> {
@ -236,22 +249,23 @@ export class OsmConnection {
console.warn("Dryrun enabled - not actually closing note ", id, " with text ", text)
return new Promise((ok) => {
ok()
});
})
}
return new Promise((ok, error) => {
this.auth.xhr({
method: 'POST',
path: `/api/0.6/notes/${id}/close${textSuffix}`,
}, function (err, _) {
if (err !== null) {
error(err)
} else {
ok()
this.auth.xhr(
{
method: "POST",
path: `/api/0.6/notes/${id}/close${textSuffix}`,
},
function (err, _) {
if (err !== null) {
error(err)
} else {
ok()
}
}
})
)
})
}
public reopenNote(id: number | string, text?: string): Promise<void> {
@ -259,110 +273,118 @@ export class OsmConnection {
console.warn("Dryrun enabled - not actually reopening note ", id, " with text ", text)
return new Promise((ok) => {
ok()
});
})
}
let textSuffix = ""
if ((text ?? "") !== "") {
textSuffix = "?text=" + encodeURIComponent(text)
}
return new Promise((ok, error) => {
this.auth.xhr({
method: 'POST',
path: `/api/0.6/notes/${id}/reopen${textSuffix}`
}, function (err, _) {
if (err !== null) {
error(err)
} else {
ok()
this.auth.xhr(
{
method: "POST",
path: `/api/0.6/notes/${id}/reopen${textSuffix}`,
},
function (err, _) {
if (err !== null) {
error(err)
} else {
ok()
}
}
})
)
})
}
public openNote(lat: number, lon: number, text: string): Promise<{ id: number }> {
if (this._dryRun.data) {
console.warn("Dryrun enabled - not actually opening note with text ", text)
return new Promise<{ id: number }>((ok) => {
window.setTimeout(() => ok({id: Math.floor(Math.random() * 1000)}), Math.random() * 5000)
});
}
const auth = this.auth;
const content = {lat, lon, text}
return new Promise((ok, error) => {
auth.xhr({
method: 'POST',
path: `/api/0.6/notes.json`,
options: {
header:
{'Content-Type': 'application/json'}
},
content: JSON.stringify(content)
}, function (
err,
response: string) {
console.log("RESPONSE IS", response)
if (err !== null) {
error(err)
} else {
const parsed = JSON.parse(response)
const id = parsed.properties.id
console.log("OPENED NOTE", id)
ok({id})
}
window.setTimeout(
() => ok({ id: Math.floor(Math.random() * 1000) }),
Math.random() * 5000
)
})
}
const auth = this.auth
const content = { lat, lon, text }
return new Promise((ok, error) => {
auth.xhr(
{
method: "POST",
path: `/api/0.6/notes.json`,
options: {
header: { "Content-Type": "application/json" },
},
content: JSON.stringify(content),
},
function (err, response: string) {
console.log("RESPONSE IS", response)
if (err !== null) {
error(err)
} else {
const parsed = JSON.parse(response)
const id = parsed.properties.id
console.log("OPENED NOTE", id)
ok({ id })
}
}
)
})
}
public addCommentToNote(id: number | string, text: string): Promise<void> {
if (this._dryRun.data) {
console.warn("Dryrun enabled - not actually adding comment ", text, "to note ", id)
return new Promise((ok) => {
ok()
});
})
}
if ((text ?? "") === "") {
throw "Invalid text!"
}
return new Promise((ok, error) => {
this.auth.xhr({
method: 'POST',
this.auth.xhr(
{
method: "POST",
path: `/api/0.6/notes/${id}/comment?text=${encodeURIComponent(text)}`
}, function (err, _) {
if (err !== null) {
error(err)
} else {
ok()
path: `/api/0.6/notes/${id}/comment?text=${encodeURIComponent(text)}`,
},
function (err, _) {
if (err !== null) {
error(err)
} else {
ok()
}
}
})
)
})
}
private updateAuthObject() {
let pwaStandAloneMode = false;
let pwaStandAloneMode = false
try {
if (Utils.runningFromConsole) {
pwaStandAloneMode = true
} else if (window.matchMedia('(display-mode: standalone)').matches || window.matchMedia('(display-mode: fullscreen)').matches) {
pwaStandAloneMode = true;
} else if (
window.matchMedia("(display-mode: standalone)").matches ||
window.matchMedia("(display-mode: fullscreen)").matches
) {
pwaStandAloneMode = true
}
} catch (e) {
console.warn("Detecting standalone mode failed", e, ". Assuming in browser and not worrying furhter")
console.warn(
"Detecting standalone mode failed",
e,
". Assuming in browser and not worrying furhter"
)
}
const standalone = this._iframeMode || pwaStandAloneMode || !this._singlePage;
const standalone = this._iframeMode || pwaStandAloneMode || !this._singlePage
// In standalone mode, we DON'T use single page login, as 'redirecting' opens a new window anyway...
// Same for an iframe...
this.auth = new osmAuth({
oauth_consumer_key: this._oauth_config.oauth_consumer_key,
oauth_secret: this._oauth_config.oauth_secret,
@ -370,22 +392,20 @@ export class OsmConnection {
landing: standalone ? undefined : window.location.href,
singlepage: !standalone,
auto: true,
});
})
}
private CheckForMessagesContinuously() {
const self = this;
const self = this
if (this.isChecking) {
return;
return
}
this.isChecking = true;
Stores.Chronic(5 * 60 * 1000).addCallback(_ => {
this.isChecking = true
Stores.Chronic(5 * 60 * 1000).addCallback((_) => {
if (self.isLoggedIn.data) {
console.log("Checking for messages")
self.AttemptLogin();
self.AttemptLogin()
}
});
})
}
}
}

View file

@ -1,32 +1,32 @@
import {Utils} from "../../Utils";
import * as polygon_features from "../../assets/polygon-features.json";
import {Store, UIEventSource} from "../UIEventSource";
import {BBox} from "../BBox";
import * as OsmToGeoJson from "osmtogeojson";
import { Utils } from "../../Utils"
import * as polygon_features from "../../assets/polygon-features.json"
import { Store, UIEventSource } from "../UIEventSource"
import { BBox } from "../BBox"
import * as OsmToGeoJson from "osmtogeojson"
import { NodeId, OsmFeature, OsmId, OsmTags, RelationId, WayId } from "../../Models/OsmFeature"
export abstract class OsmObject {
private static defaultBackend = "https://www.openstreetmap.org/"
protected static backendURL = OsmObject.defaultBackend;
protected static backendURL = OsmObject.defaultBackend
private static polygonFeatures = OsmObject.constructPolygonFeatures()
private static objectCache = new Map<string, UIEventSource<OsmObject>>();
private static historyCache = new Map<string, UIEventSource<OsmObject[]>>();
type: "node" | "way" | "relation";
id: number;
private static objectCache = new Map<string, UIEventSource<OsmObject>>()
private static historyCache = new Map<string, UIEventSource<OsmObject[]>>()
type: "node" | "way" | "relation"
id: number
/**
* The OSM tags as simple object
*/
tags: {} = {};
version: number;
public changed: boolean = false;
timestamp: Date;
tags: OsmTags
version: number
public changed: boolean = false
timestamp: Date
protected constructor(type: string, id: number) {
this.id = id;
this.id = id
// @ts-ignore
this.type = type;
this.type = type
this.tags = {
id: `${this.type}/${id}`
id: `${this.type}/${id}`,
}
}
@ -37,58 +37,63 @@ export abstract class OsmObject {
if (!url.startsWith("http")) {
throw "Backend URL must begin with http"
}
this.backendURL = url;
this.backendURL = url
}
public static DownloadObject(id: string, forceRefresh: boolean = false): Store<OsmObject> {
let src: UIEventSource<OsmObject>;
let src: UIEventSource<OsmObject>
if (OsmObject.objectCache.has(id)) {
src = OsmObject.objectCache.get(id)
if (forceRefresh) {
src.setData(undefined)
} else {
return src;
return src
}
} else {
src = UIEventSource.FromPromise(OsmObject.DownloadObjectAsync(id))
}
OsmObject.objectCache.set(id, src);
return src;
OsmObject.objectCache.set(id, src)
return src
}
static async DownloadPropertiesOf(id: string): Promise<any> {
const splitted = id.split("/");
const idN = Number(splitted[1]);
const splitted = id.split("/")
const idN = Number(splitted[1])
if (idN < 0) {
return undefined;
return undefined
}
const url = `${OsmObject.backendURL}api/0.6/${id}`;
const url = `${OsmObject.backendURL}api/0.6/${id}`
const rawData = await Utils.downloadJsonCached(url, 1000)
return rawData.elements[0].tags
}
static async DownloadObjectAsync(id: NodeId): Promise<OsmNode | undefined>
static async DownloadObjectAsync(id: WayId): Promise<OsmWay | undefined>
static async DownloadObjectAsync(id: RelationId): Promise<OsmRelation | undefined>
static async DownloadObjectAsync(id: OsmId): Promise<OsmObject | undefined>
static async DownloadObjectAsync(id: string): Promise<OsmObject | undefined>
static async DownloadObjectAsync(id: string): Promise<OsmObject | undefined> {
const splitted = id.split("/");
const type = splitted[0];
const idN = Number(splitted[1]);
const splitted = id.split("/")
const type = splitted[0]
const idN = Number(splitted[1])
if (idN < 0) {
return undefined;
return undefined
}
const full = (!id.startsWith("node")) ? "/full" : "";
const url = `${OsmObject.backendURL}api/0.6/${id}${full}`;
const full = !id.startsWith("node") ? "/full" : ""
const url = `${OsmObject.backendURL}api/0.6/${id}${full}`
const rawData = await Utils.downloadJsonCached(url, 10000)
if (rawData === undefined) {
return undefined
}
// A full query might contain more then just the requested object (e.g. nodes that are part of a way, where we only want the way)
const parsed = OsmObject.ParseObjects(rawData.elements);
const parsed = OsmObject.ParseObjects(rawData.elements)
// Lets fetch the object we need
for (const osmObject of parsed) {
if (osmObject.type !== type) {
continue;
continue
}
if (osmObject.id !== idN) {
continue
@ -97,25 +102,23 @@ export abstract class OsmObject {
return osmObject
}
throw "PANIC: requested object is not part of the response"
}
/**
* Downloads the ways that are using this node.
* Beware: their geometry will be incomplete!
*/
public static DownloadReferencingWays(id: string): Promise<OsmWay[]> {
return Utils.downloadJsonCached(`${OsmObject.backendURL}api/0.6/${id}/ways`, 60 * 1000).then(
data => {
return data.elements.map(wayInfo => {
const way = new OsmWay(wayInfo.id)
way.LoadData(wayInfo)
return way
})
}
)
return Utils.downloadJsonCached(
`${OsmObject.backendURL}api/0.6/${id}/ways`,
60 * 1000
).then((data) => {
return data.elements.map((wayInfo) => {
const way = new OsmWay(wayInfo.id)
way.LoadData(wayInfo)
return way
})
})
}
/**
@ -123,8 +126,11 @@ export abstract class OsmObject {
* Beware: their geometry will be incomplete!
*/
public static async DownloadReferencingRelations(id: string): Promise<OsmRelation[]> {
const data = await Utils.downloadJsonCached(`${OsmObject.backendURL}api/0.6/${id}/relations`, 60 * 1000)
return data.elements.map(wayInfo => {
const data = await Utils.downloadJsonCached(
`${OsmObject.backendURL}api/0.6/${id}/relations`,
60 * 1000
)
return data.elements.map((wayInfo) => {
const rel = new OsmRelation(wayInfo.id)
rel.LoadData(wayInfo)
rel.SaveExtraData(wayInfo, undefined)
@ -132,78 +138,85 @@ export abstract class OsmObject {
})
}
public static DownloadHistory(id: string): UIEventSource<OsmObject []> {
public static DownloadHistory(id: string): UIEventSource<OsmObject[]> {
if (OsmObject.historyCache.has(id)) {
return OsmObject.historyCache.get(id)
}
const splitted = id.split("/");
const type = splitted[0];
const idN = Number(splitted[1]);
const src = new UIEventSource<OsmObject[]>([]);
OsmObject.historyCache.set(id, src);
Utils.downloadJsonCached(`${OsmObject.backendURL}api/0.6/${type}/${idN}/history`, 10 * 60 * 1000).then(data => {
const elements: any[] = data.elements;
const splitted = id.split("/")
const type = splitted[0]
const idN = Number(splitted[1])
const src = new UIEventSource<OsmObject[]>([])
OsmObject.historyCache.set(id, src)
Utils.downloadJsonCached(
`${OsmObject.backendURL}api/0.6/${type}/${idN}/history`,
10 * 60 * 1000
).then((data) => {
const elements: any[] = data.elements
const osmObjects: OsmObject[] = []
for (const element of elements) {
let osmObject: OsmObject = null
switch (type) {
case("node"):
osmObject = new OsmNode(idN);
break;
case("way"):
osmObject = new OsmWay(idN);
break;
case("relation"):
osmObject = new OsmRelation(idN);
break;
case "node":
osmObject = new OsmNode(idN)
break
case "way":
osmObject = new OsmWay(idN)
break
case "relation":
osmObject = new OsmRelation(idN)
break
}
osmObject?.LoadData(element);
osmObject?.SaveExtraData(element, []);
osmObject?.LoadData(element)
osmObject?.SaveExtraData(element, [])
osmObjects.push(osmObject)
}
src.setData(osmObjects)
})
return src;
return src
}
// bounds should be: [[maxlat, minlon], [minlat, maxlon]] (same as Utils.tile_bounds)
public static async LoadArea(bbox: BBox): Promise<OsmObject[]> {
const url = `${OsmObject.backendURL}api/0.6/map.json?bbox=${bbox.minLon},${bbox.minLat},${bbox.maxLon},${bbox.maxLat}`
const data = await Utils.downloadJson(url)
const elements: any[] = data.elements;
return OsmObject.ParseObjects(elements);
const elements: any[] = data.elements
return OsmObject.ParseObjects(elements)
}
public static ParseObjects(elements: any[]): OsmObject[] {
const objects: OsmObject[] = [];
const objects: OsmObject[] = []
const allNodes: Map<number, OsmNode> = new Map<number, OsmNode>()
for (const element of elements) {
const type = element.type;
const idN = element.id;
const type = element.type
const idN = element.id
let osmObject: OsmObject = null
switch (type) {
case("node"):
const node = new OsmNode(idN);
allNodes.set(idN, node);
case "node":
const node = new OsmNode(idN)
allNodes.set(idN, node)
osmObject = node
node.SaveExtraData(element);
break;
case("way"):
osmObject = new OsmWay(idN);
const nodes = element.nodes.map(i => allNodes.get(i));
node.SaveExtraData(element)
break
case "way":
osmObject = new OsmWay(idN)
const nodes = element.nodes.map((i) => allNodes.get(i))
osmObject.SaveExtraData(element, nodes)
break;
case("relation"):
osmObject = new OsmRelation(idN);
const allGeojsons = OsmToGeoJson.default({elements},
break
case "relation":
osmObject = new OsmRelation(idN)
const allGeojsons = OsmToGeoJson.default(
{ elements },
// @ts-ignore
{
flatProperties: true
});
const feature = allGeojsons.features.find(f => f.id === osmObject.type + "/" + osmObject.id)
flatProperties: true,
}
)
const feature = allGeojsons.features.find(
(f) => f.id === osmObject.type + "/" + osmObject.id
)
osmObject.SaveExtraData(element, feature)
break;
break
}
if (osmObject !== undefined && OsmObject.backendURL !== OsmObject.defaultBackend) {
@ -213,12 +226,12 @@ export abstract class OsmObject {
osmObject?.LoadData(element)
objects.push(osmObject)
}
return objects;
return objects
}
/**
* Uses the list of polygon features to determine if the given tags are a polygon or not.
*
*
* OsmObject.isPolygon({"building":"yes"}) // => true
* OsmObject.isPolygon({"highway":"residential"}) // => false
* */
@ -227,11 +240,12 @@ export abstract class OsmObject {
if (!tags.hasOwnProperty(tagsKey)) {
continue
}
const polyGuide: { values: Set<string>; blacklist: boolean } = OsmObject.polygonFeatures.get(tagsKey)
const polyGuide: { values: Set<string>; blacklist: boolean } =
OsmObject.polygonFeatures.get(tagsKey)
if (polyGuide === undefined) {
continue
}
if ((polyGuide.values === null)) {
if (polyGuide.values === null) {
// .values is null, thus merely _having_ this key is enough to be a polygon (or if blacklist, being a line)
return !polyGuide.blacklist
}
@ -243,156 +257,178 @@ export abstract class OsmObject {
return doesMatch
}
return false;
return false
}
private static constructPolygonFeatures(): Map<string, { values: Set<string>, blacklist: boolean }> {
const result = new Map<string, { values: Set<string>, blacklist: boolean }>();
for (const polygonFeature of (polygon_features["default"] ?? polygon_features)) {
const key = polygonFeature.key;
private static constructPolygonFeatures(): Map<
string,
{ values: Set<string>; blacklist: boolean }
> {
const result = new Map<string, { values: Set<string>; blacklist: boolean }>()
for (const polygonFeature of polygon_features["default"] ?? polygon_features) {
const key = polygonFeature.key
if (polygonFeature.polygon === "all") {
result.set(key, {values: null, blacklist: false})
result.set(key, { values: null, blacklist: false })
continue
}
const blacklist = polygonFeature.polygon === "blacklist"
result.set(key, {values: new Set<string>(polygonFeature.values), blacklist: blacklist})
result.set(key, {
values: new Set<string>(polygonFeature.values),
blacklist: blacklist,
})
}
return result;
return result
}
// The centerpoint of the feature, as [lat, lon]
public abstract centerpoint(): [number, number];
public abstract centerpoint(): [number, number]
public abstract asGeoJson(): any;
public abstract asGeoJson(): any
abstract SaveExtraData(element: any, allElements: OsmObject[] | any);
abstract SaveExtraData(element: any, allElements: OsmObject[] | any)
/**
* Generates the changeset-XML for tags
* @constructor
*/
TagsXML(): string {
let tags = "";
let tags = ""
for (const key in this.tags) {
if (key.startsWith("_")) {
continue;
continue
}
if (key === "id") {
continue;
continue
}
const v = this.tags[key];
const v = this.tags[key]
if (v !== "" && v !== undefined) {
tags += ' <tag k="' + Utils.EncodeXmlValue(key) + '" v="' + Utils.EncodeXmlValue(this.tags[key]) + '"/>\n'
tags +=
' <tag k="' +
Utils.EncodeXmlValue(key) +
'" v="' +
Utils.EncodeXmlValue(this.tags[key]) +
'"/>\n'
}
}
return tags;
return tags
}
abstract ChangesetXML(changesetId: string): string;
abstract ChangesetXML(changesetId: string): string
protected VersionXML() {
if (this.version === undefined) {
return "";
return ""
}
return 'version="' + this.version + '"';
return 'version="' + this.version + '"'
}
private LoadData(element: any): void {
this.tags = element.tags ?? this.tags;
this.version = element.version;
this.timestamp = element.timestamp;
const tgs = this.tags;
this.tags = element.tags ?? this.tags
this.version = element.version
this.timestamp = element.timestamp
const tgs = this.tags
if (element.tags === undefined) {
// Simple node which is part of a way - not important
return;
return
}
tgs["_last_edit:contributor"] = element.user
tgs["_last_edit:contributor:uid"] = element.uid
tgs["_last_edit:changeset"] = element.changeset
tgs["_last_edit:timestamp"] = element.timestamp
tgs["_version_number"] = element.version
tgs["id"] = this.type + "/" + this.id;
tgs["id"] = <OsmId>(this.type + "/" + this.id)
}
}
export class OsmNode extends OsmObject {
lat: number;
lon: number;
lat: number
lon: number
constructor(id: number) {
super("node", id);
super("node", id)
}
ChangesetXML(changesetId: string): string {
let tags = this.TagsXML();
let tags = this.TagsXML()
return ' <node id="' + this.id + '" changeset="' + changesetId + '" ' + this.VersionXML() + ' lat="' + this.lat + '" lon="' + this.lon + '">\n' +
return (
' <node id="' +
this.id +
'" changeset="' +
changesetId +
'" ' +
this.VersionXML() +
' lat="' +
this.lat +
'" lon="' +
this.lon +
'">\n' +
tags +
' </node>\n';
" </node>\n"
)
}
SaveExtraData(element) {
this.lat = element.lat;
this.lon = element.lon;
this.lat = element.lat
this.lon = element.lon
}
centerpoint(): [number, number] {
return [this.lat, this.lon];
return [this.lat, this.lon]
}
asGeoJson() {
asGeoJson(): OsmFeature {
return {
"type": "Feature",
"properties": this.tags,
"geometry": {
"type": "Point",
"coordinates": [
this.lon,
this.lat
]
}
type: "Feature",
properties: this.tags,
geometry: {
type: "Point",
coordinates: [this.lon, this.lat],
},
}
}
}
export class OsmWay extends OsmObject {
nodes: number[] = [];
nodes: number[] = []
// The coordinates of the way, [lat, lon][]
coordinates: [number, number][] = []
lat: number;
lon: number;
lat: number
lon: number
constructor(id: number) {
super("way", id);
super("way", id)
}
centerpoint(): [number, number] {
return [this.lat, this.lon];
return [this.lat, this.lon]
}
ChangesetXML(changesetId: string): string {
let tags = this.TagsXML();
let nds = "";
let tags = this.TagsXML()
let nds = ""
for (const node in this.nodes) {
nds += ' <nd ref="' + this.nodes[node] + '"/>\n';
nds += ' <nd ref="' + this.nodes[node] + '"/>\n'
}
return ' <way id="' + this.id + '" changeset="' + changesetId + '" ' + this.VersionXML() + '>\n' +
return (
' <way id="' +
this.id +
'" changeset="' +
changesetId +
'" ' +
this.VersionXML() +
">\n" +
nds +
tags +
' </way>\n';
" </way>\n"
)
}
SaveExtraData(element, allNodes: OsmNode[]) {
let latSum = 0
let lonSum = 0
@ -410,88 +446,96 @@ export class OsmWay extends OsmObject {
if (node === undefined) {
console.error("Error: node ", nodeId, "not found in ", nodeDict)
// This is probably part of a relation which hasn't been fully downloaded
continue;
continue
}
this.coordinates.push(node.centerpoint());
this.coordinates.push(node.centerpoint())
latSum += node.lat
lonSum += node.lon
}
let count = this.coordinates.length;
this.lat = latSum / count;
this.lon = lonSum / count;
this.nodes = element.nodes;
let count = this.coordinates.length
this.lat = latSum / count
this.lon = lonSum / count
this.nodes = element.nodes
}
public asGeoJson() {
let coordinates: ([number, number][] | [number, number][][]) = this.coordinates.map(([lat, lon]) => [lon, lat]);
let coordinates: [number, number][] | [number, number][][] = this.coordinates.map(
([lat, lon]) => [lon, lat]
)
if (this.isPolygon()) {
coordinates = [coordinates]
}
return {
"type": "Feature",
"properties": this.tags,
"geometry": {
"type": this.isPolygon() ? "Polygon" : "LineString",
"coordinates": coordinates
}
type: "Feature",
properties: this.tags,
geometry: {
type: this.isPolygon() ? "Polygon" : "LineString",
coordinates: coordinates,
},
}
}
private isPolygon(): boolean {
// Compare lat and lon seperately, as the coordinate array might not be a reference to the same object
if (this.coordinates[0][0] !== this.coordinates[this.coordinates.length - 1][0] ||
this.coordinates[0][1] !== this.coordinates[this.coordinates.length - 1][1]) {
return false; // Not closed
if (
this.coordinates[0][0] !== this.coordinates[this.coordinates.length - 1][0] ||
this.coordinates[0][1] !== this.coordinates[this.coordinates.length - 1][1]
) {
return false // Not closed
}
return OsmObject.isPolygon(this.tags)
}
}
export class OsmRelation extends OsmObject {
public members: {
type: "node" | "way" | "relation",
ref: number,
type: "node" | "way" | "relation"
ref: number
role: string
}[];
}[]
private geojson = undefined
constructor(id: number) {
super("relation", id);
super("relation", id)
}
centerpoint(): [number, number] {
return [0, 0]; // TODO
return [0, 0] // TODO
}
ChangesetXML(changesetId: string): string {
let members = "";
let members = ""
for (const member of this.members) {
members += ' <member type="' + member.type + '" ref="' + member.ref + '" role="' + member.role + '"/>\n';
members +=
' <member type="' +
member.type +
'" ref="' +
member.ref +
'" role="' +
member.role +
'"/>\n'
}
let tags = this.TagsXML();
let tags = this.TagsXML()
let cs = ""
if (changesetId !== undefined) {
cs = `changeset="${changesetId}"`
}
return ` <relation id="${this.id}" ${cs} ${this.VersionXML()}>
${members}${tags} </relation>
`;
`
}
SaveExtraData(element, geojson) {
this.members = element.members;
this.members = element.members
this.geojson = geojson
}
asGeoJson(): any {
if (this.geojson !== undefined) {
return this.geojson;
return this.geojson
}
throw "Not Implemented"
}
}
}

View file

@ -1,22 +1,21 @@
import {UIEventSource} from "../UIEventSource";
import UserDetails, {OsmConnection} from "./OsmConnection";
import {Utils} from "../../Utils";
import {DomEvent} from "leaflet";
import preventDefault = DomEvent.preventDefault;
import { UIEventSource } from "../UIEventSource"
import UserDetails, { OsmConnection } from "./OsmConnection"
import { Utils } from "../../Utils"
import { DomEvent } from "leaflet"
import preventDefault = DomEvent.preventDefault
export class OsmPreferences {
public preferences = new UIEventSource<Record<string, string>>({}, "all-osm-preferences");
public preferences = new UIEventSource<Record<string, string>>({}, "all-osm-preferences")
private readonly preferenceSources = new Map<string, UIEventSource<string>>()
private auth: any;
private userDetails: UIEventSource<UserDetails>;
private longPreferences = {};
private auth: any
private userDetails: UIEventSource<UserDetails>
private longPreferences = {}
constructor(auth, osmConnection: OsmConnection) {
this.auth = auth;
this.userDetails = osmConnection.userDetails;
const self = this;
osmConnection.OnLoggedIn(() => self.UpdatePreferences());
this.auth = auth
this.userDetails = osmConnection.userDetails
const self = this
osmConnection.OnLoggedIn(() => self.UpdatePreferences())
}
/**
@ -26,42 +25,44 @@ export class OsmPreferences {
* @constructor
*/
public GetLongPreference(key: string, prefix: string = "mapcomplete-"): UIEventSource<string> {
if (this.longPreferences[prefix + key] !== undefined) {
return this.longPreferences[prefix + key];
return this.longPreferences[prefix + key]
}
const source = new UIEventSource<string>(undefined, "long-osm-preference:" + prefix + key)
this.longPreferences[prefix + key] = source
const source = new UIEventSource<string>(undefined, "long-osm-preference:" + prefix + key);
this.longPreferences[prefix + key] = source;
const allStartWith = prefix + key + "-combined";
const allStartWith = prefix + key + "-combined"
// Gives the number of combined preferences
const length = this.GetPreference(allStartWith + "-length", "", "");
const length = this.GetPreference(allStartWith + "-length", "", "")
if( (allStartWith + "-length").length > 255){
throw "This preference key is too long, it has "+key.length+" characters, but at most "+(255 - "-length".length - "-combined".length - prefix.length)+" characters are allowed"
}
if ((allStartWith + "-length").length > 255) {
throw (
"This preference key is too long, it has " +
key.length +
" characters, but at most " +
(255 - "-length".length - "-combined".length - prefix.length) +
" characters are allowed"
)
}
const self = this;
source.addCallback(str => {
const self = this
source.addCallback((str) => {
if (str === undefined || str === "") {
return;
return
}
if (str === null) {
console.error("Deleting " + allStartWith);
let count = parseInt(length.data);
console.error("Deleting " + allStartWith)
let count = parseInt(length.data)
for (let i = 0; i < count; i++) {
// Delete all the preferences
self.GetPreference(allStartWith + "-" + i, "", "")
.setData("");
self.GetPreference(allStartWith + "-" + i, "", "").setData("")
}
self.GetPreference(allStartWith + "-length", "", "")
.setData("");
self.GetPreference(allStartWith + "-length", "", "").setData("")
return
}
let i = 0;
let i = 0
while (str !== "") {
if (str === undefined || str === "undefined") {
throw "Long pref became undefined?"
@ -69,79 +70,91 @@ export class OsmPreferences {
if (i > 100) {
throw "This long preference is getting very long... "
}
self.GetPreference(allStartWith + "-" + i, "","").setData(str.substr(0, 255));
str = str.substr(255);
i++;
self.GetPreference(allStartWith + "-" + i, "", "").setData(str.substr(0, 255))
str = str.substr(255)
i++
}
length.setData("" + i); // We use I, the number of preference fields used
});
function updateData(l: number) {
if(Object.keys(self.preferences.data).length === 0){
// The preferences are still empty - they are not yet updated, so we delay updating for now
return
}
const prefsCount = Number(l);
if (prefsCount > 100) {
throw "Length to long";
}
let str = "";
for (let i = 0; i < prefsCount; i++) {
const key = allStartWith + "-" + i
if(self.preferences.data[key] === undefined){
console.warn("Detected a broken combined preference:", key, "is undefined", self.preferences)
}
str += self.preferences.data[key] ?? "";
}
source.setData(str);
}
length.addCallback(l => {
updateData(Number(l));
});
this.preferences.addCallbackAndRun(_ => {
updateData(Number(length.data));
length.setData("" + i) // We use I, the number of preference fields used
})
return source;
function updateData(l: number) {
if (Object.keys(self.preferences.data).length === 0) {
// The preferences are still empty - they are not yet updated, so we delay updating for now
return
}
const prefsCount = Number(l)
if (prefsCount > 100) {
throw "Length to long"
}
let str = ""
for (let i = 0; i < prefsCount; i++) {
const key = allStartWith + "-" + i
if (self.preferences.data[key] === undefined) {
console.warn(
"Detected a broken combined preference:",
key,
"is undefined",
self.preferences
)
}
str += self.preferences.data[key] ?? ""
}
source.setData(str)
}
length.addCallback((l) => {
updateData(Number(l))
})
this.preferences.addCallbackAndRun((_) => {
updateData(Number(length.data))
})
return source
}
public GetPreference(key: string, defaultValue : string = undefined, prefix: string = "mapcomplete-"): UIEventSource<string> {
if(key.startsWith(prefix) && prefix !== ""){
console.trace("A preference was requested which has a duplicate prefix in its key. This is probably a bug")
public GetPreference(
key: string,
defaultValue: string = undefined,
prefix: string = "mapcomplete-"
): UIEventSource<string> {
if (key.startsWith(prefix) && prefix !== "") {
console.trace(
"A preference was requested which has a duplicate prefix in its key. This is probably a bug"
)
}
key = prefix + key;
key = key.replace(/[:\\\/"' {}.%]/g, '')
key = prefix + key
key = key.replace(/[:\\\/"' {}.%]/g, "")
if (key.length >= 255) {
throw "Preferences: key length to big";
throw "Preferences: key length to big"
}
const cached = this.preferenceSources.get(key)
if (cached !== undefined) {
return cached;
return cached
}
if (this.userDetails.data.loggedIn && this.preferences.data[key] === undefined) {
this.UpdatePreferences();
this.UpdatePreferences()
}
const pref = new UIEventSource<string>(this.preferences.data[key] ?? defaultValue, "osm-preference:" + key);
pref.addCallback((v) => {
this.UploadPreference(key, v);
});
const pref = new UIEventSource<string>(
this.preferences.data[key] ?? defaultValue,
"osm-preference:" + key
)
pref.addCallback((v) => {
this.UploadPreference(key, v)
})
this.preferenceSources.set(key, pref)
return pref;
return pref
}
public ClearPreferences() {
let isRunning = false;
const self = this;
this.preferences.addCallback(prefs => {
let isRunning = false
const self = this
this.preferences.addCallback((prefs) => {
console.log("Cleaning preferences...")
if (Object.keys(prefs).length == 0) {
return;
return
}
if (isRunning) {
return
@ -149,94 +162,98 @@ export class OsmPreferences {
isRunning = true
const prefixes = ["mapcomplete-"]
for (const key in prefs) {
const matches = prefixes.some(prefix => key.startsWith(prefix))
const matches = prefixes.some((prefix) => key.startsWith(prefix))
if (matches) {
console.log("Clearing ", key)
self.GetPreference(key, "", "").setData("")
}
}
isRunning = false;
return;
isRunning = false
return
})
}
private UpdatePreferences() {
const self = this;
this.auth.xhr({
method: 'GET',
path: '/api/0.6/user/preferences'
}, function (error, value: XMLDocument) {
if (error) {
console.log("Could not load preferences", error);
return;
}
const prefs = value.getElementsByTagName("preference");
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;
}
// We merge all the preferences: new keys are uploaded
// For differing values, the server overrides local changes
self.preferenceSources.forEach((preference, key) => {
const osmValue = self.preferences.data[key]
if(osmValue === undefined && preference.data !== undefined){
// OSM doesn't know this value yet
self.UploadPreference(key, preference.data)
} else {
// OSM does have a value - set it
preference.setData(osmValue)
const self = this
this.auth.xhr(
{
method: "GET",
path: "/api/0.6/user/preferences",
},
function (error, value: XMLDocument) {
if (error) {
console.log("Could not load preferences", error)
return
}
})
self.preferences.ping();
});
const prefs = value.getElementsByTagName("preference")
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
}
// We merge all the preferences: new keys are uploaded
// For differing values, the server overrides local changes
self.preferenceSources.forEach((preference, key) => {
const osmValue = self.preferences.data[key]
if (osmValue === undefined && preference.data !== undefined) {
// OSM doesn't know this value yet
self.UploadPreference(key, preference.data)
} else {
// OSM does have a value - set it
preference.setData(osmValue)
}
})
self.preferences.ping()
}
)
}
private UploadPreference(k: string, v: string) {
if (!this.userDetails.data.loggedIn) {
console.debug(`Not saving preference ${k}: user not logged in`);
return;
console.debug(`Not saving preference ${k}: user not logged in`)
return
}
if (this.preferences.data[k] === v) {
return;
return
}
console.debug("Updating preference", k, " to ", Utils.EllipsesAfter(v, 15));
console.debug("Updating preference", k, " to ", Utils.EllipsesAfter(v, 15))
if (v === undefined || v === "") {
this.auth.xhr({
method: 'DELETE',
path: '/api/0.6/user/preferences/' + encodeURIComponent(k),
options: {header: {'Content-Type': 'text/plain'}},
}, function (error) {
if (error) {
console.warn("Could not remove preference", error);
return;
this.auth.xhr(
{
method: "DELETE",
path: "/api/0.6/user/preferences/" + encodeURIComponent(k),
options: { header: { "Content-Type": "text/plain" } },
},
function (error) {
if (error) {
console.warn("Could not remove preference", error)
return
}
console.debug("Preference ", k, "removed!")
}
console.debug("Preference ", k, "removed!");
});
return;
)
return
}
this.auth.xhr({
method: 'PUT',
path: '/api/0.6/user/preferences/' + encodeURIComponent(k),
options: {header: {'Content-Type': 'text/plain'}},
content: v
}, function (error) {
if (error) {
console.warn(`Could not set preference "${k}"'`, error);
return;
this.auth.xhr(
{
method: "PUT",
path: "/api/0.6/user/preferences/" + encodeURIComponent(k),
options: { header: { "Content-Type": "text/plain" } },
content: v,
},
function (error) {
if (error) {
console.warn(`Could not set preference "${k}"'`, error)
return
}
console.debug(`Preference ${k} written!`)
}
console.debug(`Preference ${k} written!`);
});
)
}
}
}

View file

@ -1,56 +1,67 @@
import {TagsFilter} from "../Tags/TagsFilter";
import RelationsTracker from "./RelationsTracker";
import {Utils} from "../../Utils";
import {ImmutableStore, Store} from "../UIEventSource";
import {BBox} from "../BBox";
import * as osmtogeojson from "osmtogeojson";
import {FeatureCollection} from "@turf/turf";
import { TagsFilter } from "../Tags/TagsFilter"
import RelationsTracker from "./RelationsTracker"
import { Utils } from "../../Utils"
import { ImmutableStore, Store } from "../UIEventSource"
import { BBox } from "../BBox"
import * as osmtogeojson from "osmtogeojson"
import { FeatureCollection } from "@turf/turf"
/**
* Interfaces overpass to get all the latest data
*/
export class Overpass {
private _filter: TagsFilter
private readonly _interpreterUrl: string;
private readonly _timeout: Store<number>;
private readonly _extraScripts: string[];
private _includeMeta: boolean;
private _relationTracker: RelationsTracker;
private readonly _interpreterUrl: string
private readonly _timeout: Store<number>
private readonly _extraScripts: string[]
private _includeMeta: boolean
private _relationTracker: RelationsTracker
constructor(filter: TagsFilter,
extraScripts: string[],
interpreterUrl: string,
timeout?: Store<number>,
relationTracker?: RelationsTracker,
includeMeta = true) {
this._timeout = timeout ?? new ImmutableStore<number>(90);
this._interpreterUrl = interpreterUrl;
constructor(
filter: TagsFilter,
extraScripts: string[],
interpreterUrl: string,
timeout?: Store<number>,
relationTracker?: RelationsTracker,
includeMeta = true
) {
this._timeout = timeout ?? new ImmutableStore<number>(90)
this._interpreterUrl = interpreterUrl
const optimized = filter.optimize()
if(optimized === true || optimized === false){
if (optimized === true || optimized === false) {
throw "Invalid filter: optimizes to true of false"
}
this._filter = optimized
this._extraScripts = extraScripts;
this._includeMeta = includeMeta;
this._extraScripts = extraScripts
this._includeMeta = includeMeta
this._relationTracker = relationTracker
}
public async queryGeoJson(bounds: BBox): Promise<[FeatureCollection, Date]> {
const bbox = "[bbox:" + bounds.getSouth() + "," + bounds.getWest() + "," + bounds.getNorth() + "," + bounds.getEast() + "]";
const bbox =
"[bbox:" +
bounds.getSouth() +
"," +
bounds.getWest() +
"," +
bounds.getNorth() +
"," +
bounds.getEast() +
"]"
const query = this.buildScript(bbox)
return this.ExecuteQuery(query);
return this.ExecuteQuery(query)
}
public buildUrl(query: string){
public buildUrl(query: string) {
return `${this._interpreterUrl}?data=${encodeURIComponent(query)}`
}
public async ExecuteQuery(query: string):Promise<[FeatureCollection, Date]> {
const self = this;
public async ExecuteQuery(query: string): Promise<[FeatureCollection, Date]> {
const self = this
const json = await Utils.downloadJson(this.buildUrl(query))
if (json.elements.length === 0 && json.remark !== undefined) {
console.warn("Timeout or other runtime error while querying overpass", json.remark);
console.warn("Timeout or other runtime error while querying overpass", json.remark)
throw `Runtime error (timeout or similar)${json.remark}`
}
if (json.elements.length === 0) {
@ -58,77 +69,81 @@ export class Overpass {
}
self._relationTracker?.RegisterRelations(json)
const geojson = osmtogeojson.default(json);
const osmTime = new Date(json.osm3s.timestamp_osm_base);
return [<any> geojson, osmTime];
const geojson = osmtogeojson.default(json)
const osmTime = new Date(json.osm3s.timestamp_osm_base)
return [<any>geojson, osmTime]
}
/**
* Constructs the actual script to execute on Overpass
* 'PostCall' can be used to set an extra range, see 'AsOverpassTurboLink'
*
*
* import {Tag} from "../Tags/Tag";
*
*
* new Overpass(new Tag("key","value"), [], "").buildScript("{{bbox}}") // => `[out:json][timeout:90]{{bbox}};(nwr["key"="value"];);out body;out meta;>;out skel qt;`
*/
public buildScript(bbox: string, postCall: string = "", pretty = false): string {
const filters = this._filter.asOverpass()
let filter = ""
for (const filterOr of filters) {
if(pretty){
if (pretty) {
filter += " "
}
filter += 'nwr' + filterOr + postCall + ';'
if(pretty){
filter+="\n"
filter += "nwr" + filterOr + postCall + ";"
if (pretty) {
filter += "\n"
}
}
for (const extraScript of this._extraScripts) {
filter += '(' + extraScript + ');';
filter += "(" + extraScript + ");"
}
return`[out:json][timeout:${this._timeout.data}]${bbox};(${filter});out body;${this._includeMeta ? 'out meta;' : ''}>;out skel qt;`
return `[out:json][timeout:${this._timeout.data}]${bbox};(${filter});out body;${
this._includeMeta ? "out meta;" : ""
}>;out skel qt;`
}
/**
* Constructs the actual script to execute on Overpass with geocoding
* 'PostCall' can be used to set an extra range, see 'AsOverpassTurboLink'
*
*/
public buildScriptInArea(area: {osm_type: "way" | "relation", osm_id: number}, pretty = false): string {
public buildScriptInArea(
area: { osm_type: "way" | "relation"; osm_id: number },
pretty = false
): string {
const filters = this._filter.asOverpass()
let filter = ""
for (const filterOr of filters) {
if(pretty){
if (pretty) {
filter += " "
}
filter += 'nwr' + filterOr + '(area.searchArea);'
if(pretty){
filter+="\n"
filter += "nwr" + filterOr + "(area.searchArea);"
if (pretty) {
filter += "\n"
}
}
for (const extraScript of this._extraScripts) {
filter += '(' + extraScript + ');';
filter += "(" + extraScript + ");"
}
let id = area.osm_id;
if(area.osm_type === "relation"){
let id = area.osm_id
if (area.osm_type === "relation") {
id += 3600000000
}
return`[out:json][timeout:${this._timeout.data}];
return `[out:json][timeout:${this._timeout.data}];
area(id:${id})->.searchArea;
(${filter});
out body;${this._includeMeta ? 'out meta;' : ''}>;out skel qt;`
out body;${this._includeMeta ? "out meta;" : ""}>;out skel qt;`
}
public buildQuery(bbox: string) {
return this.buildUrl(this.buildScript(bbox))
}
/**
* Little helper method to quickly open overpass-turbo in the browser
*/
public static AsOverpassTurboLink(tags: TagsFilter){
public static AsOverpassTurboLink(tags: TagsFilter) {
const overpass = new Overpass(tags, [], "", undefined, undefined, false)
const script = overpass.buildScript("","({{bbox}})", true)
const script = overpass.buildScript("", "({{bbox}})", true)
const url = "http://overpass-turbo.eu/?Q="
return url + encodeURIComponent(script)
}

View file

@ -1,24 +1,25 @@
import {UIEventSource} from "../UIEventSource";
import { UIEventSource } from "../UIEventSource"
export interface Relation {
id: number,
id: number
type: "relation"
members: {
type: ("way" | "node" | "relation"),
ref: number,
type: "way" | "node" | "relation"
ref: number
role: string
}[],
tags: any,
}[]
tags: any
// Alias for tags; tags == properties
properties: any
}
export default class RelationsTracker {
public knownRelations = new UIEventSource<Map<string, { role: string; relation: Relation }[]>>(
new Map(),
"Relation memberships"
)
public knownRelations = new UIEventSource<Map<string, { role: string; relation: Relation }[]>>(new Map(), "Relation memberships");
constructor() {
}
constructor() {}
/**
* Gets an overview of the relations - except for multipolygons. We don't care about those
@ -26,8 +27,9 @@ export default class RelationsTracker {
* @constructor
*/
private static GetRelationElements(overpassJson: any): Relation[] {
const relations = overpassJson.elements
.filter(element => element.type === "relation" && element.tags.type !== "multipolygon")
const relations = overpassJson.elements.filter(
(element) => element.type === "relation" && element.tags.type !== "multipolygon"
)
for (const relation of relations) {
relation.properties = relation.tags
}
@ -45,12 +47,12 @@ export default class RelationsTracker {
*/
private UpdateMembershipTable(relations: Relation[]): void {
const memberships = this.knownRelations.data
let changed = false;
let changed = false
for (const relation of relations) {
for (const member of relation.members) {
const role = {
role: member.role,
relation: relation
relation: relation,
}
const key = member.type + "/" + member.ref
if (!memberships.has(key)) {
@ -58,19 +60,17 @@ export default class RelationsTracker {
}
const knownRelations = memberships.get(key)
const alreadyExists = knownRelations.some(knownRole => {
const alreadyExists = knownRelations.some((knownRole) => {
return knownRole.role === role.role && knownRole.relation === role.relation
})
if (!alreadyExists) {
knownRelations.push(role)
changed = true;
changed = true
}
}
}
if (changed) {
this.knownRelations.ping()
}
}
}
}

View file

@ -1,13 +1,12 @@
export default class AspectedRouting {
public readonly name: string
public readonly description: string
public readonly units: string
public readonly program: any
public constructor(program) {
this.name = program.name;
this.description = program.description;
this.name = program.name
this.description = program.description
this.units = program.unit
this.program = JSON.parse(JSON.stringify(program))
delete this.program.name
@ -20,40 +19,41 @@ export default class AspectedRouting {
*/
public static interpret(program: any, properties: any) {
if (typeof program !== "object") {
return program;
return program
}
let functionName /*: string*/ = undefined;
let functionName /*: string*/ = undefined
let functionArguments /*: any */ = undefined
let otherValues = {}
// @ts-ignore
Object.entries(program).forEach(tag => {
const [key, value] = tag;
if (key.startsWith("$")) {
functionName = key
functionArguments = value
} else {
otherValues[key] = value
}
Object.entries(program).forEach((tag) => {
const [key, value] = tag
if (key.startsWith("$")) {
functionName = key
functionArguments = value
} else {
otherValues[key] = value
}
)
})
if (functionName === undefined) {
return AspectedRouting.interpretAsDictionary(program, properties)
}
if (functionName === '$multiply') {
return AspectedRouting.multiplyScore(properties, functionArguments);
} else if (functionName === '$firstMatchOf') {
return AspectedRouting.getFirstMatchScore(properties, functionArguments);
} else if (functionName === '$min') {
return AspectedRouting.getMinValue(properties, functionArguments);
} else if (functionName === '$max') {
return AspectedRouting.getMaxValue(properties, functionArguments);
} else if (functionName === '$default') {
if (functionName === "$multiply") {
return AspectedRouting.multiplyScore(properties, functionArguments)
} else if (functionName === "$firstMatchOf") {
return AspectedRouting.getFirstMatchScore(properties, functionArguments)
} else if (functionName === "$min") {
return AspectedRouting.getMinValue(properties, functionArguments)
} else if (functionName === "$max") {
return AspectedRouting.getMaxValue(properties, functionArguments)
} else if (functionName === "$default") {
return AspectedRouting.defaultV(functionArguments, otherValues, properties)
} else {
console.error(`Error: Program ${functionName} is not implemented yet. ${JSON.stringify(program)}`);
console.error(
`Error: Program ${functionName} is not implemented yet. ${JSON.stringify(program)}`
)
}
}
@ -70,7 +70,7 @@ export default class AspectedRouting {
* surface: {
* sett : 0.9
* }
*
*
* }
*
* in combination with the tags {highway: residential},
@ -86,8 +86,8 @@ export default class AspectedRouting {
*/
private static interpretAsDictionary(program, tags) {
// @ts-ignore
return Object.entries(tags).map(tag => {
const [key, value] = tag;
return Object.entries(tags).map((tag) => {
const [key, value] = tag
const propertyValue = program[key]
if (propertyValue === undefined) {
return undefined
@ -97,7 +97,7 @@ export default class AspectedRouting {
}
// @ts-ignore
return propertyValue[value]
});
})
}
private static defaultV(subProgram, otherArgs, tags) {
@ -105,7 +105,7 @@ export default class AspectedRouting {
const normalProgram = Object.entries(otherArgs)[0][1]
const value = AspectedRouting.interpret(normalProgram, tags)
if (value !== undefined) {
return value;
return value
}
return AspectedRouting.interpret(subProgram, tags)
}
@ -121,13 +121,15 @@ export default class AspectedRouting {
let subResults: any[]
if (subprograms.length !== undefined) {
subResults = AspectedRouting.concatMap(subprograms, subprogram => AspectedRouting.interpret(subprogram, tags))
subResults = AspectedRouting.concatMap(subprograms, (subprogram) =>
AspectedRouting.interpret(subprogram, tags)
)
} else {
subResults = AspectedRouting.interpret(subprograms, tags)
}
subResults.filter(r => r !== undefined).forEach(r => number *= parseFloat(r))
return number.toFixed(2);
subResults.filter((r) => r !== undefined).forEach((r) => (number *= parseFloat(r)))
return number.toFixed(2)
}
private static getFirstMatchScore(tags, order: any) {
@ -136,12 +138,12 @@ export default class AspectedRouting {
for (let key of order) {
// @ts-ignore
for (let entry of Object.entries(JSON.parse(tags))) {
const [tagKey, value] = entry;
const [tagKey, value] = entry
if (key === tagKey) {
// We have a match... let's evaluate the subprogram
const evaluated = AspectedRouting.interpret(value, tags)
if (evaluated !== undefined) {
return evaluated;
return evaluated
}
}
}
@ -152,26 +154,30 @@ export default class AspectedRouting {
}
private static getMinValue(tags, subprogram) {
const minArr = subprogram.map(part => {
if (typeof (part) === 'object') {
const calculatedValue = this.interpret(part, tags)
return parseFloat(calculatedValue)
} else {
return parseFloat(part);
}
}).filter(v => !isNaN(v));
return Math.min(...minArr);
const minArr = subprogram
.map((part) => {
if (typeof part === "object") {
const calculatedValue = this.interpret(part, tags)
return parseFloat(calculatedValue)
} else {
return parseFloat(part)
}
})
.filter((v) => !isNaN(v))
return Math.min(...minArr)
}
private static getMaxValue(tags, subprogram) {
const maxArr = subprogram.map(part => {
if (typeof (part) === 'object') {
return parseFloat(AspectedRouting.interpret(part, tags))
} else {
return parseFloat(part);
}
}).filter(v => !isNaN(v));
return Math.max(...maxArr);
const maxArr = subprogram
.map((part) => {
if (typeof part === "object") {
return parseFloat(AspectedRouting.interpret(part, tags))
} else {
return parseFloat(part)
}
})
.filter((v) => !isNaN(v))
return Math.max(...maxArr)
}
private static concatMap(list, f): any[] {
@ -185,11 +191,10 @@ export default class AspectedRouting {
result.push(elem)
}
}
return result;
return result
}
public evaluate(properties) {
return AspectedRouting.interpret(this.program, properties)
}
}
}

View file

@ -1,107 +1,125 @@
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";
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"
export class SimpleMetaTagger {
public readonly keys: string[];
public readonly doc: string;
public readonly isLazy: boolean;
public readonly keys: string[]
public readonly doc: string
public readonly isLazy: boolean
public readonly includesDates: boolean
public readonly applyMetaTagsOnFeature: (feature: any, freshness: Date, layer: LayerConfig, state) => boolean;
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, includesDates?: boolean, isLazy?: boolean, cleanupRetagger?: boolean },
f: ((feature: any, freshness: Date, layer: LayerConfig, state) => boolean)) {
this.keys = docs.keys;
this.doc = docs.doc;
constructor(
docs: {
keys: string[]
doc: string
includesDates?: boolean
isLazy?: boolean
cleanupRetagger?: boolean
},
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;
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) {
if (!key.startsWith("_") && key.toLowerCase().indexOf("theme") < 0) {
throw `Incorrect key for a calculated meta value '${key}': it should start with underscore (_)`
}
}
}
}
}
export class CountryTagger extends SimpleMetaTagger {
private static readonly coder = new CountryCoder(Constants.countryCoderEndpoint, Utils.downloadJson);
public runningTasks: Set<any>;
private static readonly coder = new CountryCoder(
Constants.countryCoderEndpoint,
Utils.downloadJson
)
public runningTasks: Set<any>
constructor() {
const runningTasks = new Set<any>();
super
(
const runningTasks = new Set<any>()
super(
{
keys: ["_country"],
doc: "The country code of the property (with latlon2country)",
includesDates: false
includesDates: false,
},
((feature, _, __, state) => {
let centerPoint: any = GeoOperations.centerpoint(feature);
const lat = centerPoint.geometry.coordinates[1];
const lon = centerPoint.geometry.coordinates[0];
(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 => {
CountryTagger.coder
.GetCountryCodeAsync(lon, lat)
.then((countries) => {
runningTasks.delete(feature)
try {
const oldCountry = feature.properties["_country"];
feature.properties["_country"] = countries[0].trim().toLowerCase();
const oldCountry = feature.properties["_country"]
feature.properties["_country"] = countries[0].trim().toLowerCase()
if (oldCountry !== feature.properties["_country"]) {
const tagsSource = state?.allElements?.getEventSourceById(feature.properties.id);
tagsSource?.ping();
const tagsSource = state?.allElements?.getEventSourceById(
feature.properties.id
)
tagsSource?.ping()
}
} catch (e) {
console.warn(e)
}
}
).catch(_ => {
runningTasks.delete(feature)
})
return false;
})
})
.catch((_) => {
runningTasks.delete(feature)
})
return false
}
)
this.runningTasks = runningTasks;
this.runningTasks = runningTasks
}
}
export default class SimpleMetaTaggers {
public static readonly objectMetaInfo = new SimpleMetaTagger(
{
keys: ["_last_edit:contributor",
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."
"_backend",
],
doc: "Information about the last edit of this object.",
},
(feature) => {/*Note: also called by 'UpdateTagsFromOsmAPI'*/
(feature) => {
/*Note: also called by 'UpdateTagsFromOsmAPI'*/
const tgs = feature.properties;
const tgs = feature.properties
function move(src: string, target: string) {
if (tgs[src] === undefined) {
return;
return
}
tgs[target] = tgs[src]
delete tgs[src]
@ -112,7 +130,7 @@ export default class SimpleMetaTaggers {
move("changeset", "_last_edit:changeset")
move("timestamp", "_last_edit:timestamp")
move("version", "_version_number")
return true;
return true
}
)
public static country = new CountryTagger()
@ -122,32 +140,45 @@ export default class SimpleMetaTaggers {
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;
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
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({
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)"
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;
})
);
(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.",
@ -156,98 +187,101 @@ export default class SimpleMetaTaggers {
},
(feature, freshness, layer) => {
if (feature.properties._layer === layer.id) {
return false;
return false
}
feature.properties._layer = layer.id
return true;
return true
}
)
private static noBothButLeftRight = new SimpleMetaTagger(
{
keys: ["sidewalk:left", "sidewalk:right", "generic_key:left:property", "generic_key:right:property"],
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
cleanupRetagger: true,
},
((feature, state, layer) => {
if (!layer.lineRendering.some(lr => lr.leftRightSensitive)) {
return;
(feature, state, layer) => {
if (!layer.lineRendering.some((lr) => lr.leftRightSensitive)) {
return
}
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
isLazy: true,
},
(feature => {
(feature) => {
Object.defineProperty(feature.properties, "_surface", {
enumerable: false,
configurable: true,
get: () => {
const sqMeters = "" + GeoOperations.surfaceAreaInSqMeters(feature);
const sqMeters = "" + GeoOperations.surfaceAreaInSqMeters(feature)
delete feature.properties["_surface"]
feature.properties["_surface"] = sqMeters;
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;
const sqMeters = GeoOperations.surfaceAreaInSqMeters(feature)
const sqMetersHa = "" + Math.floor(sqMeters / 1000) / 10
delete feature.properties["_surface:ha"]
feature.properties["_surface:ha"] = sqMetersHa;
feature.properties["_surface:ha"] = sqMetersHa
return sqMetersHa
}
},
})
return true;
})
);
return true
}
)
private static levels = new SimpleMetaTagger(
{
doc: "Extract the 'level'-tag into a normalized, ';'-separated value",
keys: ["_level"]
keys: ["_level"],
},
((feature) => {
(feature) => {
if (feature.properties["level"] === undefined) {
return false;
return false
}
const l = feature.properties["level"]
const newValue = TagUtils.LevelsParser(l).join(";")
if(l === newValue) {
return false;
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)",
keys: ["Theme-defined keys"],
},
((feature, _, __, state) => {
const units = Utils.NoNull([].concat(...state?.layoutToUse?.layers?.map(layer => layer.units) ?? []));
(feature, _, __, state) => {
const units = Utils.NoNull(
[].concat(...(state?.layoutToUse?.layers?.map((layer) => layer.units) ?? []))
)
if (units.length == 0) {
return;
return
}
let rewritten = false;
let rewritten = false
for (const key in feature.properties) {
if (!feature.properties.hasOwnProperty(key)) {
continue;
continue
}
for (const unit of units) {
if (unit === undefined) {
@ -258,56 +292,59 @@ export default class SimpleMetaTaggers {
continue
}
if (!unit.appliesToKeys.has(key)) {
continue;
continue
}
const value = feature.properties[key]
const denom = unit.findDenomination(value, () => feature.properties["_country"])
if (denom === undefined) {
// no valid value found
break;
break
}
const [, denomination] = denom;
const defaultDenom = unit.getDefaultDenomination(() => feature.properties["_country"])
let canonical = denomination?.canonicalValue(value, defaultDenom == denomination) ?? undefined;
const [, denomination] = denom
const defaultDenom = unit.getDefaultDenomination(
() => feature.properties["_country"]
)
let canonical =
denomination?.canonicalValue(value, defaultDenom == denomination) ??
undefined
if (canonical === value) {
break;
break
}
console.log("Rewritten ", key, ` from '${value}' into '${canonical}'`)
if (canonical === undefined && !unit.eraseInvalid) {
break;
break
}
feature.properties[key] = canonical;
rewritten = true;
break;
feature.properties[key] = canonical
rewritten = true
break
}
}
return rewritten
})
}
)
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"
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",
},
(feature => {
(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;
})
return true
}
)
private static isOpen = new SimpleMetaTagger(
{
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
isLazy: true,
},
((feature, _, __, state) => {
(feature, _, __, state) => {
if (Utils.runningFromConsole) {
// We are running from console, thus probably creating a cache
// isOpen is irrelevant
@ -315,7 +352,7 @@ export default class SimpleMetaTaggers {
}
if (feature.properties.opening_hours === "24/7") {
feature.properties._isOpen = "yes"
return true;
return true
}
// _isOpen is calculated dynamically on every call
@ -325,92 +362,92 @@ export default class SimpleMetaTaggers {
get: () => {
const tags = feature.properties
if (tags.opening_hours === undefined) {
return;
return
}
if (tags._country === undefined) {
return;
return
}
try {
const [lon, lat] = GeoOperations.centerpointCoordinates(feature)
const oh = new opening_hours(tags["opening_hours"], {
lat: lat,
lon: lon,
address: {
country_code: tags._country.toLowerCase(),
state: undefined
}
}, <any>{tag_key: "opening_hours"});
const oh = new opening_hours(
tags["opening_hours"],
{
lat: lat,
lon: lon,
address: {
country_code: tags._country.toLowerCase(),
state: undefined,
},
},
<any>{ tag_key: "opening_hours" }
)
// Recalculate!
return oh.getState() ? "yes" : "no";
return oh.getState() ? "yes" : "no"
} catch (e) {
console.warn("Error while parsing opening hours of ", tags.id, e);
console.warn("Error while parsing opening hours of ", tags.id, e)
delete tags._isOpen
tags["_isOpen"] = "parse_error";
tags["_isOpen"] = "parse_error"
}
}
});
},
})
const tagsSource = state.allElements.getEventSourceById(feature.properties.id);
})
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"
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"];
(feature) => {
const tags = feature.properties
const direction = tags["camera:direction"] ?? tags["direction"]
if (direction === undefined) {
return false;
return false
}
const n = SimpleMetaTaggers.cardinalDirections[direction] ?? Number(direction);
const n = SimpleMetaTaggers.cardinalDirections[direction] ?? Number(direction)
if (isNaN(n)) {
return false;
return false
}
// The % operator has range (-360, 360). We apply a trick to get [0, 360).
const normalized = ((n % 360) + 360) % 360;
const normalized = ((n % 360) + 360) % 360
tags["_direction:numerical"] = normalized;
tags["_direction:leftright"] = normalized <= 180 ? "right" : "left";
return true;
})
tags["_direction:numerical"] = normalized
tags["_direction:leftright"] = normalized <= 180 ? "right" : "left"
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
includesDates: true,
},
(feature, freshness) => {
const now = new Date();
const now = new Date()
if (typeof freshness === "string") {
freshness = new Date(freshness)
}
function date(d: Date) {
return d.toISOString().slice(0, 10);
return d.toISOString().slice(0, 10)
}
function datetime(d: Date) {
return d.toISOString().slice(0, -5).replace("T", " ");
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;
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
}
);
)
public static metatags: SimpleMetaTagger[] = [
SimpleMetaTaggers.latlon,
SimpleMetaTaggers.layerInfo,
@ -424,11 +461,11 @@ export default class SimpleMetaTaggers {
SimpleMetaTaggers.objectMetaInfo,
SimpleMetaTaggers.noBothButLeftRight,
SimpleMetaTaggers.geometryType,
SimpleMetaTaggers.levels
];
public static readonly lazyTags: string[] = [].concat(...SimpleMetaTaggers.metatags.filter(tagger => tagger.isLazy)
.map(tagger => tagger.keys));
SimpleMetaTaggers.levels,
]
public static readonly lazyTags: string[] = [].concat(
...SimpleMetaTaggers.metatags.filter((tagger) => tagger.isLazy).map((tagger) => tagger.keys)
)
/**
* Edits the given object to rewrite 'both'-tagging into a 'left-right' tagging scheme.
@ -451,36 +488,34 @@ export default class SimpleMetaTaggers {
}
if (tags["sidewalk"]) {
const v = tags["sidewalk"]
switch (v) {
case "none":
case "no":
set("sidewalk:left", "no");
set("sidewalk:right", "no");
set("sidewalk:left", "no")
set("sidewalk:right", "no")
break
case "both":
set("sidewalk:left", "yes");
set("sidewalk:right", "yes");
break;
set("sidewalk:left", "yes")
set("sidewalk:right", "yes")
break
case "left":
set("sidewalk:left", "yes");
set("sidewalk:right", "no");
break;
set("sidewalk:left", "yes")
set("sidewalk:right", "no")
break
case "right":
set("sidewalk:left", "no");
set("sidewalk:right", "yes");
break;
set("sidewalk:left", "no")
set("sidewalk:right", "yes")
break
default:
set("sidewalk:left", v);
set("sidewalk:right", v);
break;
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]
@ -503,7 +538,6 @@ export default class SimpleMetaTaggers {
}
}
return somethingChanged
}
@ -512,13 +546,16 @@ export default class SimpleMetaTaggers {
new Combine([
"Metatags are extra tags available, in order to display more data or to give better questions.",
"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")
];
"**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"))
subElements.push(
new FixedUiElement(
"The following values are always calculated, by default, by MapComplete and are available automatically on all elements in every theme"
)
)
for (const metatag of SimpleMetaTaggers.metatags) {
subElements.push(
new Title(metatag.keys.join(", "), 3),
@ -529,5 +566,4 @@ export default class SimpleMetaTaggers {
return new Combine(subElements).SetClass("flex-col")
}
}

View file

@ -1,89 +1,91 @@
import FeatureSwitchState from "./FeatureSwitchState";
import {ElementStorage} from "../ElementStorage";
import {Changes} from "../Osm/Changes";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {UIEventSource} from "../UIEventSource";
import Loc from "../../Models/Loc";
import {BBox} from "../BBox";
import {QueryParameters} from "../Web/QueryParameters";
import {LocalStorageSource} from "../Web/LocalStorageSource";
import {Utils} from "../../Utils";
import ChangeToElementsActor from "../Actors/ChangeToElementsActor";
import PendingChangesUploader from "../Actors/PendingChangesUploader";
import FeatureSwitchState from "./FeatureSwitchState"
import { ElementStorage } from "../ElementStorage"
import { Changes } from "../Osm/Changes"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { UIEventSource } from "../UIEventSource"
import Loc from "../../Models/Loc"
import { BBox } from "../BBox"
import { QueryParameters } from "../Web/QueryParameters"
import { LocalStorageSource } from "../Web/LocalStorageSource"
import { Utils } from "../../Utils"
import ChangeToElementsActor from "../Actors/ChangeToElementsActor"
import PendingChangesUploader from "../Actors/PendingChangesUploader"
/**
* The part of the state keeping track of where the elements, loading them, configuring the feature pipeline etc
*/
export default class ElementsState extends FeatureSwitchState {
/**
The mapping from id -> UIEventSource<properties>
*/
public allElements: ElementStorage = new ElementStorage();
public allElements: ElementStorage = new ElementStorage()
/**
The latest element that was selected
*/
public readonly selectedElement = new UIEventSource<any>(
undefined,
"Selected element"
);
public readonly selectedElement = new UIEventSource<any>(undefined, "Selected element")
/**
* The map location: currently centered lat, lon and zoom
*/
public readonly locationControl = new UIEventSource<Loc>(undefined, "locationControl");
public readonly locationControl = new UIEventSource<Loc>(undefined, "locationControl")
/**
* The current visible extent of the screen
*/
public readonly currentBounds = new UIEventSource<BBox>(undefined)
constructor(layoutToUse: LayoutConfig) {
super(layoutToUse);
function localStorageSynced(key: string, deflt: number, docs: string ): UIEventSource<number>{
const localStorage = LocalStorageSource.Get(key)
const previousValue = localStorage.data
const src = UIEventSource.asFloat(
QueryParameters.GetQueryParameter(
key,
"" + deflt,
docs
).syncWith(localStorage)
);
if(src.data === deflt){
const prev = Number(previousValue)
if(!isNaN(prev)){
src.setData(prev)
}
super(layoutToUse)
function localStorageSynced(
key: string,
deflt: number,
docs: string
): UIEventSource<number> {
const localStorage = LocalStorageSource.Get(key)
const previousValue = localStorage.data
const src = UIEventSource.asFloat(
QueryParameters.GetQueryParameter(key, "" + deflt, docs).syncWith(localStorage)
)
if (src.data === deflt) {
const prev = Number(previousValue)
if (!isNaN(prev)) {
src.setData(prev)
}
return src;
}
// -- Location control initialization
const zoom = localStorageSynced("z",(layoutToUse?.startZoom ?? 1),"The initial/current zoom level")
const lat = localStorageSynced("lat",(layoutToUse?.startLat ?? 0),"The initial/current latitude")
const lon = localStorageSynced("lon",(layoutToUse?.startLon ?? 0),"The initial/current longitude of the app")
return src
}
// -- Location control initialization
const zoom = localStorageSynced(
"z",
layoutToUse?.startZoom ?? 1,
"The initial/current zoom level"
)
const lat = localStorageSynced(
"lat",
layoutToUse?.startLat ?? 0,
"The initial/current latitude"
)
const lon = localStorageSynced(
"lon",
layoutToUse?.startLon ?? 0,
"The initial/current longitude of the app"
)
this.locationControl.setData({
zoom: Utils.asFloat(zoom.data),
lat: Utils.asFloat(lat.data),
lon: Utils.asFloat(lon.data),
})
this.locationControl.addCallback((latlonz) => {
// Sync the location controls
zoom.setData(latlonz.zoom);
lat.setData(latlonz.lat);
lon.setData(latlonz.lon);
});
this.locationControl.setData({
zoom: Utils.asFloat(zoom.data),
lat: Utils.asFloat(lat.data),
lon: Utils.asFloat(lon.data),
})
this.locationControl.addCallback((latlonz) => {
// Sync the location controls
zoom.setData(latlonz.zoom)
lat.setData(latlonz.lat)
lon.setData(latlonz.lon)
})
}
}
}

View file

@ -1,37 +1,39 @@
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import FeaturePipeline from "../FeatureSource/FeaturePipeline";
import {Tiles} from "../../Models/TileRange";
import ShowDataLayer from "../../UI/ShowDataLayer/ShowDataLayer";
import {TileHierarchyAggregator} from "../../UI/ShowDataLayer/TileHierarchyAggregator";
import ShowTileInfo from "../../UI/ShowDataLayer/ShowTileInfo";
import {UIEventSource} from "../UIEventSource";
import MapState from "./MapState";
import SelectedFeatureHandler from "../Actors/SelectedFeatureHandler";
import Hash from "../Web/Hash";
import {BBox} from "../BBox";
import FeatureInfoBox from "../../UI/Popup/FeatureInfoBox";
import {FeatureSourceForLayer, Tiled} from "../FeatureSource/FeatureSource";
import MetaTagRecalculator from "../FeatureSource/Actors/MetaTagRecalculator";
import ScrollableFullScreen from "../../UI/Base/ScrollableFullScreen";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import FeaturePipeline from "../FeatureSource/FeaturePipeline"
import { Tiles } from "../../Models/TileRange"
import ShowDataLayer from "../../UI/ShowDataLayer/ShowDataLayer"
import { TileHierarchyAggregator } from "../../UI/ShowDataLayer/TileHierarchyAggregator"
import ShowTileInfo from "../../UI/ShowDataLayer/ShowTileInfo"
import { UIEventSource } from "../UIEventSource"
import MapState from "./MapState"
import SelectedFeatureHandler from "../Actors/SelectedFeatureHandler"
import Hash from "../Web/Hash"
import { BBox } from "../BBox"
import FeatureInfoBox from "../../UI/Popup/FeatureInfoBox"
import { FeatureSourceForLayer, Tiled } from "../FeatureSource/FeatureSource"
import MetaTagRecalculator from "../FeatureSource/Actors/MetaTagRecalculator"
import ScrollableFullScreen from "../../UI/Base/ScrollableFullScreen"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
export default class FeaturePipelineState extends MapState {
/**
* The piece of code which fetches data from various sources and shows it on the background map
*/
public readonly featurePipeline: FeaturePipeline;
private readonly featureAggregator: TileHierarchyAggregator;
public readonly featurePipeline: FeaturePipeline
private readonly featureAggregator: TileHierarchyAggregator
private readonly metatagRecalculator: MetaTagRecalculator
private readonly popups : Map<string, ScrollableFullScreen> = new Map<string, ScrollableFullScreen>();
private readonly popups: Map<string, ScrollableFullScreen> = new Map<
string,
ScrollableFullScreen
>()
constructor(layoutToUse: LayoutConfig) {
super(layoutToUse);
super(layoutToUse)
const clustering = layoutToUse?.clustering
this.featureAggregator = TileHierarchyAggregator.createHierarchy(this);
this.featureAggregator = TileHierarchyAggregator.createHierarchy(this)
const clusterCounter = this.featureAggregator
const self = this;
const self = this
/**
* We are a bit in a bind:
@ -51,26 +53,26 @@ export default class FeaturePipelineState extends MapState {
self.metatagRecalculator.registerSource(source)
}
}
function registerSource(source: FeatureSourceForLayer & Tiled) {
function registerSource(source: FeatureSourceForLayer & Tiled) {
clusterCounter.addTile(source)
const sourceBBox = source.features.map(allFeatures => BBox.bboxAroundAll(allFeatures.map(f => BBox.get(f.feature))))
const sourceBBox = source.features.map((allFeatures) =>
BBox.bboxAroundAll(allFeatures.map((f) => BBox.get(f.feature)))
)
// Do show features indicates if the respective 'showDataLayer' should be shown. It can be hidden by e.g. clustering
const doShowFeatures = source.features.map(
f => {
(f) => {
const z = self.locationControl.data.zoom
if (!source.layer.isDisplayed.data) {
return false;
return false
}
const bounds = self.currentBounds.data
if (bounds === undefined) {
// Map is not yet displayed
return false;
return false
}
if (!sourceBBox.data.overlapsWith(bounds)) {
@ -78,10 +80,9 @@ export default class FeaturePipelineState extends MapState {
return false
}
if (z < source.layer.layerDef.minzoom) {
// Layer is always hidden for this zoom level
return false;
return false
}
if (z > clustering.maxZoom) {
@ -93,55 +94,55 @@ export default class FeaturePipelineState extends MapState {
return false
}
let [tileZ, tileX, tileY] = Tiles.tile_from_index(source.tileIndex);
let [tileZ, tileX, tileY] = Tiles.tile_from_index(source.tileIndex)
if (tileZ >= z) {
while (tileZ > z) {
tileZ--
tileX = Math.floor(tileX / 2)
tileY = Math.floor(tileY / 2)
}
if (clusterCounter.getTile(Tiles.tile_index(tileZ, tileX, tileY))?.totalValue > clustering.minNeededElements) {
if (
clusterCounter.getTile(Tiles.tile_index(tileZ, tileX, tileY))
?.totalValue > clustering.minNeededElements
) {
// To much elements
return false
}
}
return true
}, [self.currentBounds, source.layer.isDisplayed, sourceBBox]
},
[self.currentBounds, source.layer.isDisplayed, sourceBBox]
)
new ShowDataLayer(
{
features: source,
leafletMap: self.leafletMap,
layerToShow: source.layer.layerDef,
doShowLayer: doShowFeatures,
selectedElement: self.selectedElement,
state: self,
popup: (tags, layer) => self.CreatePopup(tags, layer)
}
)
new ShowDataLayer({
features: source,
leafletMap: self.leafletMap,
layerToShow: source.layer.layerDef,
doShowLayer: doShowFeatures,
selectedElement: self.selectedElement,
state: self,
popup: (tags, layer) => self.CreatePopup(tags, layer),
})
}
this.featurePipeline = new FeaturePipeline(registerSource, this, {handleRawFeatureSource: registerRaw});
this.featurePipeline = new FeaturePipeline(registerSource, this, {
handleRawFeatureSource: registerRaw,
})
this.metatagRecalculator = new MetaTagRecalculator(this, this.featurePipeline)
this.metatagRecalculator.registerSource(this.currentView, true)
sourcesToRegister.forEach(source => self.metatagRecalculator.registerSource(source))
sourcesToRegister.forEach((source) => self.metatagRecalculator.registerSource(source))
new SelectedFeatureHandler(Hash.hash, this)
this.AddClusteringToMap(this.leafletMap)
}
public CreatePopup(tags:UIEventSource<any> , layer: LayerConfig): ScrollableFullScreen{
if(this.popups.has(tags.data.id)){
return this.popups.get(tags.data.id)
public CreatePopup(tags: UIEventSource<any>, layer: LayerConfig): ScrollableFullScreen {
if (this.popups.has(tags.data.id)) {
return this.popups.get(tags.data.id)
}
const popup = new FeatureInfoBox(tags, layer, this)
this.popups.set(tags.data.id, popup)
@ -155,15 +156,19 @@ export default class FeaturePipelineState extends MapState {
*/
public AddClusteringToMap(leafletMap: UIEventSource<any>) {
const clustering = this.layoutToUse.clustering
const self = this;
const self = this
new ShowDataLayer({
features: this.featureAggregator.getCountsForZoom(clustering, this.locationControl, clustering.minNeededElements),
features: this.featureAggregator.getCountsForZoom(
clustering,
this.locationControl,
clustering.minNeededElements
),
leafletMap: leafletMap,
layerToShow: ShowTileInfo.styling,
popup: this.featureSwitchIsDebugging.data ? (tags, layer) => new FeatureInfoBox(tags, layer, self) : undefined,
state: this
popup: this.featureSwitchIsDebugging.data
? (tags, layer) => new FeatureInfoBox(tags, layer, self)
: undefined,
state: this,
})
}
}
}

View file

@ -1,45 +1,43 @@
/**
* The part of the global state which initializes the feature switches, based on default values and on the layoutToUse
*/
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {UIEventSource} from "../UIEventSource";
import {QueryParameters} from "../Web/QueryParameters";
import Constants from "../../Models/Constants";
import {Utils} from "../../Utils";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { UIEventSource } from "../UIEventSource"
import { QueryParameters } from "../Web/QueryParameters"
import Constants from "../../Models/Constants"
import { Utils } from "../../Utils"
export default class FeatureSwitchState {
/**
* The layout that is being used in this run
*/
public readonly layoutToUse: LayoutConfig;
public readonly layoutToUse: LayoutConfig
public readonly featureSwitchUserbadge: UIEventSource<boolean>;
public readonly featureSwitchSearch: UIEventSource<boolean>;
public readonly featureSwitchBackgroundSelection: UIEventSource<boolean>;
public readonly featureSwitchAddNew: UIEventSource<boolean>;
public readonly featureSwitchWelcomeMessage: UIEventSource<boolean>;
public readonly featureSwitchExtraLinkEnabled: UIEventSource<boolean>;
public readonly featureSwitchMoreQuests: UIEventSource<boolean>;
public readonly featureSwitchShareScreen: UIEventSource<boolean>;
public readonly featureSwitchGeolocation: UIEventSource<boolean>;
public readonly featureSwitchIsTesting: UIEventSource<boolean>;
public readonly featureSwitchIsDebugging: UIEventSource<boolean>;
public readonly featureSwitchShowAllQuestions: UIEventSource<boolean>;
public readonly featureSwitchApiURL: UIEventSource<string>;
public readonly featureSwitchFilter: UIEventSource<boolean>;
public readonly featureSwitchEnableExport: UIEventSource<boolean>;
public readonly featureSwitchFakeUser: UIEventSource<boolean>;
public readonly featureSwitchExportAsPdf: UIEventSource<boolean>;
public readonly overpassUrl: UIEventSource<string[]>;
public readonly overpassTimeout: UIEventSource<number>;
public readonly overpassMaxZoom: UIEventSource<number>;
public readonly osmApiTileSize: UIEventSource<number>;
public readonly backgroundLayerId: UIEventSource<string>;
public readonly featureSwitchUserbadge: UIEventSource<boolean>
public readonly featureSwitchSearch: UIEventSource<boolean>
public readonly featureSwitchBackgroundSelection: UIEventSource<boolean>
public readonly featureSwitchAddNew: UIEventSource<boolean>
public readonly featureSwitchWelcomeMessage: UIEventSource<boolean>
public readonly featureSwitchExtraLinkEnabled: UIEventSource<boolean>
public readonly featureSwitchMoreQuests: UIEventSource<boolean>
public readonly featureSwitchShareScreen: UIEventSource<boolean>
public readonly featureSwitchGeolocation: UIEventSource<boolean>
public readonly featureSwitchIsTesting: UIEventSource<boolean>
public readonly featureSwitchIsDebugging: UIEventSource<boolean>
public readonly featureSwitchShowAllQuestions: UIEventSource<boolean>
public readonly featureSwitchApiURL: UIEventSource<string>
public readonly featureSwitchFilter: UIEventSource<boolean>
public readonly featureSwitchEnableExport: UIEventSource<boolean>
public readonly featureSwitchFakeUser: UIEventSource<boolean>
public readonly featureSwitchExportAsPdf: UIEventSource<boolean>
public readonly overpassUrl: UIEventSource<string[]>
public readonly overpassTimeout: UIEventSource<number>
public readonly overpassMaxZoom: UIEventSource<number>
public readonly osmApiTileSize: UIEventSource<number>
public readonly backgroundLayerId: UIEventSource<string>
public constructor(layoutToUse: LayoutConfig) {
this.layoutToUse = layoutToUse;
this.layoutToUse = layoutToUse
// Helper function to initialize feature switches
function featSw(
@ -47,104 +45,104 @@ export default class FeatureSwitchState {
deflt: (layout: LayoutConfig) => boolean,
documentation: string
): UIEventSource<boolean> {
const defaultValue = deflt(layoutToUse);
const defaultValue = deflt(layoutToUse)
const queryParam = QueryParameters.GetQueryParameter(
key,
"" + defaultValue,
documentation
);
// It takes the current layout, extracts the default value for this query parameter. A query parameter event source is then retrieved and flattened
return queryParam.sync((str) =>
str === undefined ? defaultValue : str !== "false", [],
b => b == defaultValue ? undefined : (""+b)
)
// It takes the current layout, extracts the default value for this query parameter. A query parameter event source is then retrieved and flattened
return queryParam.sync(
(str) => (str === undefined ? defaultValue : str !== "false"),
[],
(b) => (b == defaultValue ? undefined : "" + b)
)
}
this.featureSwitchUserbadge = featSw(
"fs-userbadge",
(layoutToUse) => layoutToUse?.enableUserBadge ?? true,
"Disables/Enables the user information pill (userbadge) at the top left. Disabling this disables logging in and thus disables editing all together, effectively putting MapComplete into read-only mode."
);
)
this.featureSwitchSearch = featSw(
"fs-search",
(layoutToUse) => layoutToUse?.enableSearch ?? true,
"Disables/Enables the search bar"
);
)
this.featureSwitchBackgroundSelection = featSw(
"fs-background",
(layoutToUse) => layoutToUse?.enableBackgroundLayerSelection ?? true,
"Disables/Enables the background layer control"
);
)
this.featureSwitchFilter = featSw(
"fs-filter",
(layoutToUse) => layoutToUse?.enableLayers ?? true,
"Disables/Enables the filter view"
);
)
this.featureSwitchAddNew = featSw(
"fs-add-new",
(layoutToUse) => layoutToUse?.enableAddNewPoints ?? true,
"Disables/Enables the 'add new feature'-popup. (A theme without presets might not have it in the first place)"
);
)
this.featureSwitchWelcomeMessage = featSw(
"fs-welcome-message",
() => true,
"Disables/enables the help menu or welcome message"
);
)
this.featureSwitchExtraLinkEnabled = featSw(
"fs-iframe-popout",
_ => true,
(_) => true,
"Disables/Enables the extraLink button. By default, if in iframe mode and the welcome message is hidden, a popout button to the full mapcomplete instance is shown instead (unless disabled with this switch or another extraLink button is enabled)"
);
)
this.featureSwitchMoreQuests = featSw(
"fs-more-quests",
(layoutToUse) => layoutToUse?.enableMoreQuests ?? true,
"Disables/Enables the 'More Quests'-tab in the welcome message"
);
)
this.featureSwitchShareScreen = featSw(
"fs-share-screen",
(layoutToUse) => layoutToUse?.enableShareScreen ?? true,
"Disables/Enables the 'Share-screen'-tab in the welcome message"
);
)
this.featureSwitchGeolocation = featSw(
"fs-geolocation",
(layoutToUse) => layoutToUse?.enableGeolocation ?? true,
"Disables/Enables the geolocation button"
);
)
this.featureSwitchShowAllQuestions = featSw(
"fs-all-questions",
(layoutToUse) => layoutToUse?.enableShowAllQuestions ?? false,
"Always show all questions"
);
)
this.featureSwitchEnableExport = featSw(
"fs-export",
(layoutToUse) => layoutToUse?.enableExportButton ?? false,
"Enable the export as GeoJSON and CSV button"
);
)
this.featureSwitchExportAsPdf = featSw(
"fs-pdf",
(layoutToUse) => layoutToUse?.enablePdfDownload ?? false,
"Enable the PDF download button"
);
)
this.featureSwitchApiURL = QueryParameters.GetQueryParameter(
"backend",
"osm",
"The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using 'osm-test'"
);
)
let testingDefaultValue = false;
if (this.featureSwitchApiURL.data !== "osm-test" && !Utils.runningFromConsole &&
(location.hostname === "localhost" || location.hostname === "127.0.0.1")) {
let testingDefaultValue = false
if (
this.featureSwitchApiURL.data !== "osm-test" &&
!Utils.runningFromConsole &&
(location.hostname === "localhost" || location.hostname === "127.0.0.1")
) {
testingDefaultValue = true
}
this.featureSwitchIsTesting = QueryParameters.GetBooleanQueryParameter(
"test",
testingDefaultValue,
@ -157,31 +155,47 @@ export default class FeatureSwitchState {
"If true, shows some extra debugging help such as all the available tags on every object"
)
this.featureSwitchFakeUser = QueryParameters.GetBooleanQueryParameter("fake-user", false,
"If true, 'dryrun' mode is activated and a fake user account is loaded")
this.featureSwitchFakeUser = QueryParameters.GetBooleanQueryParameter(
"fake-user",
false,
"If true, 'dryrun' mode is activated and a fake user account is loaded"
)
this.overpassUrl = QueryParameters.GetQueryParameter("overpassUrl",
this.overpassUrl = QueryParameters.GetQueryParameter(
"overpassUrl",
(layoutToUse?.overpassUrl ?? Constants.defaultOverpassUrls).join(","),
"Point mapcomplete to a different overpass-instance. Example: https://overpass-api.de/api/interpreter"
).sync(param => param.split(","), [], urls => urls.join(","))
).sync(
(param) => param.split(","),
[],
(urls) => urls.join(",")
)
this.overpassTimeout = UIEventSource.asFloat(QueryParameters.GetQueryParameter("overpassTimeout",
"" + layoutToUse?.overpassTimeout,
"Set a different timeout (in seconds) for queries in overpass"))
this.overpassTimeout = UIEventSource.asFloat(
QueryParameters.GetQueryParameter(
"overpassTimeout",
"" + layoutToUse?.overpassTimeout,
"Set a different timeout (in seconds) for queries in overpass"
)
)
this.overpassMaxZoom =
UIEventSource.asFloat(QueryParameters.GetQueryParameter("overpassMaxZoom",
this.overpassMaxZoom = UIEventSource.asFloat(
QueryParameters.GetQueryParameter(
"overpassMaxZoom",
"" + layoutToUse?.overpassMaxZoom,
" point to switch between OSM-api and overpass"))
" point to switch between OSM-api and overpass"
)
)
this.osmApiTileSize =
UIEventSource.asFloat(QueryParameters.GetQueryParameter("osmApiTileSize",
this.osmApiTileSize = UIEventSource.asFloat(
QueryParameters.GetQueryParameter(
"osmApiTileSize",
"" + layoutToUse?.osmApiTileSize,
"Tilesize when the OSM-API is used to fetch data within a BBOX"))
"Tilesize when the OSM-API is used to fetch data within a BBOX"
)
)
this.featureSwitchUserbadge.addCallbackAndRun(userbadge => {
this.featureSwitchUserbadge.addCallbackAndRun((userbadge) => {
if (!userbadge) {
this.featureSwitchAddNew.setData(false)
}
@ -191,9 +205,6 @@ export default class FeatureSwitchState {
"background",
layoutToUse?.defaultBackgroundId ?? "osm",
"The id of the background layer to start with"
);
)
}
}
}

View file

@ -1,34 +1,33 @@
import UserRelatedState from "./UserRelatedState";
import {Store, Stores, UIEventSource} from "../UIEventSource";
import BaseLayer from "../../Models/BaseLayer";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import AvailableBaseLayers from "../Actors/AvailableBaseLayers";
import Attribution from "../../UI/BigComponents/Attribution";
import Minimap, {MinimapObj} from "../../UI/Base/Minimap";
import {Tiles} from "../../Models/TileRange";
import BaseUIElement from "../../UI/BaseUIElement";
import FilteredLayer, {FilterState} from "../../Models/FilteredLayer";
import TilesourceConfig from "../../Models/ThemeConfig/TilesourceConfig";
import {QueryParameters} from "../Web/QueryParameters";
import ShowOverlayLayer from "../../UI/ShowDataLayer/ShowOverlayLayer";
import {FeatureSourceForLayer, Tiled} from "../FeatureSource/FeatureSource";
import SimpleFeatureSource from "../FeatureSource/Sources/SimpleFeatureSource";
import {LocalStorageSource} from "../Web/LocalStorageSource";
import {GeoOperations} from "../GeoOperations";
import TitleHandler from "../Actors/TitleHandler";
import {BBox} from "../BBox";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import {TiledStaticFeatureSource} from "../FeatureSource/Sources/StaticFeatureSource";
import {Translation, TypedTranslation} from "../../UI/i18n/Translation";
import {Tag} from "../Tags/Tag";
import {OsmConnection} from "../Osm/OsmConnection";
import UserRelatedState from "./UserRelatedState"
import { Store, Stores, UIEventSource } from "../UIEventSource"
import BaseLayer from "../../Models/BaseLayer"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import AvailableBaseLayers from "../Actors/AvailableBaseLayers"
import Attribution from "../../UI/BigComponents/Attribution"
import Minimap, { MinimapObj } from "../../UI/Base/Minimap"
import { Tiles } from "../../Models/TileRange"
import BaseUIElement from "../../UI/BaseUIElement"
import FilteredLayer, { FilterState } from "../../Models/FilteredLayer"
import TilesourceConfig from "../../Models/ThemeConfig/TilesourceConfig"
import { QueryParameters } from "../Web/QueryParameters"
import ShowOverlayLayer from "../../UI/ShowDataLayer/ShowOverlayLayer"
import { FeatureSourceForLayer, Tiled } from "../FeatureSource/FeatureSource"
import SimpleFeatureSource from "../FeatureSource/Sources/SimpleFeatureSource"
import { LocalStorageSource } from "../Web/LocalStorageSource"
import { GeoOperations } from "../GeoOperations"
import TitleHandler from "../Actors/TitleHandler"
import { BBox } from "../BBox"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { TiledStaticFeatureSource } from "../FeatureSource/Sources/StaticFeatureSource"
import { Translation, TypedTranslation } from "../../UI/i18n/Translation"
import { Tag } from "../Tags/Tag"
import { OsmConnection } from "../Osm/OsmConnection"
export interface GlobalFilter {
filter: FilterState,
id: string,
filter: FilterState
id: string
onNewPoint: {
safetyCheck: Translation,
safetyCheck: Translation
confirmAddNew: TypedTranslation<{ preset: Translation }>
tags: Tag[]
}
@ -38,60 +37,64 @@ export interface GlobalFilter {
* Contains all the leaflet-map related state
*/
export default class MapState extends UserRelatedState {
/**
The leaflet instance of the big basemap
*/
public leafletMap = new UIEventSource<any /*L.Map*/>(undefined, "leafletmap");
public leafletMap = new UIEventSource<any /*L.Map*/>(undefined, "leafletmap")
/**
* A list of currently available background layers
*/
public availableBackgroundLayers: Store<BaseLayer[]>;
public availableBackgroundLayers: Store<BaseLayer[]>
/**
* The current background layer
*/
public backgroundLayer: UIEventSource<BaseLayer>;
public backgroundLayer: UIEventSource<BaseLayer>
/**
* Last location where a click was registered
*/
public readonly LastClickLocation: UIEventSource<{
lat: number;
lon: number;
}> = new UIEventSource<{ lat: number; lon: number }>(undefined);
lat: number
lon: number
}> = new UIEventSource<{ lat: number; lon: number }>(undefined)
/**
* The bounds of the current map view
*/
public currentView: FeatureSourceForLayer & Tiled;
public currentView: FeatureSourceForLayer & Tiled
/**
* The location as delivered by the GPS
*/
public currentUserLocation: SimpleFeatureSource;
public currentUserLocation: SimpleFeatureSource
/**
* All previously visited points
*/
public historicalUserLocations: SimpleFeatureSource;
public historicalUserLocations: SimpleFeatureSource
/**
* The number of seconds that the GPS-locations are stored in memory.
* Time in seconds
*/
public gpsLocationHistoryRetentionTime = new UIEventSource(7 * 24 * 60 * 60, "gps_location_retention")
public historicalUserLocationsTrack: FeatureSourceForLayer & Tiled;
public gpsLocationHistoryRetentionTime = new UIEventSource(
7 * 24 * 60 * 60,
"gps_location_retention"
)
public historicalUserLocationsTrack: FeatureSourceForLayer & Tiled
/**
* A feature source containing the current home location of the user
*/
public homeLocation: FeatureSourceForLayer & Tiled
public readonly mainMapObject: BaseUIElement & MinimapObj;
public readonly mainMapObject: BaseUIElement & MinimapObj
/**
* Which layers are enabled in the current theme and what filters are applied onto them
*/
public filteredLayers: UIEventSource<FilteredLayer[]> = new UIEventSource<FilteredLayer[]>([], "filteredLayers");
public filteredLayers: UIEventSource<FilteredLayer[]> = new UIEventSource<FilteredLayer[]>(
[],
"filteredLayers"
)
/**
* Filters which apply onto all layers
@ -101,31 +104,30 @@ export default class MapState extends UserRelatedState {
/**
* Which overlays are shown
*/
public overlayToggles: { config: TilesourceConfig, isDisplayed: UIEventSource<boolean> }[]
public overlayToggles: { config: TilesourceConfig; isDisplayed: UIEventSource<boolean> }[]
constructor(layoutToUse: LayoutConfig, options?: { attemptLogin: true | boolean }) {
super(layoutToUse, options);
super(layoutToUse, options)
this.availableBackgroundLayers = AvailableBaseLayers.AvailableLayersAt(this.locationControl);
this.availableBackgroundLayers = AvailableBaseLayers.AvailableLayersAt(this.locationControl)
let defaultLayer = AvailableBaseLayers.osmCarto
const available = this.availableBackgroundLayers.data;
const available = this.availableBackgroundLayers.data
for (const layer of available) {
if (this.backgroundLayerId.data === layer.id) {
defaultLayer = layer;
defaultLayer = layer
}
}
const self = this
this.backgroundLayer = new UIEventSource<BaseLayer>(defaultLayer)
this.backgroundLayer.addCallbackAndRunD(layer => self.backgroundLayerId.setData(layer.id))
this.backgroundLayer.addCallbackAndRunD((layer) => self.backgroundLayerId.setData(layer.id))
const attr = new Attribution(
this.locationControl,
this.osmConnection.userDetails,
this.layoutToUse,
this.currentBounds
);
)
// Will write into this.leafletMap
this.mainMapObject = Minimap.createMiniMap({
@ -134,18 +136,23 @@ export default class MapState extends UserRelatedState {
leafletMap: this.leafletMap,
bounds: this.currentBounds,
attribution: attr,
lastClickLocation: this.LastClickLocation
lastClickLocation: this.LastClickLocation,
})
this.overlayToggles = this.layoutToUse?.tileLayerSources
?.filter(c => c.name !== undefined)
?.map(c => ({
config: c,
isDisplayed: QueryParameters.GetBooleanQueryParameter("overlay-" + c.id, c.defaultState, "Wether or not the overlay " + c.id + " is shown")
})) ?? []
this.filteredLayers = new UIEventSource<FilteredLayer[]>( MapState.InitializeFilteredLayers(this.layoutToUse, this.osmConnection))
this.overlayToggles =
this.layoutToUse?.tileLayerSources
?.filter((c) => c.name !== undefined)
?.map((c) => ({
config: c,
isDisplayed: QueryParameters.GetBooleanQueryParameter(
"overlay-" + c.id,
c.defaultState,
"Wether or not the overlay " + c.id + " is shown"
),
})) ?? []
this.filteredLayers = new UIEventSource<FilteredLayer[]>(
MapState.InitializeFilteredLayers(this.layoutToUse, this.osmConnection)
)
this.lockBounds()
this.AddAllOverlaysToMap(this.leafletMap)
@ -155,7 +162,7 @@ export default class MapState extends UserRelatedState {
this.initUserLocationTrail()
this.initCurrentView()
new TitleHandler(this);
new TitleHandler(this)
}
public AddAllOverlaysToMap(leafletMap: UIEventSource<any>) {
@ -171,15 +178,14 @@ export default class MapState extends UserRelatedState {
}
new ShowOverlayLayer(tileLayerSource, leafletMap)
}
}
private lockBounds() {
const layout = this.layoutToUse;
const layout = this.layoutToUse
if (!layout?.lockLocation) {
return;
return
}
console.warn("Locking the bounds to ", layout.lockLocation);
console.warn("Locking the bounds to ", layout.lockLocation)
this.mainMapObject.installBounds(
new BBox(layout.lockLocation),
this.featureSwitchIsTesting.data
@ -187,69 +193,82 @@ export default class MapState extends UserRelatedState {
}
private initCurrentView() {
let currentViewLayer: FilteredLayer = this.filteredLayers.data.filter(l => l.layerDef.id === "current_view")[0]
let currentViewLayer: FilteredLayer = this.filteredLayers.data.filter(
(l) => l.layerDef.id === "current_view"
)[0]
if (currentViewLayer === undefined) {
// This layer is not needed by the theme and thus unloaded
return;
return
}
let i = 0
const self = this;
const features: Store<{ feature: any, freshness: Date }[]> = this.currentBounds.map(bounds => {
if (bounds === undefined) {
return []
}
i++
const feature = {
freshness: new Date(),
feature: {
type: "Feature",
properties: {
id: "current_view-" + i,
"current_view": "yes",
"zoom": "" + self.locationControl.data.zoom
},
geometry: {
type: "Polygon",
coordinates: [[
[bounds.maxLon, bounds.maxLat],
[bounds.minLon, bounds.maxLat],
[bounds.minLon, bounds.minLat],
[bounds.maxLon, bounds.minLat],
[bounds.maxLon, bounds.maxLat],
]]
}
const self = this
const features: Store<{ feature: any; freshness: Date }[]> = this.currentBounds.map(
(bounds) => {
if (bounds === undefined) {
return []
}
i++
const feature = {
freshness: new Date(),
feature: {
type: "Feature",
properties: {
id: "current_view-" + i,
current_view: "yes",
zoom: "" + self.locationControl.data.zoom,
},
geometry: {
type: "Polygon",
coordinates: [
[
[bounds.maxLon, bounds.maxLat],
[bounds.minLon, bounds.maxLat],
[bounds.minLon, bounds.minLat],
[bounds.maxLon, bounds.minLat],
[bounds.maxLon, bounds.maxLat],
],
],
},
},
}
return [feature]
}
return [feature]
})
)
this.currentView = new TiledStaticFeatureSource(features, currentViewLayer);
this.currentView = new TiledStaticFeatureSource(features, currentViewLayer)
}
private initGpsLocation() {
// Initialize the gps layer data. This is emtpy for now, the actual writing happens in the Geolocationhandler
let gpsLayerDef: FilteredLayer = this.filteredLayers.data.filter(l => l.layerDef.id === "gps_location")[0]
let gpsLayerDef: FilteredLayer = this.filteredLayers.data.filter(
(l) => l.layerDef.id === "gps_location"
)[0]
if (gpsLayerDef === undefined) {
return
}
this.currentUserLocation = new SimpleFeatureSource(gpsLayerDef, Tiles.tile_index(0, 0, 0));
this.currentUserLocation = new SimpleFeatureSource(gpsLayerDef, Tiles.tile_index(0, 0, 0))
}
private initUserLocationTrail() {
const features = LocalStorageSource.GetParsed<{ feature: any, freshness: Date }[]>("gps_location_history", [])
const features = LocalStorageSource.GetParsed<{ feature: any; freshness: Date }[]>(
"gps_location_history",
[]
)
const now = new Date().getTime()
features.data = features.data
.map(ff => ({feature: ff.feature, freshness: new Date(ff.freshness)}))
.filter(ff => (now - ff.freshness.getTime()) < 1000 * this.gpsLocationHistoryRetentionTime.data)
.map((ff) => ({ feature: ff.feature, freshness: new Date(ff.freshness) }))
.filter(
(ff) =>
now - ff.freshness.getTime() < 1000 * this.gpsLocationHistoryRetentionTime.data
)
features.ping()
const self = this;
const self = this
let i = 0
this.currentUserLocation?.features?.addCallbackAndRunD(([location]) => {
if (location === undefined) {
return;
return
}
const previousLocation = features.data[features.data.length - 1]
@ -261,30 +280,37 @@ export default class MapState extends UserRelatedState {
let timeDiff = Number.MAX_VALUE // in seconds
const olderLocation = features.data[features.data.length - 2]
if (olderLocation !== undefined) {
timeDiff = (new Date(previousLocation.freshness).getTime() - new Date(olderLocation.freshness).getTime()) / 1000
timeDiff =
(new Date(previousLocation.freshness).getTime() -
new Date(olderLocation.freshness).getTime()) /
1000
}
if (d < 20 && timeDiff < 60) {
// Do not append changes less then 20m - it's probably noise anyway
return;
return
}
}
const feature = JSON.parse(JSON.stringify(location.feature))
feature.properties.id = "gps/" + features.data.length
i++
features.data.push({feature, freshness: new Date()})
features.data.push({ feature, freshness: new Date() })
features.ping()
})
let gpsLayerDef: FilteredLayer = this.filteredLayers.data.filter(l => l.layerDef.id === "gps_location_history")[0]
let gpsLayerDef: FilteredLayer = this.filteredLayers.data.filter(
(l) => l.layerDef.id === "gps_location_history"
)[0]
if (gpsLayerDef !== undefined) {
this.historicalUserLocations = new SimpleFeatureSource(gpsLayerDef, Tiles.tile_index(0, 0, 0), features);
this.historicalUserLocations = new SimpleFeatureSource(
gpsLayerDef,
Tiles.tile_index(0, 0, 0),
features
)
this.changes.setHistoricalUserLocations(this.historicalUserLocations)
}
const asLine = features.map(allPoints => {
const asLine = features.map((allPoints) => {
if (allPoints === undefined || allPoints.length < 2) {
return []
}
@ -292,136 +318,184 @@ export default class MapState extends UserRelatedState {
const feature = {
type: "Feature",
properties: {
"id": "location_track",
id: "location_track",
"_date:now": new Date().toISOString(),
},
geometry: {
type: "LineString",
coordinates: allPoints.map(ff => ff.feature.geometry.coordinates)
}
coordinates: allPoints.map((ff) => ff.feature.geometry.coordinates),
},
}
self.allElements.ContainingFeatures.set(feature.properties.id, feature)
return [{
feature,
freshness: new Date()
}]
return [
{
feature,
freshness: new Date(),
},
]
})
let gpsLineLayerDef: FilteredLayer = this.filteredLayers.data.filter(l => l.layerDef.id === "gps_track")[0]
let gpsLineLayerDef: FilteredLayer = this.filteredLayers.data.filter(
(l) => l.layerDef.id === "gps_track"
)[0]
if (gpsLineLayerDef !== undefined) {
this.historicalUserLocationsTrack = new TiledStaticFeatureSource(asLine, gpsLineLayerDef);
this.historicalUserLocationsTrack = new TiledStaticFeatureSource(
asLine,
gpsLineLayerDef
)
}
}
private initHomeLocation() {
const empty = []
const feature = Stores.ListStabilized(this.osmConnection.userDetails.map(userDetails => {
if (userDetails === undefined) {
return undefined;
}
const home = userDetails.home;
if (home === undefined) {
return undefined;
}
return [home.lon, home.lat]
})).map(homeLonLat => {
const feature = Stores.ListStabilized(
this.osmConnection.userDetails.map((userDetails) => {
if (userDetails === undefined) {
return undefined
}
const home = userDetails.home
if (home === undefined) {
return undefined
}
return [home.lon, home.lat]
})
).map((homeLonLat) => {
if (homeLonLat === undefined) {
return empty
}
return [{
feature: {
"type": "Feature",
"properties": {
"id": "home",
"user:home": "yes",
"_lon": homeLonLat[0],
"_lat": homeLonLat[1]
return [
{
feature: {
type: "Feature",
properties: {
id: "home",
"user:home": "yes",
_lon: homeLonLat[0],
_lat: homeLonLat[1],
},
geometry: {
type: "Point",
coordinates: homeLonLat,
},
},
"geometry": {
"type": "Point",
"coordinates": homeLonLat
}
}, freshness: new Date()
}]
freshness: new Date(),
},
]
})
const flayer = this.filteredLayers.data.filter(l => l.layerDef.id === "home_location")[0]
const flayer = this.filteredLayers.data.filter((l) => l.layerDef.id === "home_location")[0]
if (flayer !== undefined) {
this.homeLocation = new TiledStaticFeatureSource(feature, flayer)
}
}
private static getPref(osmConnection: OsmConnection, key: string, layer: LayerConfig): UIEventSource<boolean> {
return osmConnection
.GetPreference(key, layer.shownByDefault + "")
.sync(v => {
private static getPref(
osmConnection: OsmConnection,
key: string,
layer: LayerConfig
): UIEventSource<boolean> {
return osmConnection.GetPreference(key, layer.shownByDefault + "").sync(
(v) => {
if (v === undefined) {
return undefined
}
return v === "true";
}, [], b => {
return v === "true"
},
[],
(b) => {
if (b === undefined) {
return undefined
}
return "" + b;
})
return "" + b
}
)
}
public static InitializeFilteredLayers(layoutToUse: {layers: LayerConfig[], id: string}, osmConnection: OsmConnection): FilteredLayer[] {
public static InitializeFilteredLayers(
layoutToUse: { layers: LayerConfig[]; id: string },
osmConnection: OsmConnection
): FilteredLayer[] {
if (layoutToUse === undefined) {
return []
}
const flayers: FilteredLayer[] = [];
const flayers: FilteredLayer[] = []
for (const layer of layoutToUse.layers) {
let isDisplayed: UIEventSource<boolean>
if (layer.syncSelection === "local") {
isDisplayed = LocalStorageSource.GetParsed(layoutToUse.id + "-layer-" + layer.id + "-enabled", layer.shownByDefault)
isDisplayed = LocalStorageSource.GetParsed(
layoutToUse.id + "-layer-" + layer.id + "-enabled",
layer.shownByDefault
)
} else if (layer.syncSelection === "theme-only") {
isDisplayed = MapState.getPref(osmConnection, layoutToUse.id + "-layer-" + layer.id + "-enabled", layer)
isDisplayed = MapState.getPref(
osmConnection,
layoutToUse.id + "-layer-" + layer.id + "-enabled",
layer
)
} else if (layer.syncSelection === "global") {
isDisplayed = MapState.getPref(osmConnection,"layer-" + layer.id + "-enabled", layer)
isDisplayed = MapState.getPref(
osmConnection,
"layer-" + layer.id + "-enabled",
layer
)
} else {
isDisplayed = QueryParameters.GetBooleanQueryParameter("layer-" + layer.id, layer.shownByDefault, "Wether or not layer " + layer.id + " is shown")
isDisplayed = QueryParameters.GetBooleanQueryParameter(
"layer-" + layer.id,
layer.shownByDefault,
"Wether or not layer " + layer.id + " is shown"
)
}
const flayer: FilteredLayer = {
isDisplayed,
layerDef: layer,
appliedFilters: new UIEventSource<Map<string, FilterState>>(new Map<string, FilterState>())
};
layer.filters.forEach(filterConfig => {
appliedFilters: new UIEventSource<Map<string, FilterState>>(
new Map<string, FilterState>()
),
}
layer.filters.forEach((filterConfig) => {
const stateSrc = filterConfig.initState()
stateSrc.addCallbackAndRun(state => flayer.appliedFilters.data.set(filterConfig.id, state))
flayer.appliedFilters.map(dict => dict.get(filterConfig.id))
.addCallback(state => stateSrc.setData(state))
stateSrc.addCallbackAndRun((state) =>
flayer.appliedFilters.data.set(filterConfig.id, state)
)
flayer.appliedFilters
.map((dict) => dict.get(filterConfig.id))
.addCallback((state) => stateSrc.setData(state))
})
flayers.push(flayer);
flayers.push(flayer)
}
for (const layer of layoutToUse.layers) {
if (layer.filterIsSameAs === undefined) {
continue
}
const toReuse = flayers.find(l => l.layerDef.id === layer.filterIsSameAs)
const toReuse = flayers.find((l) => l.layerDef.id === layer.filterIsSameAs)
if (toReuse === undefined) {
throw "Error in layer " + layer.id + ": it defines that it should be use the filters of " + layer.filterIsSameAs + ", but this layer was not loaded"
throw (
"Error in layer " +
layer.id +
": it defines that it should be use the filters of " +
layer.filterIsSameAs +
", but this layer was not loaded"
)
}
console.warn("Linking filter and isDisplayed-states of " + layer.id + " and " + layer.filterIsSameAs)
const selfLayer = flayers.findIndex(l => l.layerDef.id === layer.id)
console.warn(
"Linking filter and isDisplayed-states of " +
layer.id +
" and " +
layer.filterIsSameAs
)
const selfLayer = flayers.findIndex((l) => l.layerDef.id === layer.id)
flayers[selfLayer] = {
isDisplayed: toReuse.isDisplayed,
layerDef: layer,
appliedFilters: toReuse.appliedFilters
};
appliedFilters: toReuse.appliedFilters,
}
}
return flayers;
return flayers
}
}
}

View file

@ -1,51 +1,48 @@
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {OsmConnection} from "../Osm/OsmConnection";
import {MangroveIdentity} from "../Web/MangroveReviews";
import {Store, UIEventSource} from "../UIEventSource";
import {QueryParameters} from "../Web/QueryParameters";
import {LocalStorageSource} from "../Web/LocalStorageSource";
import {Utils} from "../../Utils";
import Locale from "../../UI/i18n/Locale";
import ElementsState from "./ElementsState";
import SelectedElementTagsUpdater from "../Actors/SelectedElementTagsUpdater";
import {Changes} from "../Osm/Changes";
import ChangeToElementsActor from "../Actors/ChangeToElementsActor";
import PendingChangesUploader from "../Actors/PendingChangesUploader";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { OsmConnection } from "../Osm/OsmConnection"
import { MangroveIdentity } from "../Web/MangroveReviews"
import { Store, UIEventSource } from "../UIEventSource"
import { QueryParameters } from "../Web/QueryParameters"
import { LocalStorageSource } from "../Web/LocalStorageSource"
import { Utils } from "../../Utils"
import Locale from "../../UI/i18n/Locale"
import ElementsState from "./ElementsState"
import SelectedElementTagsUpdater from "../Actors/SelectedElementTagsUpdater"
import { Changes } from "../Osm/Changes"
import ChangeToElementsActor from "../Actors/ChangeToElementsActor"
import PendingChangesUploader from "../Actors/PendingChangesUploader"
import * as translators from "../../assets/translators.json"
import {post} from "jquery";
import Maproulette from "../Maproulette";
import Maproulette from "../Maproulette"
/**
* The part of the state which keeps track of user-related stuff, e.g. the OSM-connection,
* which layers they enabled, ...
*/
export default class UserRelatedState extends ElementsState {
/**
The user credentials
*/
public osmConnection: OsmConnection;
public osmConnection: OsmConnection
/**
THe change handler
*/
public changes: Changes;
public changes: Changes
/**
* The key for mangrove
*/
public mangroveIdentity: MangroveIdentity;
public mangroveIdentity: MangroveIdentity
/**
* Maproulette connection
*/
public maprouletteConnection: Maproulette;
public maprouletteConnection: Maproulette
public readonly isTranslator: Store<boolean>
public readonly isTranslator : Store<boolean>;
public readonly installedUserThemes: Store<string[]>
constructor(layoutToUse: LayoutConfig, options?: { attemptLogin: true | boolean }) {
super(layoutToUse);
super(layoutToUse)
this.osmConnection = new OsmConnection({
dryRun: this.featureSwitchIsTesting,
@ -55,138 +52,147 @@ export default class UserRelatedState extends ElementsState {
undefined,
"Used to complete the login"
),
osmConfiguration: <'osm' | 'osm-test'>this.featureSwitchApiURL.data,
attemptLogin: options?.attemptLogin
osmConfiguration: <"osm" | "osm-test">this.featureSwitchApiURL.data,
attemptLogin: options?.attemptLogin,
})
const translationMode = this.osmConnection.GetPreference("translation-mode").sync(str => str === undefined ? undefined : str === "true", [], b => b === undefined ? undefined : b+"")
const translationMode = this.osmConnection.GetPreference("translation-mode").sync(
(str) => (str === undefined ? undefined : str === "true"),
[],
(b) => (b === undefined ? undefined : b + "")
)
translationMode.syncWith(Locale.showLinkToWeblate)
this.isTranslator = this.osmConnection.userDetails.map(ud => {
if(!ud.loggedIn){
return false;
this.isTranslator = this.osmConnection.userDetails.map((ud) => {
if (!ud.loggedIn) {
return false
}
const name= ud.name.toLowerCase().replace(/\s+/g, '')
return translators.contributors.some(c => c.contributor.toLowerCase().replace(/\s+/g, '') === name)
const name = ud.name.toLowerCase().replace(/\s+/g, "")
return translators.contributors.some(
(c) => c.contributor.toLowerCase().replace(/\s+/g, "") === name
)
})
this.isTranslator.addCallbackAndRunD(ud => {
if(ud){
this.isTranslator.addCallbackAndRunD((ud) => {
if (ud) {
Locale.showLinkToWeblate.setData(true)
}
});
})
this.changes = new Changes(this, layoutToUse?.isLeftRightSensitive() ?? false)
new ChangeToElementsActor(this.changes, this.allElements)
new PendingChangesUploader(this.changes, this.selectedElement);
new PendingChangesUploader(this.changes, this.selectedElement)
this.mangroveIdentity = new MangroveIdentity(
this.osmConnection.GetLongPreference("identity", "mangrove")
);
)
this.maprouletteConnection = new Maproulette();
this.maprouletteConnection = new Maproulette()
if (layoutToUse?.hideFromOverview) {
this.osmConnection.isLoggedIn.addCallbackAndRunD(loggedIn => {
this.osmConnection.isLoggedIn.addCallbackAndRunD((loggedIn) => {
if (loggedIn) {
this.osmConnection
.GetPreference("hidden-theme-" + layoutToUse?.id + "-enabled")
.setData("true");
return true;
.setData("true")
return true
}
})
}
if (this.layoutToUse !== undefined && !this.layoutToUse.official) {
console.log("Marking unofficial theme as visited")
this.osmConnection.GetLongPreference("unofficial-theme-" + this.layoutToUse.id)
.setData(JSON.stringify({
this.osmConnection.GetLongPreference("unofficial-theme-" + this.layoutToUse.id).setData(
JSON.stringify({
id: this.layoutToUse.id,
icon: this.layoutToUse.icon,
title: this.layoutToUse.title.translations,
shortDescription: this.layoutToUse.shortDescription.translations,
definition: this.layoutToUse["definition"]
}))
definition: this.layoutToUse["definition"],
})
)
}
this.InitializeLanguage();
this.InitializeLanguage()
new SelectedElementTagsUpdater(this)
this.installedUserThemes = this.InitInstalledUserThemes();
this.installedUserThemes = this.InitInstalledUserThemes()
}
private InitializeLanguage() {
const layoutToUse = this.layoutToUse;
Locale.language.syncWith(this.osmConnection.GetPreference("language"));
Locale.language
.addCallback((currentLanguage) => {
if (layoutToUse === undefined) {
return;
}
if(Locale.showLinkToWeblate.data){
return true; // Disable auto switching as we are in translators mode
}
if (this.layoutToUse.language.indexOf(currentLanguage) < 0) {
console.log(
"Resetting language to",
layoutToUse.language[0],
"as",
currentLanguage,
" is unsupported"
);
// The current language is not supported -> switch to a supported one
Locale.language.setData(layoutToUse.language[0]);
}
})
Locale.language.ping();
const layoutToUse = this.layoutToUse
Locale.language.syncWith(this.osmConnection.GetPreference("language"))
Locale.language.addCallback((currentLanguage) => {
if (layoutToUse === undefined) {
return
}
if (Locale.showLinkToWeblate.data) {
return true // Disable auto switching as we are in translators mode
}
if (this.layoutToUse.language.indexOf(currentLanguage) < 0) {
console.log(
"Resetting language to",
layoutToUse.language[0],
"as",
currentLanguage,
" is unsupported"
)
// The current language is not supported -> switch to a supported one
Locale.language.setData(layoutToUse.language[0])
}
})
Locale.language.ping()
}
private InitInstalledUserThemes(): Store<string[]>{
const prefix = "mapcomplete-unofficial-theme-";
private InitInstalledUserThemes(): Store<string[]> {
const prefix = "mapcomplete-unofficial-theme-"
const postfix = "-combined-length"
return this.osmConnection.preferencesHandler.preferences.map(prefs =>
return this.osmConnection.preferencesHandler.preferences.map((prefs) =>
Object.keys(prefs)
.filter(k => k.startsWith(prefix) && k.endsWith(postfix))
.map(k => k.substring(prefix.length, k.length - postfix.length))
.filter((k) => k.startsWith(prefix) && k.endsWith(postfix))
.map((k) => k.substring(prefix.length, k.length - postfix.length))
)
}
public GetUnofficialTheme(id: string): {
id: string
icon: string,
title: any,
shortDescription: any,
definition?: any,
isOfficial: boolean
} | undefined {
public GetUnofficialTheme(id: string):
| {
id: string
icon: string
title: any
shortDescription: any
definition?: any
isOfficial: boolean
}
| undefined {
console.log("GETTING UNOFFICIAL THEME")
const pref = this.osmConnection.GetLongPreference("unofficial-theme-"+id)
const pref = this.osmConnection.GetLongPreference("unofficial-theme-" + id)
const str = pref.data
if (str === undefined || str === "undefined" || str === "") {
pref.setData(null)
return undefined
}
try {
const value: {
id: string
icon: string,
title: any,
shortDescription: any,
definition?: any,
icon: string
title: any
shortDescription: any
definition?: any
isOfficial: boolean
} = JSON.parse(str)
value.isOfficial = false
return value;
return value
} catch (e) {
console.warn("Removing theme " + id + " as it could not be parsed from the preferences; the content is:", str)
console.warn(
"Removing theme " +
id +
" as it could not be parsed from the preferences; the content is:",
str
)
pref.setData(null)
return undefined
}
}
}
}

View file

@ -1,15 +1,14 @@
import {TagsFilter} from "./TagsFilter";
import {Or} from "./Or";
import {TagUtils} from "./TagUtils";
import {Tag} from "./Tag";
import {RegexTag} from "./RegexTag";
import { TagsFilter } from "./TagsFilter"
import { Or } from "./Or"
import { TagUtils } from "./TagUtils"
import { Tag } from "./Tag"
import { RegexTag } from "./RegexTag"
export class And extends TagsFilter {
public and: TagsFilter[]
constructor(and: TagsFilter[]) {
super();
super()
this.and = and
}
@ -21,11 +20,11 @@ export class And extends TagsFilter {
}
private static combine(filter: string, choices: string[]): string[] {
const values = [];
const values = []
for (const or of choices) {
values.push(filter + or);
values.push(filter + or)
}
return values;
return values
}
normalize() {
@ -43,11 +42,11 @@ export class And extends TagsFilter {
matchesProperties(tags: any): boolean {
for (const tagsFilter of this.and) {
if (!tagsFilter.matchesProperties(tags)) {
return false;
return false
}
}
return true;
return true
}
/**
@ -56,36 +55,37 @@ export class And extends TagsFilter {
* and.asOverpass() // => [ "[\"boundary\"=\"protected_area\"][\"protect_class\"!=\"98\"]" ]
*/
asOverpass(): string[] {
let allChoices: string[] = null;
let allChoices: string[] = null
for (const andElement of this.and) {
const andElementFilter = andElement.asOverpass();
const andElementFilter = andElement.asOverpass()
if (allChoices === null) {
allChoices = andElementFilter;
continue;
allChoices = andElementFilter
continue
}
const newChoices: string[] = [];
const newChoices: string[] = []
for (const choice of allChoices) {
newChoices.push(
...And.combine(choice, andElementFilter)
)
newChoices.push(...And.combine(choice, andElementFilter))
}
allChoices = newChoices;
allChoices = newChoices
}
return allChoices;
return allChoices
}
asHumanString(linkToWiki: boolean, shorten: boolean, properties) {
return this.and.map(t => t.asHumanString(linkToWiki, shorten, properties)).filter(x => x !== "").join("&");
return this.and
.map((t) => t.asHumanString(linkToWiki, shorten, properties))
.filter((x) => x !== "")
.join("&")
}
isUsableAsAnswer(): boolean {
for (const t of this.and) {
if (!t.isUsableAsAnswer()) {
return false;
return false
}
}
return true;
return true
}
/**
@ -107,45 +107,44 @@ export class And extends TagsFilter {
*/
shadows(other: TagsFilter): boolean {
if (!(other instanceof And)) {
return false;
return false
}
for (const selfTag of this.and) {
let matchFound = false;
let matchFound = false
for (const otherTag of other.and) {
matchFound = selfTag.shadows(otherTag);
matchFound = selfTag.shadows(otherTag)
if (matchFound) {
break;
break
}
}
if (!matchFound) {
return false;
return false
}
}
for (const otherTag of other.and) {
let matchFound = false;
let matchFound = false
for (const selfTag of this.and) {
matchFound = selfTag.shadows(otherTag);
matchFound = selfTag.shadows(otherTag)
if (matchFound) {
break;
break
}
}
if (!matchFound) {
return false;
return false
}
}
return true;
return true
}
usedKeys(): string[] {
return [].concat(...this.and.map(subkeys => subkeys.usedKeys()));
return [].concat(...this.and.map((subkeys) => subkeys.usedKeys()))
}
usedTags(): { key: string; value: string }[] {
return [].concat(...this.and.map(subkeys => subkeys.usedTags()));
return [].concat(...this.and.map((subkeys) => subkeys.usedTags()))
}
asChange(properties: any): { k: string; v: string }[] {
@ -153,7 +152,7 @@ export class And extends TagsFilter {
for (const tagsFilter of this.and) {
result.push(...tagsFilter.asChange(properties))
}
return result;
return result
}
/**
@ -187,7 +186,7 @@ export class And extends TagsFilter {
continue
}
if (r === false) {
return false;
return false
}
newAnds.push(r)
continue
@ -203,7 +202,6 @@ export class And extends TagsFilter {
continue
}
if (!value && tag.shadows(knownExpression)) {
/**
* We know that knownExpression is unmet.
* if the tag shadows 'knownExpression' (which is the case when control flows gets here),
@ -228,49 +226,50 @@ export class And extends TagsFilter {
if (this.and.length === 0) {
return true
}
const optimizedRaw = this.and.map(t => t.optimize())
.filter(t => t !== true /* true is the neutral element in an AND, we drop them*/)
if (optimizedRaw.some(t => t === false)) {
const optimizedRaw = this.and
.map((t) => t.optimize())
.filter((t) => t !== true /* true is the neutral element in an AND, we drop them*/)
if (optimizedRaw.some((t) => t === false)) {
// We have an AND with a contained false: this is always 'false'
return false;
return false
}
const optimized = <TagsFilter[]>optimizedRaw;
const optimized = <TagsFilter[]>optimizedRaw
{
// Conflicting keys do return false
const properties: object = {}
const properties: object = {}
for (const opt of optimized) {
if (opt instanceof Tag) {
properties[opt.key] = opt.value
}
}
for (const opt of optimized) {
if(opt instanceof Tag ){
const k = opt.key
const v = properties[k]
if(v === undefined){
continue
}
if(v !== opt.value){
// detected an internal conflict
return false
}
}
if(opt instanceof RegexTag ){
const k = opt.key
if(typeof k !== "string"){
continue
}
const v = properties[k]
if(v === undefined){
continue
}
if(v !== opt.value){
// detected an internal conflict
return false
}
}
}
for (const opt of optimized) {
if (opt instanceof Tag) {
const k = opt.key
const v = properties[k]
if (v === undefined) {
continue
}
if (v !== opt.value) {
// detected an internal conflict
return false
}
}
if (opt instanceof RegexTag) {
const k = opt.key
if (typeof k !== "string") {
continue
}
const v = properties[k]
if (v === undefined) {
continue
}
if (v !== opt.value) {
// detected an internal conflict
return false
}
}
}
}
const newAnds: TagsFilter[] = []
@ -287,7 +286,7 @@ export class And extends TagsFilter {
}
{
let dirty = false;
let dirty = false
do {
const cleanedContainedOrs: Or[] = []
outer: for (let containedOr of containedOrs) {
@ -310,8 +309,8 @@ export class And extends TagsFilter {
}
// the 'or' dissolved into a normal tag -> it has to be added to the newAnds
newAnds.push(cleaned)
dirty = true; // rerun this algo later on
continue outer;
dirty = true // rerun this algo later on
continue outer
}
cleanedContainedOrs.push(containedOr)
}
@ -319,30 +318,32 @@ export class And extends TagsFilter {
} while (dirty)
}
containedOrs = containedOrs.filter(ca => {
containedOrs = containedOrs.filter((ca) => {
const isShadowed = TagUtils.containsEquivalents(newAnds, ca.or)
// If 'isShadowed', then at least one part of the 'OR' is matched by the outer and, so this means that this OR isn't needed at all
// XY & (XY | AB) === XY
return !isShadowed;
return !isShadowed
})
// Extract common keys from the OR
if (containedOrs.length === 1) {
newAnds.push(containedOrs[0])
} else if (containedOrs.length > 1) {
let commonValues: TagsFilter [] = containedOrs[0].or
let commonValues: TagsFilter[] = containedOrs[0].or
for (let i = 1; i < containedOrs.length && commonValues.length > 0; i++) {
const containedOr = containedOrs[i];
commonValues = commonValues.filter(cv => containedOr.or.some(candidate => candidate.shadows(cv)))
const containedOr = containedOrs[i]
commonValues = commonValues.filter((cv) =>
containedOr.or.some((candidate) => candidate.shadows(cv))
)
}
if (commonValues.length === 0) {
newAnds.push(...containedOrs)
} else {
const newOrs: TagsFilter[] = []
for (const containedOr of containedOrs) {
const elements = containedOr.or
.filter(candidate => !commonValues.some(cv => cv.shadows(candidate)))
const elements = containedOr.or.filter(
(candidate) => !commonValues.some((cv) => cv.shadows(candidate))
)
newOrs.push(Or.construct(elements))
}
@ -371,12 +372,11 @@ export class And extends TagsFilter {
}
isNegative(): boolean {
return !this.and.some(t => !t.isNegative());
return !this.and.some((t) => !t.isNegative())
}
visit(f: (TagsFilter: any) => void) {
f(this)
this.and.forEach(sub => sub.visit(f))
this.and.forEach((sub) => sub.visit(f))
}
}
}

View file

@ -1,14 +1,18 @@
import {TagsFilter} from "./TagsFilter";
import { TagsFilter } from "./TagsFilter"
export default class ComparingTag implements TagsFilter {
private readonly _key: string;
private readonly _predicate: (value: string) => boolean;
private readonly _representation: string;
private readonly _key: string
private readonly _predicate: (value: string) => boolean
private readonly _representation: string
constructor(key: string, predicate: (value: string | undefined) => boolean, representation: string = "") {
this._key = key;
this._predicate = predicate;
this._representation = representation;
constructor(
key: string,
predicate: (value: string | undefined) => boolean,
representation: string = ""
) {
this._key = key
this._predicate = predicate
this._representation = representation
}
asChange(properties: any): { k: string; v: string }[] {
@ -24,16 +28,16 @@ export default class ComparingTag implements TagsFilter {
}
shadows(other: TagsFilter): boolean {
return other === this;
return other === this
}
isUsableAsAnswer(): boolean {
return false;
return false
}
/**
* Checks if the properties match
*
*
* const t = new ComparingTag("key", (x => Number(x) < 42))
* t.matchesProperties({key: 42}) // => false
* t.matchesProperties({key: 41}) // => true
@ -41,26 +45,26 @@ export default class ComparingTag implements TagsFilter {
* t.matchesProperties({differentKey: 42}) // => false
*/
matchesProperties(properties: any): boolean {
return this._predicate(properties[this._key]);
return this._predicate(properties[this._key])
}
usedKeys(): string[] {
return [this._key];
return [this._key]
}
usedTags(): { key: string; value: string }[] {
return [];
return []
}
optimize(): TagsFilter | boolean {
return this;
return this
}
isNegative(): boolean {
return true;
return true
}
visit(f: (TagsFilter) => void) {
f(this)
}
}
}

Some files were not shown because too many files have changed in this diff Show more