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" using: "composite"
steps: steps:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v1.4.6 uses: actions/setup-node@v3
with: with:
node-version: '16' node-version: "16"
cache: "npm"
cache-dependency-path: package-lock.json
- name: install deps - name: install deps
run: npm ci run: npm ci

View file

@ -1,6 +1,5 @@
name: Deployment on pietervdvn name: Deployment on pietervdvn
on: on: push
push
jobs: jobs:
build: build:
@ -28,13 +27,15 @@ jobs:
cd pietervdvn.github.io cd pietervdvn.github.io
git pull git pull
- name: get branch name
run: echo TARGET_BRANCH=${GITHUB_REF:11} >> $GITHUB_ENV
- name: "Copying files" - name: "Copying files"
run: | run: |
echo "Deploying" echo "Deploying"
TARGET=${GITHUB_REF:11} rm -rf pietervdvn.github.io/mc/${{ env.TARGET_BRANCH }}/*
rm -rf pietervdvn.github.io/mc/$TARGET/* mkdir -p pietervdvn.github.io/mc/${{ env.TARGET_BRANCH }}/
mkdir -p pietervdvn.github.io/mc/$TARGET/ cp -r dist/* pietervdvn.github.io/mc/${{ env.TARGET_BRANCH }}/
cp -r dist/* pietervdvn.github.io/mc/$TARGET/
cd pietervdvn.github.io/ cd pietervdvn.github.io/
git add * git add *
if git status | grep -q "Changes to be committed" if git status | grep -q "Changes to be committed"
@ -44,3 +45,13 @@ jobs:
else else
echo "No changes to commit" echo "No changes to commit"
fi 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: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Setup and validate themes - name: Setup and validate themes
uses: ./.github/actions/setup-and-validate 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,22 +1,24 @@
import * as known_themes from "../assets/generated/known_layers_and_themes.json" import * as known_themes from "../assets/generated/known_layers_and_themes.json"
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"
import LayerConfig from "../Models/ThemeConfig/LayerConfig"; import LayerConfig from "../Models/ThemeConfig/LayerConfig"
import BaseUIElement from "../UI/BaseUIElement"; import BaseUIElement from "../UI/BaseUIElement"
import Combine from "../UI/Base/Combine"; import Combine from "../UI/Base/Combine"
import Title from "../UI/Base/Title"; import Title from "../UI/Base/Title"
import List from "../UI/Base/List"; import List from "../UI/Base/List"
import DependencyCalculator from "../Models/ThemeConfig/DependencyCalculator"; import DependencyCalculator from "../Models/ThemeConfig/DependencyCalculator"
import Constants from "../Models/Constants"; import Constants from "../Models/Constants"
import {Utils} from "../Utils"; import { Utils } from "../Utils"
import Link from "../UI/Base/Link"; import Link from "../UI/Base/Link"
import {LayoutConfigJson} from "../Models/ThemeConfig/Json/LayoutConfigJson"; import { LayoutConfigJson } from "../Models/ThemeConfig/Json/LayoutConfigJson"
import {LayerConfigJson} from "../Models/ThemeConfig/Json/LayerConfigJson"; import { LayerConfigJson } from "../Models/ThemeConfig/Json/LayerConfigJson"
export class AllKnownLayouts { export class AllKnownLayouts {
public static allKnownLayouts: Map<string, LayoutConfig> = AllKnownLayouts.AllLayouts(); public static allKnownLayouts: Map<string, LayoutConfig> = AllKnownLayouts.AllLayouts()
public static layoutsList: LayoutConfig[] = AllKnownLayouts.GenerateOrderedList(AllKnownLayouts.allKnownLayouts); public static layoutsList: LayoutConfig[] = AllKnownLayouts.GenerateOrderedList(
AllKnownLayouts.allKnownLayouts
)
// Must be below the list... // 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?: { public static AllPublicLayers(options?: {
includeInlineLayers: true | boolean includeInlineLayers: true | boolean
@ -28,7 +30,7 @@ export class AllKnownLayouts {
allLayers.push(layer) allLayers.push(layer)
}) })
if (options?.includeInlineLayers ?? true) { 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) { for (const layout of publicLayouts) {
if (layout.hideFromOverview) { if (layout.hideFromOverview) {
continue continue
@ -40,7 +42,6 @@ export class AllKnownLayouts {
seendIds.add(layer.id) seendIds.add(layer.id)
allLayers.push(layer) allLayers.push(layer)
} }
} }
} }
@ -52,11 +53,14 @@ export class AllKnownLayouts {
*/ */
public static themesUsingLayer(id: string, publicOnly = true): LayoutConfig[] { public static themesUsingLayer(id: string, publicOnly = true): LayoutConfig[] {
const themes = AllKnownLayouts.layoutsList const themes = AllKnownLayouts.layoutsList
.filter(l => !(publicOnly && l.hideFromOverview) && l.id !== "personal") .filter((l) => !(publicOnly && l.hideFromOverview) && l.id !== "personal")
.map(theme => ({theme, minzoom: theme.layers.find(layer => layer.id === id)?.minzoom})) .map((theme) => ({
.filter(obj => obj.minzoom !== undefined) theme,
minzoom: theme.layers.find((layer) => layer.id === id)?.minzoom,
}))
.filter((obj) => obj.minzoom !== undefined)
themes.sort((th0, th1) => th1.minzoom - th0.minzoom) 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 * @param callback
* @constructor * @constructor
*/ */
public static GenOverviewsForSingleLayer(callback: (layer: LayerConfig, element: BaseUIElement, inlineSource: string) => void): void { public static GenOverviewsForSingleLayer(
const allLayers: LayerConfig[] = Array.from(AllKnownLayouts.sharedLayers.values()) callback: (layer: LayerConfig, element: BaseUIElement, inlineSource: string) => void
.filter(layer => Constants.priviliged_layers.indexOf(layer.id) < 0) ): 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>() const builtinLayerIds: Set<string> = new Set<string>()
allLayers.forEach(l => builtinLayerIds.add(l.id)) allLayers.forEach((l) => builtinLayerIds.add(l.id))
const inlineLayers = new Map<string, string>(); const inlineLayers = new Map<string, string>()
for (const layout of Array.from(AllKnownLayouts.allKnownLayouts.values())) { for (const layout of Array.from(AllKnownLayouts.allKnownLayouts.values())) {
if (layout.hideFromOverview) { if (layout.hideFromOverview) {
@ -78,7 +85,6 @@ export class AllKnownLayouts {
} }
for (const layer of layout.layers) { for (const layer of layout.layers) {
if (Constants.priviliged_layers.indexOf(layer.id) >= 0) { if (Constants.priviliged_layers.indexOf(layer.id) >= 0) {
continue continue
} }
@ -113,7 +119,6 @@ export class AllKnownLayouts {
} }
} }
// Determine the cross-dependencies // Determine the cross-dependencies
const layerIsNeededBy: Map<string, string[]> = new Map<string, string[]>() const layerIsNeededBy: Map<string, string[]> = new Map<string, string[]>()
@ -125,12 +130,14 @@ export class AllKnownLayouts {
} }
layerIsNeededBy.get(dependency).push(layer.id) layerIsNeededBy.get(dependency).push(layer.id)
} }
} }
allLayers.forEach((layer) => { 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)) callback(layer, element, inlineLayers.get(layer.id))
}) })
} }
@ -146,11 +153,12 @@ export class AllKnownLayouts {
} }
} }
const allLayers: LayerConfig[] = Array.from(AllKnownLayouts.sharedLayers.values()) const allLayers: LayerConfig[] = Array.from(AllKnownLayouts.sharedLayers.values()).filter(
.filter(layer => Constants.priviliged_layers.indexOf(layer.id) < 0) (layer) => Constants.priviliged_layers.indexOf(layer.id) < 0
)
const builtinLayerIds: Set<string> = new Set<string>() 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[]>() const themesPerLayer = new Map<string, string[]>()
@ -166,7 +174,6 @@ export class AllKnownLayouts {
} }
} }
// Determine the cross-dependencies // Determine the cross-dependencies
const layerIsNeededBy: Map<string, string[]> = new Map<string, string[]>() const layerIsNeededBy: Map<string, string[]> = new Map<string, string[]>()
@ -178,25 +185,32 @@ export class AllKnownLayouts {
} }
layerIsNeededBy.get(dependency).push(layer.id) layerIsNeededBy.get(dependency).push(layer.id)
} }
} }
return new Combine([ return new Combine([
new Title("Special and other useful layers", 1), 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.", "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 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 ...Constants.priviliged_layers
.map(id => AllKnownLayouts.sharedLayers.get(id)) .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((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), new Title("Normal layers", 1),
"The following layers are included in MapComplete:", "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 { public static GenerateDocumentationForTheme(theme: LayoutConfig): BaseUIElement {
@ -204,37 +218,42 @@ export class AllKnownLayouts {
new Title(new Combine([theme.title, "(", theme.id + ")"]), 2), new Title(new Combine([theme.title, "(", theme.id + ")"]), 2),
theme.description, theme.description,
"This theme contains the following layers:", "This theme contains the following layers:",
new List(theme.layers.map(l => l.id)), new List(theme.layers.map((l) => l.id)),
"Available languages:", "Available languages:",
new List(theme.language) new List(theme.language),
]) ])
} }
public static getSharedLayers(): Map<string, LayerConfig> { 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"]) { for (const layer of known_themes["layers"]) {
try { try {
// @ts-ignore // @ts-ignore
const parsed = new LayerConfig(layer, "shared_layers") const parsed = new LayerConfig(layer, "shared_layers")
sharedLayers.set(layer.id, parsed); sharedLayers.set(layer.id, parsed)
} catch (e) { } catch (e) {
if (!Utils.runningFromConsole) { 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> { 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"]) { for (const layer of known_themes["layers"]) {
// @ts-ignore // @ts-ignore
sharedLayers.set(layer.id, layer); sharedLayers.set(layer.id, layer)
} }
return sharedLayers; return sharedLayers
} }
private static GenerateOrderedList(allKnownLayouts: Map<string, LayoutConfig>): LayoutConfig[] { private static GenerateOrderedList(allKnownLayouts: Map<string, LayoutConfig>): LayoutConfig[] {
@ -242,28 +261,26 @@ export class AllKnownLayouts {
allKnownLayouts.forEach((layout) => { allKnownLayouts.forEach((layout) => {
list.push(layout) list.push(layout)
}) })
return list; return list
} }
private static AllLayouts(): Map<string, LayoutConfig> { 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"]) { for (const layoutConfigJson of known_themes["themes"]) {
const layout = new LayoutConfig(<LayoutConfigJson>layoutConfigJson, true) const layout = new LayoutConfig(<LayoutConfigJson>layoutConfigJson, true)
dict.set(layout.id, layout) dict.set(layout.id, layout)
for (let i = 0; i < layout.layers.length; i++) { for (let i = 0; i < layout.layers.length; i++) {
let layer = layout.layers[i]; let layer = layout.layers[i]
if (typeof (layer) === "string") { if (typeof layer === "string") {
layer = AllKnownLayouts.sharedLayers.get(layer); layer = AllKnownLayouts.sharedLayers.get(layer)
layout.layers[i] = layer layout.layers[i] = layer
if (layer === undefined) { if (layer === undefined) {
console.log("Defined layers are ", AllKnownLayouts.sharedLayers.keys()) console.log("Defined layers are ", AllKnownLayouts.sharedLayers.keys())
throw `Layer ${layer} was not found or defined - probably a type was made` 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 questions from "../assets/tagRenderings/questions.json"
import * as icons from "../assets/tagRenderings/icons.json"; import * as icons from "../assets/tagRenderings/icons.json"
import {Utils} from "../Utils"; import { Utils } from "../Utils"
import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig"; import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig"
import {TagRenderingConfigJson} from "../Models/ThemeConfig/Json/TagRenderingConfigJson"; import { TagRenderingConfigJson } from "../Models/ThemeConfig/Json/TagRenderingConfigJson"
import BaseUIElement from "../UI/BaseUIElement"; import BaseUIElement from "../UI/BaseUIElement"
import Combine from "../UI/Base/Combine"; import Combine from "../UI/Base/Combine"
import Title from "../UI/Base/Title"; import Title from "../UI/Base/Title"
import {FixedUiElement} from "../UI/Base/FixedUiElement"; import { FixedUiElement } from "../UI/Base/FixedUiElement"
import List from "../UI/Base/List"; import List from "../UI/Base/List"
export default class SharedTagRenderings { export default class SharedTagRenderings {
public static SharedTagRendering: Map<string, TagRenderingConfig> =
public static SharedTagRendering: Map<string, TagRenderingConfig> = SharedTagRenderings.generatedSharedFields(); SharedTagRenderings.generatedSharedFields()
public static SharedTagRenderingJson: Map<string, TagRenderingConfigJson> = SharedTagRenderings.generatedSharedFieldsJsons(); public static SharedTagRenderingJson: Map<string, TagRenderingConfigJson> =
public static SharedIcons: Map<string, TagRenderingConfig> = SharedTagRenderings.generatedSharedFields(true); SharedTagRenderings.generatedSharedFieldsJsons()
public static SharedIcons: Map<string, TagRenderingConfig> =
SharedTagRenderings.generatedSharedFields(true)
private static generatedSharedFields(iconsOnly = false): Map<string, TagRenderingConfig> { private static generatedSharedFields(iconsOnly = false): Map<string, TagRenderingConfig> {
const configJsons = SharedTagRenderings.generatedSharedFieldsJsons(iconsOnly) const configJsons = SharedTagRenderings.generatedSharedFieldsJsons(iconsOnly)
const d = new Map<string, TagRenderingConfig>() const d = new Map<string, TagRenderingConfig>()
for (const key of Array.from(configJsons.keys())) { for (const key of Array.from(configJsons.keys())) {
try { try {
d.set(key, new TagRenderingConfig(configJsons.get(key), `SharedTagRenderings.${key}`)) d.set(
key,
new TagRenderingConfig(configJsons.get(key), `SharedTagRenderings.${key}`)
)
} catch (e) { } catch (e) {
if (!Utils.runningFromConsole) { 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 return d
} }
private static generatedSharedFieldsJsons(iconsOnly = false): Map<string, TagRenderingConfigJson> { private static generatedSharedFieldsJsons(
const dict = new Map<string, TagRenderingConfigJson>(); iconsOnly = false
): Map<string, TagRenderingConfigJson> {
const dict = new Map<string, TagRenderingConfigJson>()
if (!iconsOnly) { if (!iconsOnly) {
for (const key in questions) { for (const key in questions) {
@ -53,13 +64,16 @@ export default class SharedTagRenderings {
if (key === "id") { if (key === "id") {
return return
} }
value.id = value.id ?? key; value.id = value.id ?? key
if (value["builtin"] !== undefined) { if (value["builtin"] !== undefined) {
if (value["override"] == undefined) { if (value["override"] == undefined) {
throw "HUH? Why whould you want to reuse a builtin if one doesn't override? In questions.json/"+key throw (
"HUH? Why whould you want to reuse a builtin if one doesn't override? In questions.json/" +
key
)
} }
if (typeof value["builtin"] !== "string") { if (typeof value["builtin"] !== "string") {
return; return
} }
// This is a really funny situation: we extend another tagRendering! // This is a really funny situation: we extend another tagRendering!
const parent = Utils.Clone(dict.get(value["builtin"])) const parent = Utils.Clone(dict.get(value["builtin"]))
@ -73,36 +87,31 @@ export default class SharedTagRenderings {
} }
}) })
return dict
return dict;
} }
public static HelpText(): BaseUIElement { public static HelpText(): BaseUIElement {
return new Combine([ return new Combine([
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"), ]).SetClass("flex flex-col"),
... Array.from( SharedTagRenderings.SharedTagRendering.keys()).map(key => { ...Array.from(SharedTagRenderings.SharedTagRendering.keys()).map((key) => {
const tr = SharedTagRenderings.SharedTagRendering.get(key) const tr = SharedTagRenderings.SharedTagRendering.get(key)
let mappings: BaseUIElement = undefined let mappings: BaseUIElement = undefined
if (tr.mappings?.length > 0) { if (tr.mappings?.length > 0) {
mappings = new List(tr.mappings.map(m => m.then.textFor("en"))) mappings = new List(tr.mappings.map((m) => m.then.textFor("en")))
} }
return new Combine([ return new Combine([
new Title(key), new Title(key),
tr.render?.textFor("en"), tr.render?.textFor("en"),
tr.question?.textFor("en") ?? new FixedUiElement("Read-only tagrendering").SetClass("font-bold"), tr.question?.textFor("en") ??
mappings new FixedUiElement("Read-only tagrendering").SetClass("font-bold"),
mappings,
]).SetClass("flex flex-col") ]).SetClass("flex flex-col")
}),
})
]).SetClass("flex flex-col") ]).SetClass("flex flex-col")
} }
} }

View file

@ -1,29 +1,27 @@
import {existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync} from "fs"; import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "fs"
import ScriptUtils from "../../scripts/ScriptUtils"; import ScriptUtils from "../../scripts/ScriptUtils"
import {Utils} from "../../Utils"; import { Utils } from "../../Utils"
ScriptUtils.fixUtils() ScriptUtils.fixUtils()
class StatsDownloader { 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 = ".") { constructor(targetDirectory = ".") {
this._targetDirectory = targetDirectory; this._targetDirectory = targetDirectory
} }
public async DownloadStats(startYear = 2020, startMonth = 5) { public async DownloadStats(startYear = 2020, startMonth = 5, startDay = 1) {
const today = new Date()
const today = new Date();
const currentYear = today.getFullYear() const currentYear = today.getFullYear()
const currentMonth = today.getMonth() + 1 const currentMonth = today.getMonth() + 1
for (let year = startYear; year <= currentYear; year++) { for (let year = startYear; year <= currentYear; year++) {
for (let month = 1; month <= 12; month++) { for (let month = 1; month <= 12; month++) {
if (year === startYear && month < startMonth) { if (year === startYear && month < startMonth) {
continue; continue
} }
if (year === currentYear && month > currentMonth) { if (year === currentYear && month > currentMonth) {
@ -32,14 +30,16 @@ class StatsDownloader {
const pathM = `${this._targetDirectory}/stats.${year}-${month}.json` const pathM = `${this._targetDirectory}/stats.${year}-${month}.json`
if (existsSync(pathM)) { if (existsSync(pathM)) {
continue; continue
} }
const features = [] 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()) { if (year === currentYear && month === currentMonth && day === today.getDate()) {
break; monthIsFinished = false
break
} }
{ {
const date = new Date(year, month - 1, day) const date = new Date(year, month - 1, day)
@ -48,10 +48,22 @@ class StatsDownloader {
continue 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)) { if (existsSync(path)) {
features.push(...JSON.parse(readFileSync(path, "UTF-8"))) let features = JSON.parse(readFileSync(path, "UTF-8"))
console.log("Loaded ", path, "from disk, got", features.length, "features now") 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 continue
} }
let dayFeatures: any[] = undefined let dayFeatures: any[] = undefined
@ -59,47 +71,72 @@ class StatsDownloader {
dayFeatures = await this.DownloadStatsForDay(year, month, day, path) dayFeatures = await this.DownloadStatsForDay(year, month, day, path)
} catch (e) { } catch (e) {
console.error(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) dayFeatures = await this.DownloadStatsForDay(year, month, day, path)
} }
writeFileSync(path, JSON.stringify(dayFeatures)) writeFileSync(path, JSON.stringify(dayFeatures))
features.push(...dayFeatures) features.push(...dayFeatures)
} }
if (monthIsFinished) {
writeFileSync(pathM, JSON.stringify({ features })) writeFileSync(pathM, JSON.stringify({ features }))
for (const writtenFile of writtenFiles) {
unlinkSync(writtenFile)
}
}
}
startDay = 1
} }
} }
} public async DownloadStatsForDay(
year: number,
public async DownloadStatsForDay(year: number, month: number, day: number, path: string): Promise<any[]> { month: number,
day: number,
let page = 1; path: string
): Promise<any[]> {
let page = 1
let allFeatures = [] let allFeatures = []
let endDay = new Date(year, month - 1 /* Zero-indexed: 0 = january*/, day + 1); 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 endDate = `${endDay.getFullYear()}-${Utils.TwoDigits(
let url = this.urlTemplate.replace("{start_date}", year + "-" + Utils.TwoDigits(month) + "-" + Utils.TwoDigits(day)) 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("{end_date}", endDate)
.replace("{page}", "" + page) .replace("{page}", "" + page)
let headers = { let headers = {
'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0', "User-Agent":
'Accept-Language': 'en-US,en;q=0.5', "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0",
'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', "Accept-Language": "en-US,en;q=0.5",
'Content-Type': 'application/json', Referer:
'Authorization': 'Token 6e422e2afedb79ef66573982012000281f03dc91', "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",
'DNT': '1', "Content-Type": "application/json",
'Connection': 'keep-alive', Authorization: "Token 6e422e2afedb79ef66573982012000281f03dc91",
'TE': 'Trailers', DNT: "1",
'Pragma': 'no-cache', Connection: "keep-alive",
'Cache-Control': 'no-cache' TE: "Trailers",
Pragma: "no-cache",
"Cache-Control": "no-cache",
} }
while (url) { 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) const result = await Utils.downloadJson(url, headers)
page++; page++
allFeatures.push(...result.features) allFeatures.push(...result.features)
if (result.features === undefined) { if (result.features === undefined) {
console.log("ERROR", result) console.log("ERROR", result)
@ -107,58 +144,59 @@ class StatsDownloader {
} }
url = result.next 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 = Utils.NoNull(allFeatures)
allFeatures.forEach(f => { allFeatures.forEach((f) => {
f.properties = { ...f.properties, ...f.properties.metadata } f.properties = { ...f.properties, ...f.properties.metadata }
delete f.properties.metadata delete f.properties.metadata
f.properties.id = f.id f.properties.id = f.id
}) })
return allFeatures return allFeatures
} }
} }
interface ChangeSetData { interface ChangeSetData {
"id": number, id: number
"type": "Feature", type: "Feature"
"geometry": { geometry: {
"type": "Polygon", type: "Polygon"
"coordinates": [number, number][][] coordinates: [number, number][][]
}, }
"properties": { properties: {
"check_user": null, check_user: null
"reasons": [], reasons: []
"tags": [], tags: []
"features": [], features: []
"user": string, user: string
"uid": string, uid: string
"editor": string, editor: string
"comment": string, comment: string
"comments_count": number, comments_count: number
"source": string, source: string
"imagery_used": string, imagery_used: string
"date": string, date: string
"reviewed_features": [], reviewed_features: []
"create": number, create: number
"modify": number, modify: number
"delete": number, delete: number
"area": number, area: number
"is_suspect": boolean, is_suspect: boolean
"harmful": any, harmful: any
"checked": boolean, checked: boolean
"check_date": any, check_date: any
"metadata": { metadata: {
"host": string, host: string
"theme": string, theme: string
"imagery": string, imagery: string
"language": string language: string
} }
} }
} }
async function main(): Promise<void> { async function main(): Promise<void> {
if (!existsSync("graphs")) { if (!existsSync("graphs")) {
mkdirSync("graphs") mkdirSync("graphs")
@ -167,6 +205,7 @@ async function main(): Promise<void> {
const targetDir = "Docs/Tools/stats" const targetDir = "Docs/Tools/stats"
let year = 2020 let year = 2020
let month = 5 let month = 5
let day = 1
if (!isNaN(Number(process.argv[2]))) { if (!isNaN(Number(process.argv[2]))) {
year = Number(process.argv[2]) year = Number(process.argv[2])
} }
@ -174,31 +213,40 @@ async function main(): Promise<void> {
month = Number(process.argv[3]) month = Number(process.argv[3])
} }
if (!isNaN(Number(process.argv[4]))) {
day = Number(process.argv[4])
}
do { do {
try { try {
await new StatsDownloader(targetDir).DownloadStats(year, month, day)
await new StatsDownloader(targetDir).DownloadStats(year, month)
break break
} catch (e) { } catch (e) {
console.log(e) console.log(e)
} }
} while (true) } while (true)
const allPaths = readdirSync(targetDir) const allPaths = readdirSync(targetDir).filter(
.filter(p => p.startsWith("stats.") && p.endsWith(".json")); (p) => p.startsWith("stats.") && p.endsWith(".json")
let allFeatures: ChangeSetData[] = [].concat(...allPaths )
.map(path => JSON.parse(readFileSync("Docs/Tools/stats/" + path, "utf-8")).features)); let allFeatures: ChangeSetData[] = [].concat(
allFeatures = allFeatures.filter(f => f?.properties !== undefined && (f.properties.editor === null || f.properties.editor.toLowerCase().startsWith("mapcomplete"))) ...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) { if (process.argv.indexOf("--no-graphs") >= 0) {
return 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)) 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","stats.2022-9-01.day.json","stats.2022-9-02.day.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"
]

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

View file

@ -1,66 +1,77 @@
import BaseLayer from "../../Models/BaseLayer"; import BaseLayer from "../../Models/BaseLayer"
import {Store, Stores} from "../UIEventSource"; import { Store, Stores } from "../UIEventSource"
import Loc from "../../Models/Loc"; import Loc from "../../Models/Loc"
import {GeoOperations} from "../GeoOperations"; import { GeoOperations } from "../GeoOperations"
import * as editorlayerindex from "../../assets/editor-layer-index.json"; import * as editorlayerindex from "../../assets/editor-layer-index.json"
import * as L from "leaflet"; import * as L from "leaflet"
import {TileLayer} from "leaflet"; import { TileLayer } from "leaflet"
import * as X from "leaflet-providers"; import * as X from "leaflet-providers"
import {Utils} from "../../Utils"; import { Utils } from "../../Utils"
import {AvailableBaseLayersObj} from "./AvailableBaseLayers"; import { AvailableBaseLayersObj } from "./AvailableBaseLayers"
import {BBox} from "../BBox"; import { BBox } from "../BBox"
export default class AvailableBaseLayersImplementation implements AvailableBaseLayersObj { export default class AvailableBaseLayersImplementation implements AvailableBaseLayersObj {
public readonly osmCarto: BaseLayer = {
public readonly osmCarto: BaseLayer =
{
id: "osm", id: "osm",
name: "OpenStreetMap", name: "OpenStreetMap",
layer: () => AvailableBaseLayersImplementation.CreateBackgroundLayer("osm", "OpenStreetMap", layer: () =>
"https://tile.openstreetmap.org/{z}/{x}/{y}.png", "OpenStreetMap", "https://openStreetMap.org/copyright", AvailableBaseLayersImplementation.CreateBackgroundLayer(
"osm",
"OpenStreetMap",
"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"OpenStreetMap",
"https://openStreetMap.org/copyright",
19, 19,
false, false), false,
false
),
feature: null, feature: null,
max_zoom: 19, max_zoom: 19,
min_zoom: 0, min_zoom: 0,
isBest: true, // Of course, OpenStreetMap is the best map! isBest: true, // Of course, OpenStreetMap is the best map!
category: "osmbasedmap" category: "osmbasedmap",
} }
public readonly layerOverview = AvailableBaseLayersImplementation.LoadRasterIndex().concat(AvailableBaseLayersImplementation.LoadProviderIndex()); public readonly layerOverview = AvailableBaseLayersImplementation.LoadRasterIndex().concat(
public readonly globalLayers = this.layerOverview.filter(layer => layer.feature?.geometry === undefined || layer.feature?.geometry === null) AvailableBaseLayersImplementation.LoadProviderIndex()
public readonly localLayers = this.layerOverview.filter(layer => layer.feature?.geometry !== undefined && layer.feature?.geometry !== null) )
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[] { private static LoadRasterIndex(): BaseLayer[] {
const layers: BaseLayer[] = [] const layers: BaseLayer[] = []
// @ts-ignore // @ts-ignore
const features = editorlayerindex.features; const features = editorlayerindex.features
for (const i in features) { for (const i in features) {
const layer = features[i]; const layer = features[i]
const props = layer.properties; const props = layer.properties
if (props.type === "bing") { if (props.type === "bing") {
// A lot of work to implement - see https://github.com/pietervdvn/MapComplete/issues/648 // A lot of work to implement - see https://github.com/pietervdvn/MapComplete/issues/648
continue; continue
} }
if (props.id === "MAPNIK") { if (props.id === "MAPNIK") {
// Already added by default // Already added by default
continue; continue
} }
if (props.overlay) { if (props.overlay) {
continue; continue
} }
if (props.url.toLowerCase().indexOf("apikey") > 0) { if (props.url.toLowerCase().indexOf("apikey") > 0) {
continue; continue
} }
if (props.max_zoom < 19) { if (props.max_zoom < 19) {
// We want users to zoom to level 19 when adding a point // 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 // 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) { if (props.name === undefined) {
@ -68,8 +79,8 @@ export default class AvailableBaseLayersImplementation implements AvailableBaseL
continue continue
} }
const leafletLayer: () => TileLayer = () =>
const leafletLayer: () => TileLayer = () => AvailableBaseLayersImplementation.CreateBackgroundLayer( AvailableBaseLayersImplementation.CreateBackgroundLayer(
props.id, props.id,
props.name, props.name,
props.url, props.url,
@ -89,34 +100,35 @@ export default class AvailableBaseLayersImplementation implements AvailableBaseL
layer: leafletLayer, layer: leafletLayer,
feature: layer.geometry !== null ? layer : null, feature: layer.geometry !== null ? layer : null,
isBest: props.best ?? false, isBest: props.best ?? false,
category: props.category category: props.category,
}); })
} }
return layers; return layers
} }
private static LoadProviderIndex(): BaseLayer[] { private static LoadProviderIndex(): BaseLayer[] {
// @ts-ignore // @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 { function l(id: string, name: string): BaseLayer {
try { try {
const layer: any = L.tileLayer.provider(id, undefined); const layer: any = L.tileLayer.provider(id, undefined)
return { return {
feature: null, feature: null,
id: id, id: id,
name: name, name: name,
layer: () => L.tileLayer.provider(id, { layer: () =>
L.tileLayer.provider(id, {
maxNativeZoom: layer.options?.maxZoom, maxNativeZoom: layer.options?.maxZoom,
maxZoom: Math.max(layer.options?.maxZoom ?? 19, 21) maxZoom: Math.max(layer.options?.maxZoom ?? 19, 21),
}), }),
min_zoom: 1, min_zoom: 1,
max_zoom: layer.options.maxZoom, max_zoom: layer.options.maxZoom,
category: "osmbasedmap", category: "osmbasedmap",
isBest: false isBest: false,
} }
} catch (e) { } catch (e) {
console.error("Could not find provided layer", name, e); console.error("Could not find provided layer", name, e)
return null; return null
} }
} }
@ -129,38 +141,50 @@ export default class AvailableBaseLayersImplementation implements AvailableBaseL
l("CartoDB.PositronNoLabels", "Positron - no labels (by CartoDB)"), l("CartoDB.PositronNoLabels", "Positron - no labels (by CartoDB)"),
l("CartoDB.Voyager", "Voyager (by CartoDB)"), l("CartoDB.Voyager", "Voyager (by CartoDB)"),
l("CartoDB.VoyagerNoLabels", "Voyager - no labels (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 * 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, private static CreateBackgroundLayer(
maxZoom: number, isWms: boolean, isWMTS?: boolean): TileLayer { id: string,
name: string,
url = url.replace("{zoom}", "{z}") url: string,
.replace("&BBOX={bbox}", "") attribution: string,
.replace("&bbox={bbox}", ""); 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:[^}]*}/) const subdomainsMatch = url.match(/{switch:[^}]*}/)
let domains: string[] = []; let domains: string[] = []
if (subdomainsMatch !== null) { if (subdomainsMatch !== null) {
let domainsStr = subdomainsMatch[0].substr("{switch:".length); let domainsStr = subdomainsMatch[0].substr("{switch:".length)
domainsStr = domainsStr.substr(0, domainsStr.length - 1); domainsStr = domainsStr.substr(0, domainsStr.length - 1)
domains = domainsStr.split(","); domains = domainsStr.split(",")
url = url.replace(/{switch:[^}]*}/, "{s}") url = url.replace(/{switch:[^}]*}/, "{s}")
} }
if (isWms) { if (isWms) {
url = url.replace("&SRS={proj}", ""); url = url.replace("&SRS={proj}", "")
url = url.replace("&srs={proj}", ""); url = url.replace("&srs={proj}", "")
const paramaters = ["format", "layers", "version", "service", "request", "styles", "transparent", "version"]; const paramaters = [
const urlObj = new URL(url); "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 = { const options = {
maxZoom: Math.max(maxZoom ?? 19, 21), maxZoom: Math.max(maxZoom ?? 19, 21),
maxNativeZoom: maxZoom ?? 19, maxNativeZoom: maxZoom ?? 19,
@ -168,65 +192,66 @@ export default class AvailableBaseLayersImplementation implements AvailableBaseL
subdomains: domains, subdomains: domains,
uppercase: isUpper, uppercase: isUpper,
transparent: false, transparent: false,
}; }
for (const paramater of paramaters) { for (const paramater of paramaters) {
let p = paramater; let p = paramater
if (isUpper) { if (isUpper) {
p = paramater.toUpperCase(); p = paramater.toUpperCase()
} }
options[paramater] = urlObj.searchParams.get(p); options[paramater] = urlObj.searchParams.get(p)
} }
if (options.transparent === null) { 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) { if (attributionUrl) {
attribution = `<a href='${attributionUrl}' target='_blank'>${attribution}</a>`; attribution = `<a href='${attributionUrl}' target='_blank'>${attribution}</a>`
} }
return L.tileLayer(url, return L.tileLayer(url, {
{
attribution: attribution, attribution: attribution,
maxZoom: Math.max(21, maxZoom ?? 19), maxZoom: Math.max(21, maxZoom ?? 19),
maxNativeZoom: maxZoom ?? 19, maxNativeZoom: maxZoom ?? 19,
minZoom: 1, minZoom: 1,
// @ts-ignore // @ts-ignore
wmts: isWMTS ?? false, wmts: isWMTS ?? false,
subdomains: domains subdomains: domains,
}); })
} }
public AvailableLayersAt(location: Store<Loc>): Store<BaseLayer[]> { public AvailableLayersAt(location: Store<Loc>): Store<BaseLayer[]> {
return Stores.ListStabilized(location.map( return Stores.ListStabilized(
(currentLocation) => { location.map((currentLocation) => {
if (currentLocation === undefined) { 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> { public SelectBestLayerAccordingTo(
return this.AvailableLayersAt(location) location: Store<Loc>,
.map(available => { preferedCategory: Store<string | string[]>
): Store<BaseLayer> {
return this.AvailableLayersAt(location).map(
(available) => {
// First float all 'best layers' to the top // First float all 'best layers' to the top
available.sort((a, b) => { available.sort((a, b) => {
if (a.isBest && b.isBest) { if (a.isBest && b.isBest) {
return 0; return 0
} }
if (!a.isBest) { if (!a.isBest) {
return 1 return 1
} }
return -1; return -1
} })
)
if (preferedCategory.data === undefined) { if (preferedCategory.data === undefined) {
return available[0] return available[0]
@ -236,37 +261,37 @@ export default class AvailableBaseLayersImplementation implements AvailableBaseL
if (typeof preferedCategory.data === "string") { if (typeof preferedCategory.data === "string") {
prefered = [preferedCategory.data] prefered = [preferedCategory.data]
} else { } 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) { 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 //Then sort all 'photo'-layers to the top. Stability of the sorting will force a 'best' photo layer on top
available.sort((a, b) => { available.sort((a, b) => {
if (a.category === category && b.category === category) { if (a.category === category && b.category === category) {
return 0; return 0
} }
if (a.category !== category) { if (a.category !== category) {
return 1 return 1
} }
return -1; return -1
} })
)
} }
return available[0] return available[0]
}, [preferedCategory]) },
[preferedCategory]
)
} }
private CalculateAvailableLayersAt(lon: number, lat: number): BaseLayer[] { private CalculateAvailableLayersAt(lon: number, lat: number): BaseLayer[] {
const availableLayers = [this.osmCarto] const availableLayers = [this.osmCarto]
if (lon === undefined || lat === undefined) { 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) { for (const layerOverviewItem of this.localLayers) {
const layer = layerOverviewItem; const layer = layerOverviewItem
const bbox = BBox.get(layer.feature) const bbox = BBox.get(layer.feature)
if (!bbox.contains(lonlat)) { if (!bbox.contains(lonlat)) {
@ -274,10 +299,10 @@ export default class AvailableBaseLayersImplementation implements AvailableBaseL
} }
if (GeoOperations.inside(lonlat, layer.feature)) { 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 { UIEventSource } from "../UIEventSource"
import BaseLayer from "../../Models/BaseLayer"; import BaseLayer from "../../Models/BaseLayer"
import AvailableBaseLayers from "./AvailableBaseLayers"; import AvailableBaseLayers from "./AvailableBaseLayers"
import Loc from "../../Models/Loc"; import Loc from "../../Models/Loc"
import {Utils} from "../../Utils"; import { Utils } from "../../Utils"
/** /**
* Sets the current background layer to a layer that is actually available * Sets the current background layer to a layer that is actually available
*/ */
export default class BackgroundLayerResetter { export default class BackgroundLayerResetter {
constructor(
constructor(currentBackgroundLayer: UIEventSource<BaseLayer>, currentBackgroundLayer: UIEventSource<BaseLayer>,
location: UIEventSource<Loc>, location: UIEventSource<Loc>,
availableLayers: UIEventSource<BaseLayer[]>, availableLayers: UIEventSource<BaseLayer[]>,
defaultLayerId: string = undefined) { defaultLayerId: string = undefined
) {
if (Utils.runningFromConsole) { if (Utils.runningFromConsole) {
return 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 // Change the baselayer back to OSM if we go out of the current range of the layer
availableLayers.addCallbackAndRun(availableLayers => { availableLayers.addCallbackAndRun((availableLayers) => {
let defaultLayer = undefined; let defaultLayer = undefined
const currentLayer = currentBackgroundLayer.data.id; const currentLayer = currentBackgroundLayer.data.id
for (const availableLayer of availableLayers) { for (const availableLayer of availableLayers) {
if (availableLayer.id === currentLayer) { if (availableLayer.id === currentLayer) {
if (availableLayer.max_zoom < location.data.zoom) { if (availableLayer.max_zoom < location.data.zoom) {
break; break
} }
if (availableLayer.min_zoom > location.data.zoom) { if (availableLayer.min_zoom > location.data.zoom) {
break; break
} }
if (availableLayer.id === defaultLayerId) { 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! // 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") console.log(
currentBackgroundLayer.setData(defaultLayer ?? AvailableBaseLayers.osmCarto); "AvailableBaseLayers-actor: detected that the current bounds aren't sufficient anymore - reverting to OSM standard"
}); )
currentBackgroundLayer.setData(defaultLayer ?? AvailableBaseLayers.osmCarto)
})
} }
} }

View file

@ -1,35 +1,33 @@
import {ElementStorage} from "../ElementStorage"; import { ElementStorage } from "../ElementStorage"
import {Changes} from "../Osm/Changes"; import { Changes } from "../Osm/Changes"
export default class ChangeToElementsActor { export default class ChangeToElementsActor {
constructor(changes: Changes, allElements: ElementStorage) { constructor(changes: Changes, allElements: ElementStorage) {
changes.pendingChanges.addCallbackAndRun(changes => { changes.pendingChanges.addCallbackAndRun((changes) => {
for (const change of changes) { for (const change of changes) {
const id = change.type + "/" + change.id; const id = change.type + "/" + change.id
if (!allElements.has(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) const src = allElements.getEventSourceById(id)
let changed = false; let changed = false
for (const kv of change.tags ?? []) { for (const kv of change.tags ?? []) {
// Apply tag changes and ping the consumers // Apply tag changes and ping the consumers
const k = kv.k const k = kv.k
let v = kv.v let v = kv.v
if (v === "") { if (v === "") {
v = undefined; v = undefined
} }
if (src.data[k] === v) { if (src.data[k] === v) {
continue continue
} }
changed = true; changed = true
src.data[k] = v; src.data[k] = v
} }
if (changed) { if (changed) {
src.ping() src.ping()
} }
} }
}) })
} }

View file

@ -1,60 +1,59 @@
import {Store, UIEventSource} from "../UIEventSource"; import { Store, UIEventSource } from "../UIEventSource"
import Svg from "../../Svg"; import Svg from "../../Svg"
import {LocalStorageSource} from "../Web/LocalStorageSource"; import { LocalStorageSource } from "../Web/LocalStorageSource"
import {VariableUiElement} from "../../UI/Base/VariableUIElement"; import { VariableUiElement } from "../../UI/Base/VariableUIElement"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import {QueryParameters} from "../Web/QueryParameters"; import { QueryParameters } from "../Web/QueryParameters"
import {BBox} from "../BBox"; import { BBox } from "../BBox"
import Constants from "../../Models/Constants"; import Constants from "../../Models/Constants"
import SimpleFeatureSource from "../FeatureSource/Sources/SimpleFeatureSource"; import SimpleFeatureSource from "../FeatureSource/Sources/SimpleFeatureSource"
export interface GeoLocationPointProperties { export interface GeoLocationPointProperties {
id: "gps", id: "gps"
"user:location": "yes", "user:location": "yes"
"date": string, date: string
"latitude": number latitude: number
"longitude": number, longitude: number
"speed": number, speed: number
"accuracy": number accuracy: number
"heading": number heading: number
"altitude": number altitude: number
} }
export default class GeoLocationHandler extends VariableUiElement { export default class GeoLocationHandler extends VariableUiElement {
private readonly currentLocation?: SimpleFeatureSource private readonly currentLocation?: SimpleFeatureSource
/** /**
* Wether or not the geolocation is active, aka the user requested the current location * 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 * 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 * The callback over the permission API
* @private * @private
*/ */
private readonly _permission: UIEventSource<string>; private readonly _permission: UIEventSource<string>
/** /**
* Literally: _currentGPSLocation.data != undefined * Literally: _currentGPSLocation.data != undefined
* @private * @private
*/ */
private readonly _hasLocation: Store<boolean>; private readonly _hasLocation: Store<boolean>
private readonly _currentGPSLocation: UIEventSource<Coordinates>; private readonly _currentGPSLocation: UIEventSource<GeolocationCoordinates>
/** /**
* Kept in order to update the marker * Kept in order to update the marker
* @private * @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 * 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. * 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 * If the user denies the geolocation this time, we unset this flag
* @private * @private
*/ */
private readonly _previousLocationGrant: UIEventSource<string>; private readonly _previousLocationGrant: UIEventSource<string>
private readonly _layoutToUse: LayoutConfig; private readonly _layoutToUse: LayoutConfig
constructor( constructor(state: {
state: { selectedElement: UIEventSource<any>
selectedElement: UIEventSource<any>; currentUserLocation?: SimpleFeatureSource
currentUserLocation?: SimpleFeatureSource, leafletMap: UIEventSource<any>
leafletMap: UIEventSource<any>, layoutToUse: LayoutConfig
layoutToUse: LayoutConfig,
featureSwitchGeolocation: UIEventSource<boolean> featureSwitchGeolocation: UIEventSource<boolean>
} }) {
) { const currentGPSLocation = new UIEventSource<GeolocationCoordinates>(
const currentGPSLocation = new UIEventSource<Coordinates>(undefined, "GPS-coordinate") undefined,
"GPS-coordinate"
)
const leafletMap = state.leafletMap const leafletMap = state.leafletMap
const initedAt = new Date() const initedAt = new Date()
let autozoomDone = false; let autozoomDone = false
const hasLocation = currentGPSLocation.map( const hasLocation = currentGPSLocation.map((location) => location !== undefined)
(location) => location !== undefined const previousLocationGrant = LocalStorageSource.Get("geolocation-permissions")
); const isActive = new UIEventSource<boolean>(false)
const previousLocationGrant = LocalStorageSource.Get( const isLocked = new UIEventSource<boolean>(false)
"geolocation-permissions" const permission = new UIEventSource<string>("")
); const lastClick = new UIEventSource<Date>(undefined)
const isActive = new UIEventSource<boolean>(false); const lastClickWithinThreeSecs = lastClick.map((lastClick) => {
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) { if (lastClick === undefined) {
return false; return false
} }
const timeDiff = (new Date().getTime() - lastClick.getTime()) / 1000 const timeDiff = (new Date().getTime() - lastClick.getTime()) / 1000
return timeDiff <= 3 return timeDiff <= 3
}) })
const latLonGiven = QueryParameters.wasInitialized("lat") && QueryParameters.wasInitialized("lon") const latLonGiven =
const willFocus = lastClick.map(lastUserRequest => { QueryParameters.wasInitialized("lat") && QueryParameters.wasInitialized("lon")
const willFocus = lastClick.map((lastUserRequest) => {
const timeDiffInited = (new Date().getTime() - initedAt.getTime()) / 1000 const timeDiffInited = (new Date().getTime() - initedAt.getTime()) / 1000
if (!latLonGiven && !autozoomDone && timeDiffInited < Constants.zoomToLocationTimeout) { if (!latLonGiven && !autozoomDone && timeDiffInited < Constants.zoomToLocationTimeout) {
return true return true
} }
if (lastUserRequest === undefined) { if (lastUserRequest === undefined) {
return false; return false
} }
const timeDiff = (new Date().getTime() - lastUserRequest.getTime()) / 1000 const timeDiff = (new Date().getTime() - lastUserRequest.getTime()) / 1000
return timeDiff <= Constants.zoomToLocationTimeout return timeDiff <= Constants.zoomToLocationTimeout
}) })
lastClick.addCallbackAndRunD(_ => { lastClick.addCallbackAndRunD((_) => {
window.setTimeout(() => { window.setTimeout(() => {
if (lastClickWithinThreeSecs.data || willFocus.data) { if (lastClickWithinThreeSecs.data || willFocus.data) {
lastClick.ping() lastClick.ping()
@ -123,7 +120,7 @@ export default class GeoLocationHandler extends VariableUiElement {
hasLocation.map( hasLocation.map(
(hasLocationData) => { (hasLocationData) => {
if (permission.data === "denied") { if (permission.data === "denied") {
return Svg.location_refused_svg(); return Svg.location_refused_svg()
} }
if (!isActive.data) { if (!isActive.data) {
@ -134,7 +131,7 @@ export default class GeoLocationHandler extends VariableUiElement {
// If will focus is active too, we indicate this differently // If will focus is active too, we indicate this differently
const icon = willFocus.data ? Svg.location_svg() : Svg.location_empty_svg() const icon = willFocus.data ? Svg.location_svg() : Svg.location_empty_svg()
icon.SetStyle("animation: spin 4s linear infinite;") icon.SetStyle("animation: spin 4s linear infinite;")
return icon; return icon
} }
if (isLocked.data) { if (isLocked.data) {
return Svg.location_locked_svg() return Svg.location_locked_svg()
@ -144,38 +141,37 @@ export default class GeoLocationHandler extends VariableUiElement {
} }
// We have a location, so we show a dot in the center // 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] [isActive, isLocked, permission, lastClickWithinThreeSecs, willFocus]
) )
); )
this.SetClass("mapcontrol") this.SetClass("mapcontrol")
this._isActive = isActive; this._isActive = isActive
this._isLocked = isLocked; this._isLocked = isLocked
this._permission = permission this._permission = permission
this._previousLocationGrant = previousLocationGrant; this._previousLocationGrant = previousLocationGrant
this._currentGPSLocation = currentGPSLocation; this._currentGPSLocation = currentGPSLocation
this._leafletMap = leafletMap; this._leafletMap = leafletMap
this._layoutToUse = state.layoutToUse; this._layoutToUse = state.layoutToUse
this._hasLocation = hasLocation; this._hasLocation = hasLocation
this._lastUserRequest = lastClick this._lastUserRequest = lastClick
const self = this; const self = this
const currentPointer = this._isActive.map( const currentPointer = this._isActive.map(
(isActive) => { (isActive) => {
if (isActive && !self._hasLocation.data) { if (isActive && !self._hasLocation.data) {
return "cursor-wait"; return "cursor-wait"
} }
return "cursor-pointer"; return "cursor-pointer"
}, },
[this._hasLocation] [this._hasLocation]
); )
currentPointer.addCallbackAndRun((pointerClass) => { currentPointer.addCallbackAndRun((pointerClass) => {
self.RemoveClass("cursor-wait") self.RemoveClass("cursor-wait")
self.RemoveClass("cursor-pointer") self.RemoveClass("cursor-pointer")
self.SetClass(pointerClass); self.SetClass(pointerClass)
}); })
this.onClick(() => { this.onClick(() => {
/* /*
@ -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 isLocked.addCallbackAndRunD((isLocked) => {
this.init(false, doAutoZoomToLocation);
isLocked.addCallbackAndRunD(isLocked => {
if (isLocked) { if (isLocked) {
leafletMap.data?.dragging?.disable() leafletMap.data?.dragging?.disable()
} else { } else {
@ -214,47 +212,45 @@ export default class GeoLocationHandler extends VariableUiElement {
this.currentLocation = state.currentUserLocation this.currentLocation = state.currentUserLocation
this._currentGPSLocation.addCallback((location) => { this._currentGPSLocation.addCallback((location) => {
self._previousLocationGrant.setData("granted"); self._previousLocationGrant.setData("granted")
const feature = { const feature = {
"type": "Feature", type: "Feature",
properties: <GeoLocationPointProperties>{ properties: <GeoLocationPointProperties>{
id: "gps", id: "gps",
"user:location": "yes", "user:location": "yes",
"date": new Date().toISOString(), date: new Date().toISOString(),
"latitude": location.latitude, latitude: location.latitude,
"longitude": location.longitude, longitude: location.longitude,
"speed": location.speed, speed: location.speed,
"accuracy": location.accuracy, accuracy: location.accuracy,
"heading": location.heading, heading: location.heading,
"altitude": location.altitude altitude: location.altitude,
}, },
geometry: { geometry: {
type: "Point", type: "Point",
coordinates: [location.longitude, location.latitude], coordinates: [location.longitude, location.latitude],
} },
} }
self.currentLocation?.features?.setData([{ feature, freshness: new Date() }]) self.currentLocation?.features?.setData([{ feature, freshness: new Date() }])
if (willFocus.data) { if (willFocus.data) {
console.log("Zooming to user location: willFocus is set") console.log("Zooming to user location: willFocus is set")
lastClick.setData(undefined); lastClick.setData(undefined)
autozoomDone = true; autozoomDone = true
self.MoveToCurrentLocation(16); self.MoveToCurrentLocation(16)
} else if (self._isLocked.data) { } else if (self._isLocked.data) {
self.MoveToCurrentLocation(); self.MoveToCurrentLocation()
} }
})
});
} }
private init(askPermission: boolean, zoomToLocation: boolean) { private init(askPermission: boolean, zoomToLocation: boolean) {
const self = this; const self = this
if (self._isActive.data) { if (self._isActive.data) {
self.MoveToCurrentLocation(16); self.MoveToCurrentLocation(16)
return; return
} }
if (typeof navigator === "undefined") { if (typeof navigator === "undefined") {
@ -262,27 +258,25 @@ export default class GeoLocationHandler extends VariableUiElement {
} }
try { try {
navigator?.permissions navigator?.permissions?.query({ name: "geolocation" })?.then(function (status) {
?.query({name: "geolocation"}) console.log("Geolocation permission is ", status.state)
?.then(function (status) {
console.log("Geolocation permission is ", status.state);
if (status.state === "granted") { if (status.state === "granted") {
self.StartGeolocating(zoomToLocation); self.StartGeolocating(zoomToLocation)
} }
self._permission.setData(status.state); self._permission.setData(status.state)
status.onchange = function () { status.onchange = function () {
self._permission.setData(status.state); self._permission.setData(status.state)
}; }
}); })
} catch (e) { } catch (e) {
console.error(e); console.error(e)
} }
if (askPermission) { if (askPermission) {
self.StartGeolocating(zoomToLocation); self.StartGeolocating(zoomToLocation)
} else if (this._previousLocationGrant.data === "granted") { } else if (this._previousLocationGrant.data === "granted") {
this._previousLocationGrant.setData(""); this._previousLocationGrant.setData("")
self.StartGeolocating(zoomToLocation); self.StartGeolocating(zoomToLocation)
} }
} }
@ -337,20 +331,20 @@ export default class GeoLocationHandler extends VariableUiElement {
* resultingLocation // => [51.3, 4.1] * resultingLocation // => [51.3, 4.1]
*/ */
private MoveToCurrentLocation(targetZoom?: number) { private MoveToCurrentLocation(targetZoom?: number) {
const location = this._currentGPSLocation.data; const location = this._currentGPSLocation.data
this._lastUserRequest.setData(undefined); this._lastUserRequest.setData(undefined)
if ( if (
this._currentGPSLocation.data.latitude === 0 && this._currentGPSLocation.data.latitude === 0 &&
this._currentGPSLocation.data.longitude === 0 this._currentGPSLocation.data.longitude === 0
) { ) {
console.debug("Not moving to GPS-location: it is null island"); console.debug("Not moving to GPS-location: it is null island")
return; return
} }
// We check that the GPS location is not out of bounds // We check that the GPS location is not out of bounds
const b = this._layoutToUse.lockLocation; const b = this._layoutToUse.lockLocation
let inRange = true; let inRange = true
if (b) { if (b) {
if (b !== true) { if (b !== true) {
// B is an array with our locklocation // B is an array with our locklocation
@ -358,41 +352,44 @@ export default class GeoLocationHandler extends VariableUiElement {
} }
} }
if (!inRange) { 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 { } else {
const currentZoom = this._leafletMap.data.getZoom() 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) { private StartGeolocating(zoomToGPS = true) {
const self = this; const self = this
this._lastUserRequest.setData(zoomToGPS ? new Date() : new Date(0)) this._lastUserRequest.setData(zoomToGPS ? new Date() : new Date(0))
if (self._permission.data === "denied") { if (self._permission.data === "denied") {
self._previousLocationGrant.setData(""); self._previousLocationGrant.setData("")
self._isActive.setData(false) self._isActive.setData(false)
return ""; return ""
} }
if (this._currentGPSLocation.data !== undefined) { if (this._currentGPSLocation.data !== undefined) {
this.MoveToCurrentLocation(16); this.MoveToCurrentLocation(16)
} }
if (self._isActive.data) { if (self._isActive.data) {
return; return
} }
self._isActive.setData(true); self._isActive.setData(true)
navigator.geolocation.watchPosition( navigator.geolocation.watchPosition(
function (position) { function (position) {
self._currentGPSLocation.setData(position.coords); self._currentGPSLocation.setData(position.coords)
}, },
function () { 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 { Store, UIEventSource } from "../UIEventSource"
import {Or} from "../Tags/Or"; import { Or } from "../Tags/Or"
import {Overpass} from "../Osm/Overpass"; import { Overpass } from "../Osm/Overpass"
import FeatureSource from "../FeatureSource/FeatureSource"; import FeatureSource from "../FeatureSource/FeatureSource"
import {Utils} from "../../Utils"; import { Utils } from "../../Utils"
import {TagsFilter} from "../Tags/TagsFilter"; import { TagsFilter } from "../Tags/TagsFilter"
import SimpleMetaTagger from "../SimpleMetaTagger"; import SimpleMetaTagger from "../SimpleMetaTagger"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import RelationsTracker from "../Osm/RelationsTracker"; import RelationsTracker from "../Osm/RelationsTracker"
import {BBox} from "../BBox"; import { BBox } from "../BBox"
import Loc from "../../Models/Loc"; import Loc from "../../Models/Loc"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"; import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import Constants from "../../Models/Constants"; import Constants from "../../Models/Constants"
import TileFreshnessCalculator from "../FeatureSource/TileFreshnessCalculator"; import TileFreshnessCalculator from "../FeatureSource/TileFreshnessCalculator"
import {Tiles} from "../../Models/TileRange"; import { Tiles } from "../../Models/TileRange"
export default class OverpassFeatureSource implements FeatureSource { export default class OverpassFeatureSource implements FeatureSource {
public readonly name = "OverpassFeatureSource" public readonly name = "OverpassFeatureSource"
/** /**
* The last loaded features of the geojson * 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 relationsTracker: RelationsTracker
public readonly timeout: UIEventSource<number> = new UIEventSource<number>(0);
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: { private readonly state: {
readonly locationControl: Store<Loc>, readonly locationControl: Store<Loc>
readonly layoutToUse: LayoutConfig, readonly layoutToUse: LayoutConfig
readonly overpassUrl: Store<string[]>; readonly overpassUrl: Store<string[]>
readonly overpassTimeout: Store<number>; readonly overpassTimeout: Store<number>
readonly currentBounds: Store<BBox> readonly currentBounds: Store<BBox>
} }
private readonly _isActive: Store<boolean> private readonly _isActive: Store<boolean>
/** /**
* Callback to handle all the data * 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 * Keeps track of how fresh the data is
* @private * @private
*/ */
private readonly freshnesses: Map<string, TileFreshnessCalculator>; private readonly freshnesses: Map<string, TileFreshnessCalculator>
constructor( constructor(
state: { state: {
readonly locationControl: Store<Loc>, readonly locationControl: Store<Loc>
readonly layoutToUse: LayoutConfig, readonly layoutToUse: LayoutConfig
readonly overpassUrl: Store<string[]>; readonly overpassUrl: Store<string[]>
readonly overpassTimeout: Store<number>; readonly overpassTimeout: Store<number>
readonly overpassMaxZoom: Store<number>, readonly overpassMaxZoom: Store<number>
readonly currentBounds: Store<BBox> readonly currentBounds: Store<BBox>
}, },
options: { options: {
padToTiles: Store<number>, padToTiles: Store<number>
isActive?: Store<boolean>, isActive?: Store<boolean>
relationTracker: RelationsTracker, relationTracker: RelationsTracker
onBboxLoaded?: (bbox: BBox, date: Date, layers: LayerConfig[], zoomlevel: number) => void, onBboxLoaded?: (
bbox: BBox,
date: Date,
layers: LayerConfig[],
zoomlevel: number
) => void
freshnesses?: Map<string, TileFreshnessCalculator> freshnesses?: Map<string, TileFreshnessCalculator>
}) { }
) {
this.state = state this.state = state
this._isActive = options.isActive; this._isActive = options.isActive
this.onBboxLoaded = options.onBboxLoaded this.onBboxLoaded = options.onBboxLoaded
this.relationsTracker = options.relationTracker this.relationsTracker = options.relationTracker
this.freshnesses = options.freshnesses this.freshnesses = options.freshnesses
const self = this; const self = this
state.currentBounds.addCallback(_ => { state.currentBounds.addCallback((_) => {
self.update(options.padToTiles.data) self.update(options.padToTiles.data)
}) })
} }
private GetFilter(interpreterUrl: string, layersToDownload: LayerConfig[]): Overpass { private GetFilter(interpreterUrl: string, layersToDownload: LayerConfig[]): Overpass {
let filters: TagsFilter[] = []; let filters: TagsFilter[] = []
let extraScripts: string[] = []; let extraScripts: string[] = []
for (const layer of layersToDownload) { for (const layer of layersToDownload) {
if (layer.source.overpassScript !== undefined) { if (layer.source.overpassScript !== undefined) {
extraScripts.push(layer.source.overpassScript) extraScripts.push(layer.source.overpassScript)
} else { } else {
filters.push(layer.source.osmTags); filters.push(layer.source.osmTags)
} }
} }
filters = Utils.NoNull(filters) filters = Utils.NoNull(filters)
extraScripts = Utils.NoNull(extraScripts) extraScripts = Utils.NoNull(extraScripts)
if (filters.length + extraScripts.length === 0) { 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) { private update(paddedZoomLevel: number) {
if (!this._isActive.data) { if (!this._isActive.data) {
return; return
} }
const self = this; const self = this
this.updateAsync(paddedZoomLevel).then(bboxDate => { this.updateAsync(paddedZoomLevel).then((bboxDate) => {
if (bboxDate === undefined || self.onBboxLoaded === undefined) { if (bboxDate === undefined || self.onBboxLoaded === undefined) {
return; return
} }
const [bbox, date, layers] = bboxDate const [bbox, date, layers] = bboxDate
self.onBboxLoaded(bbox, date, layers, paddedZoomLevel) 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[]]> { private async updateAsync(padToZoomLevel: number): Promise<[BBox, Date, LayerConfig[]]> {
if (this.runningQuery.data) { if (this.runningQuery.data) {
console.log("Still running a query, not updating"); console.log("Still running a query, not updating")
return undefined; return undefined
} }
if (this.timeout.data > 0) { if (this.timeout.data > 0) {
console.log("Still in timeout - not updating") console.log("Still in timeout - not updating")
return undefined; return undefined
} }
let data: any = undefined let data: any = undefined
let date: Date = undefined let date: Date = undefined
let lastUsed = 0; let lastUsed = 0
const layersToDownload = [] 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) { for (const layer of this.state.layoutToUse.layers) {
if (typeof layer === "string") {
if (typeof (layer) === "string") {
throw "A layer was not expanded!" throw "A layer was not expanded!"
} }
if (Constants.priviliged_layers.indexOf(layer.id) >= 0) { if (Constants.priviliged_layers.indexOf(layer.id) >= 0) {
continue continue
} }
if (this.state.locationControl.data.zoom < layer.minzoom) { if (this.state.locationControl.data.zoom < layer.minzoom) {
continue; continue
} }
if (layer.doNotDownload) { if (layer.doNotDownload) {
continue; continue
} }
if (layer.source.geojsonSource !== undefined) { if (layer.source.geojsonSource !== undefined) {
// Not our responsibility to download this layer! // Not our responsibility to download this layer!
continue; continue
} }
const freshness = this.freshnesses?.get(layer.id) const freshness = this.freshnesses?.get(layer.id)
if (freshness !== undefined) { if (freshness !== undefined) {
const oldestDataDate = Math.min(...Tiles.MapRange(neededTiles, (x, y) => { const oldestDataDate =
const date = freshness.freshnessFor(padToZoomLevel, x, y); Math.min(
...Tiles.MapRange(neededTiles, (x, y) => {
const date = freshness.freshnessFor(padToZoomLevel, x, y)
if (date === undefined) { if (date === undefined) {
return 0 return 0
} }
return date.getTime() return date.getTime()
})) / 1000; })
) / 1000
const now = new Date().getTime() const now = new Date().getTime()
const minRequiredAge = (now / 1000) - layer.maxAgeOfCache const minRequiredAge = now / 1000 - layer.maxAgeOfCache
if (oldestDataDate >= minRequiredAge) { if (oldestDataDate >= minRequiredAge) {
// still fresh enough - not updating // still fresh enough - not updating
continue continue
} }
} }
layersToDownload.push(layer) layersToDownload.push(layer)
@ -172,34 +186,35 @@ export default class OverpassFeatureSource implements FeatureSource {
if (layersToDownload.length == 0) { if (layersToDownload.length == 0) {
console.debug("Not updating - no layers needed") console.debug("Not updating - no layers needed")
return; return
} }
const self = this; const self = this
const overpassUrls = self.state.overpassUrl.data const overpassUrls = self.state.overpassUrl.data
let bounds: BBox let bounds: BBox
do { do {
try { try {
bounds = this.state.currentBounds.data
bounds = this.state.currentBounds.data?.pad(this.state.layoutToUse.widenFactor)?.expandToTileBounds(padToZoomLevel); ?.pad(this.state.layoutToUse.widenFactor)
?.expandToTileBounds(padToZoomLevel)
if (bounds === undefined) { if (bounds === undefined) {
return undefined; return undefined
} }
const overpass = this.GetFilter(overpassUrls[lastUsed], layersToDownload); const overpass = this.GetFilter(overpassUrls[lastUsed], layersToDownload)
if (overpass === undefined) { 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) console.log("Querying overpass is done", data)
} catch (e) { } catch (e) {
self.retries.data++; self.retries.data++
self.retries.ping(); self.retries.ping()
console.error(`QUERY FAILED due to`, e); console.error(`QUERY FAILED due to`, e)
await Utils.waitFor(1000) await Utils.waitFor(1000)
@ -208,34 +223,38 @@ export default class OverpassFeatureSource implements FeatureSource {
console.log("Trying next time with", overpassUrls[lastUsed]) console.log("Trying next time with", overpassUrls[lastUsed])
} else { } else {
lastUsed = 0 lastUsed = 0
self.timeout.setData(self.retries.data * 5); self.timeout.setData(self.retries.data * 5)
while (self.timeout.data > 0) { while (self.timeout.data > 0) {
await Utils.waitFor(1000) await Utils.waitFor(1000)
console.log(self.timeout.data) console.log(self.timeout.data)
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 { try {
if (data === undefined) { if (data === undefined) {
return undefined return undefined
} }
data.features.forEach(feature => SimpleMetaTagger.objectMetaInfo.applyMetaTagsOnFeature(feature, date, undefined, this.state)); data.features.forEach((feature) =>
self.features.setData(data.features.map(f => ({feature: f, freshness: date}))); SimpleMetaTagger.objectMetaInfo.applyMetaTagsOnFeature(
return [bounds, date, layersToDownload]; feature,
date,
undefined,
this.state
)
)
self.features.setData(data.features.map((f) => ({ feature: f, freshness: date })))
return [bounds, date, layersToDownload]
} catch (e) { } catch (e) {
console.error("Got the overpass response, but could not process it: ", e, e.stack) console.error("Got the overpass response, but could not process it: ", e, e.stack)
return undefined return undefined
} finally { } finally {
self.retries.setData(0); self.retries.setData(0)
self.runningQuery.setData(false); self.runningQuery.setData(false)
} }
} }
} }

View file

@ -1,46 +1,42 @@
import {Changes} from "../Osm/Changes"; import { Changes } from "../Osm/Changes"
import Constants from "../../Models/Constants"; import Constants from "../../Models/Constants"
import {UIEventSource} from "../UIEventSource"; import { UIEventSource } from "../UIEventSource"
import {Utils} from "../../Utils"; import { Utils } from "../../Utils"
export default class PendingChangesUploader { export default class PendingChangesUploader {
private lastChange: Date
private lastChange: Date;
constructor(changes: Changes, selectedFeature: UIEventSource<any>) { constructor(changes: Changes, selectedFeature: UIEventSource<any>) {
const self = this; const self = this
this.lastChange = new Date(); this.lastChange = new Date()
changes.pendingChanges.addCallback(() => { changes.pendingChanges.addCallback(() => {
self.lastChange = new Date(); self.lastChange = new Date()
window.setTimeout(() => { 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) { 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) => {
selectedFeature
.stabilized(10000)
.addCallback(feature => {
if (feature === undefined) { if (feature === undefined) {
// The popup got closed - we flush // The popup got closed - we flush
changes.flushChanges("Flushing changes due to popup closed"); changes.flushChanges("Flushing changes due to popup closed")
} }
}); })
if (Utils.runningFromConsole) { if (Utils.runningFromConsole) {
return; return
} }
document.addEventListener('mouseout', e => { document.addEventListener("mouseout", (e) => {
// @ts-ignore // @ts-ignore
if (!e.toElement && !e.relatedTarget) { if (!e.toElement && !e.relatedTarget) {
changes.flushChanges("Flushing changes due to focus lost"); changes.flushChanges("Flushing changes due to focus lost")
} }
}); })
document.onfocus = () => { document.onfocus = () => {
changes.flushChanges("OnFocus") changes.flushChanges("OnFocus")
@ -50,28 +46,28 @@ export default class PendingChangesUploader {
changes.flushChanges("OnFocus") changes.flushChanges("OnFocus")
} }
try { try {
document.addEventListener("visibilitychange", () => { document.addEventListener(
"visibilitychange",
() => {
changes.flushChanges("Visibility change") changes.flushChanges("Visibility change")
}, false); },
false
)
} catch (e) { } catch (e) {
console.warn("Could not register visibility change listener", e) console.warn("Could not register visibility change listener", e)
} }
function onunload(e) { function onunload(e) {
if (changes.pendingChanges.data.length == 0) { if (changes.pendingChanges.data.length == 0) {
return; return
} }
changes.flushChanges("onbeforeunload - probably closing or something similar"); changes.flushChanges("onbeforeunload - probably closing or something similar")
e.preventDefault(); e.preventDefault()
return "Saving your last changes..." return "Saving your last changes..."
} }
window.onbeforeunload = onunload window.onbeforeunload = onunload
// https://stackoverflow.com/questions/3239834/window-onbeforeunload-not-working-on-the-ipad#4824156 // https://stackoverflow.com/questions/3239834/window-onbeforeunload-not-working-on-the-ipad#4824156
window.addEventListener("pagehide", onunload) 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. * This actor will download the latest version of the selected element from OSM and update the tags if necessary.
*/ */
import {UIEventSource} from "../UIEventSource"; import { UIEventSource } from "../UIEventSource"
import {ElementStorage} from "../ElementStorage"; import { ElementStorage } from "../ElementStorage"
import {Changes} from "../Osm/Changes"; import { Changes } from "../Osm/Changes"
import {OsmObject} from "../Osm/OsmObject"; import { OsmObject } from "../Osm/OsmObject"
import {OsmConnection} from "../Osm/OsmConnection"; import { OsmConnection } from "../Osm/OsmConnection"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import SimpleMetaTagger from "../SimpleMetaTagger"; import SimpleMetaTagger from "../SimpleMetaTagger"
export default class SelectedElementTagsUpdater { export default class SelectedElementTagsUpdater {
private static readonly metatags = new Set([
private static readonly metatags = new Set(["timestamp", "timestamp",
"version", "version",
"changeset", "changeset",
"user", "user",
"uid", "uid",
"id"]) "id",
])
constructor(state: { constructor(state: {
selectedElement: UIEventSource<any>, selectedElement: UIEventSource<any>
allElements: ElementStorage, allElements: ElementStorage
changes: Changes, changes: Changes
osmConnection: OsmConnection, osmConnection: OsmConnection
layoutToUse: LayoutConfig layoutToUse: LayoutConfig
}) { }) {
state.osmConnection.isLoggedIn.addCallbackAndRun((isLoggedIn) => {
state.osmConnection.isLoggedIn.addCallbackAndRun(isLoggedIn => {
if (isLoggedIn) { if (isLoggedIn) {
SelectedElementTagsUpdater.installCallback(state) SelectedElementTagsUpdater.installCallback(state)
return true; return true
} }
}) })
} }
public static installCallback(state: { public static installCallback(state: {
selectedElement: UIEventSource<any>, selectedElement: UIEventSource<any>
allElements: ElementStorage, allElements: ElementStorage
changes: Changes, changes: Changes
osmConnection: OsmConnection, osmConnection: OsmConnection
layoutToUse: LayoutConfig layoutToUse: LayoutConfig
}) { }) {
state.selectedElement.addCallbackAndRunD((s) => {
state.selectedElement.addCallbackAndRunD(s => {
let id = s.properties?.id let id = s.properties?.id
const backendUrl = state.osmConnection._oauth_config.url 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"))) { if (!(id.startsWith("way") || id.startsWith("node") || id.startsWith("relation"))) {
// This object is _not_ from OSM, so we skip it! // This object is _not_ from OSM, so we skip it!
return; return
} }
if (id.indexOf("-") >= 0) { if (id.indexOf("-") >= 0) {
// This is a new object // This is a new object
return; return
} }
OsmObject.DownloadPropertiesOf(id).then(latestTags => { OsmObject.DownloadPropertiesOf(id).then((latestTags) => {
SelectedElementTagsUpdater.applyUpdate(state, latestTags, id) SelectedElementTagsUpdater.applyUpdate(state, latestTags, id)
}) })
})
});
} }
public static applyUpdate(state: { public static applyUpdate(
selectedElement: UIEventSource<any>, state: {
allElements: ElementStorage, selectedElement: UIEventSource<any>
changes: Changes, allElements: ElementStorage
osmConnection: OsmConnection, changes: Changes
osmConnection: OsmConnection
layoutToUse: LayoutConfig layoutToUse: LayoutConfig
}, latestTags: any, id: string },
latestTags: any,
id: string
) { ) {
try { try {
const leftRightSensitive = state.layoutToUse.isLeftRightSensitive() const leftRightSensitive = state.layoutToUse.isLeftRightSensitive()
if (leftRightSensitive) { if (leftRightSensitive) {
@ -87,11 +83,11 @@ export default class SelectedElementTagsUpdater {
} }
const pendingChanges = state.changes.pendingChanges.data const pendingChanges = state.changes.pendingChanges.data
.filter(change => change.type + "/" + change.id === id) .filter((change) => change.type + "/" + change.id === id)
.filter(change => change.tags !== undefined); .filter((change) => change.tags !== undefined)
for (const pendingChange of pendingChanges) { for (const pendingChange of pendingChanges) {
const tagChanges = pendingChange.tags; const tagChanges = pendingChange.tags
for (const tagChange of tagChanges) { for (const tagChange of tagChanges) {
const key = tagChange.k const key = tagChange.k
const v = tagChange.v const v = tagChange.v
@ -103,10 +99,9 @@ export default class SelectedElementTagsUpdater {
} }
} }
// With the changes applied, we merge them onto the upstream object // With the changes applied, we merge them onto the upstream object
let somethingChanged = false; let somethingChanged = false
const currentTagsSource = state.allElements.getEventSourceById(id); const currentTagsSource = state.allElements.getEventSourceById(id)
const currentTags = currentTagsSource.data const currentTags = currentTagsSource.data
for (const key in latestTags) { for (const key in latestTags) {
let osmValue = latestTags[key] let osmValue = latestTags[key]
@ -117,7 +112,7 @@ export default class SelectedElementTagsUpdater {
const localValue = currentTags[key] const localValue = currentTags[key]
if (localValue !== osmValue) { if (localValue !== osmValue) {
somethingChanged = true; somethingChanged = true
currentTags[key] = osmValue currentTags[key] = osmValue
} }
} }
@ -137,7 +132,6 @@ export default class SelectedElementTagsUpdater {
somethingChanged = true somethingChanged = true
} }
if (somethingChanged) { if (somethingChanged) {
console.log("Detected upstream changes to the object when opening it, updating...") console.log("Detected upstream changes to the object when opening it, updating...")
currentTagsSource.ping() currentTagsSource.ping()
@ -148,6 +142,4 @@ export default class SelectedElementTagsUpdater {
console.error("Updating the tags of selected element ", id, "failed due to", e) console.error("Updating the tags of selected element ", id, "failed due to", e)
} }
} }
} }

View file

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

View file

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

View file

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

View file

@ -1,31 +1,32 @@
import * as turf from "@turf/turf"; import * as turf from "@turf/turf"
import {TileRange, Tiles} from "../Models/TileRange"; import { TileRange, Tiles } from "../Models/TileRange"
import {GeoOperations} from "./GeoOperations"; import { GeoOperations } from "./GeoOperations"
export class BBox { export class BBox {
static global: BBox = new BBox([
static global: BBox = new BBox([[-180, -90], [180, 90]]); [-180, -90],
readonly maxLat: number; [180, 90],
readonly maxLon: number; ])
readonly minLat: number; readonly maxLat: number
readonly minLon: number; readonly maxLon: number
readonly minLat: number
readonly minLon: number
/*** /***
* Coordinates should be [[lon, lat],[lon, lat]] * Coordinates should be [[lon, lat],[lon, lat]]
* @param coordinates * @param coordinates
*/ */
constructor(coordinates) { constructor(coordinates) {
this.maxLat = -90; this.maxLat = -90
this.maxLon = -180; this.maxLon = -180
this.minLat = 90; this.minLat = 90
this.minLon = 180; this.minLon = 180
for (const coordinate of coordinates) { for (const coordinate of coordinates) {
this.maxLon = Math.max(this.maxLon, coordinate[0]); this.maxLon = Math.max(this.maxLon, coordinate[0])
this.maxLat = Math.max(this.maxLat, coordinate[1]); this.maxLat = Math.max(this.maxLat, coordinate[1])
this.minLon = Math.min(this.minLon, coordinate[0]); this.minLon = Math.min(this.minLon, coordinate[0])
this.minLat = Math.min(this.minLat, coordinate[1]); this.minLat = Math.min(this.minLat, coordinate[1])
} }
this.maxLon = Math.min(this.maxLon, 180) this.maxLon = Math.min(this.maxLon, 180)
@ -33,27 +34,32 @@ export class BBox {
this.minLon = Math.max(this.minLon, -180) this.minLon = Math.max(this.minLon, -180)
this.minLat = Math.max(this.minLat, -90) this.minLat = Math.max(this.minLat, -90)
this.check()
this.check();
} }
static fromLeafletBounds(bounds) { 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 { static get(feature): BBox {
if (feature.bbox?.overlapsWith === undefined) { if (feature.bbox?.overlapsWith === undefined) {
const turfBbox: number[] = turf.bbox(feature) 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 { static bboxAroundAll(bboxes: BBox[]): BBox {
let maxLat: number = -90; let maxLat: number = -90
let maxLon: number = -180; let maxLon: number = -180
let minLat: number = 80; let minLat: number = 80
let minLon: number = 180; let minLon: number = 180
for (const bbox of bboxes) { for (const bbox of bboxes) {
maxLat = Math.max(maxLat, bbox.maxLat) maxLat = Math.max(maxLat, bbox.maxLat)
@ -61,7 +67,10 @@ export class BBox {
minLat = Math.min(minLat, bbox.minLat) minLat = Math.min(minLat, bbox.minLat)
minLon = Math.min(minLon, bbox.minLon) minLon = Math.min(minLon, bbox.minLon)
} }
return new BBox([[maxLon, maxLat], [minLon, minLat]]) return new BBox([
[maxLon, maxLat],
[minLon, minLat],
])
} }
/** /**
@ -85,11 +94,10 @@ export class BBox {
} }
public unionWith(other: BBox) { public unionWith(other: BBox) {
return new BBox([[ return new BBox([
Math.max(this.maxLon, other.maxLon), [Math.max(this.maxLon, other.maxLon), Math.max(this.maxLat, other.maxLat)],
Math.max(this.maxLat, other.maxLat)], [Math.min(this.minLon, other.minLon), Math.min(this.minLat, other.minLat)],
[Math.min(this.minLon, other.minLon), ])
Math.min(this.minLat, other.minLat)]])
} }
/** /**
@ -102,32 +110,31 @@ export class BBox {
public overlapsWith(other: BBox) { public overlapsWith(other: BBox) {
if (this.maxLon < other.minLon) { if (this.maxLon < other.minLon) {
return false; return false
} }
if (this.maxLat < other.minLat) { if (this.maxLat < other.minLat) {
return false; return false
} }
if (this.minLon > other.maxLon) { if (this.minLon > other.maxLon) {
return false; return false
} }
return this.minLat <= other.maxLat; return this.minLat <= other.maxLat
} }
public isContainedIn(other: BBox) { public isContainedIn(other: BBox) {
if (this.maxLon > other.maxLon) { if (this.maxLon > other.maxLon) {
return false; return false
} }
if (this.maxLat > other.maxLat) { if (this.maxLat > other.maxLat) {
return false; return false
} }
if (this.minLon < other.minLon) { if (this.minLon < other.minLon) {
return false; return false
} }
if (this.minLat < other.minLat) { if (this.minLat < other.minLat) {
return false return false
} }
return true; return true
} }
getEast() { getEast() {
@ -147,32 +154,35 @@ export class BBox {
} }
contains(lonLat: [number, number]) { contains(lonLat: [number, number]) {
return this.minLat <= lonLat[1] && lonLat[1] <= this.maxLat return (
&& this.minLon <= lonLat[0] && lonLat[0] <= this.maxLon this.minLat <= lonLat[1] &&
lonLat[1] <= this.maxLat &&
this.minLon <= lonLat[0] &&
lonLat[0] <= this.maxLon
)
} }
pad(factor: number, maxIncrease = 2): BBox { pad(factor: number, maxIncrease = 2): BBox {
const latDiff = Math.min(maxIncrease / 2, Math.abs(this.maxLat - this.minLat) * factor) 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) const lonDiff = Math.min(maxIncrease / 2, Math.abs(this.maxLon - this.minLon) * factor)
return new BBox([[ return new BBox([
this.minLon - lonDiff, [this.minLon - lonDiff, this.minLat - latDiff],
this.minLat - latDiff [this.maxLon + lonDiff, this.maxLat + latDiff],
], [this.maxLon + lonDiff, ])
this.maxLat + latDiff]])
} }
padAbsolute(degrees: number): BBox { padAbsolute(degrees: number): BBox {
return new BBox([
return new BBox([[ [this.minLon - degrees, this.minLat - degrees],
this.minLon - degrees, [this.maxLon + degrees, this.maxLat + degrees],
this.minLat - degrees ])
], [this.maxLon + degrees,
this.maxLat + degrees]])
} }
toLeaflet() { toLeaflet() {
return [[this.minLat, this.minLon], [this.maxLat, this.maxLon]] return [
[this.minLat, this.minLon],
[this.maxLat, this.maxLon],
]
} }
asGeoJson(properties: any): any { asGeoJson(properties: any): any {
@ -181,16 +191,16 @@ export class BBox {
properties: properties, properties: properties,
geometry: { geometry: {
type: "Polygon", type: "Polygon",
coordinates: [[ coordinates: [
[
[this.minLon, this.minLat], [this.minLon, this.minLat],
[this.maxLon, this.minLat], [this.maxLon, this.minLat],
[this.maxLon, this.maxLat], [this.maxLon, this.maxLat],
[this.minLon, this.maxLat], [this.minLon, this.maxLat],
[this.minLon, this.minLat], [this.minLon, this.minLat],
],
]] ],
} },
} }
} }
@ -206,22 +216,22 @@ export class BBox {
return new BBox([].concat(boundsul, boundslr)) 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 [minLon, minLat] = GeoOperations.ConvertWgs84To900913([this.minLon, this.minLat])
const [maxLon, maxLat] = GeoOperations.ConvertWgs84To900913([this.maxLon, this.maxLat]) const [maxLon, maxLat] = GeoOperations.ConvertWgs84To900913([this.maxLon, this.maxLat])
return { return {
minLon, maxLon, minLon,
minLat, maxLat maxLon,
minLat,
maxLat,
} }
} }
private check() { private check() {
if (isNaN(this.maxLon) || isNaN(this.maxLat) || isNaN(this.minLon) || isNaN(this.minLat)) { if (isNaN(this.maxLon) || isNaN(this.maxLat) || isNaN(this.minLon) || isNaN(this.minLat)) {
console.trace("BBox with NaN detected:", this); console.trace("BBox with NaN detected:", this)
throw "BBOX has NAN"; 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 /// Given a feature source, calculates a list of OSM-contributors who mapped the latest versions
import {Store, UIEventSource} from "./UIEventSource"; import { Store, UIEventSource } from "./UIEventSource"
import FeaturePipeline from "./FeatureSource/FeaturePipeline"; import FeaturePipeline from "./FeatureSource/FeaturePipeline"
import Loc from "../Models/Loc"; import Loc from "../Models/Loc"
import {BBox} from "./BBox"; import { BBox } from "./BBox"
export default class ContributorCount { 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>()); constructor(state: {
private readonly state: { featurePipeline: FeaturePipeline, currentBounds: Store<BBox>, locationControl: Store<Loc> }; featurePipeline: FeaturePipeline
private lastUpdate: Date = undefined; currentBounds: Store<BBox>
locationControl: Store<Loc>
constructor(state: { featurePipeline: FeaturePipeline, currentBounds: Store<BBox>, locationControl: Store<Loc> }) { }) {
this.state = state; this.state = state
const self = this; const self = this
state.currentBounds.map(bbox => { state.currentBounds.map((bbox) => {
self.update(bbox) self.update(bbox)
}) })
state.featurePipeline.runningQuery.addCallbackAndRun( state.featurePipeline.runningQuery.addCallbackAndRun((_) =>
_ => self.update(state.currentBounds.data) self.update(state.currentBounds.data)
) )
} }
private update(bbox: BBox) { private update(bbox: BBox) {
if (bbox === undefined) { if (bbox === undefined) {
return; return
} }
const now = new Date(); const now = new Date()
if (this.lastUpdate !== undefined && ((now.getTime() - this.lastUpdate.getTime()) < 1000 * 60)) { if (
return; 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 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 list of featuresList) {
for (const feature of list) { for (const feature of list) {
const contributor = feature.properties["_last_edit:contributor"] const contributor = feature.properties["_last_edit:contributor"]
const count = hist.get(contributor) ?? 0; const count = hist.get(contributor) ?? 0
hist.set(contributor, count + 1) hist.set(contributor, count + 1)
} }
} }
this.Contributors.setData(hist) this.Contributors.setData(hist)
} }
} }

View file

@ -1,35 +1,37 @@
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"
import {QueryParameters} from "./Web/QueryParameters"; import { QueryParameters } from "./Web/QueryParameters"
import {AllKnownLayouts} from "../Customizations/AllKnownLayouts"; import { AllKnownLayouts } from "../Customizations/AllKnownLayouts"
import {FixedUiElement} from "../UI/Base/FixedUiElement"; import { FixedUiElement } from "../UI/Base/FixedUiElement"
import {Utils} from "../Utils"; import { Utils } from "../Utils"
import Combine from "../UI/Base/Combine"; import Combine from "../UI/Base/Combine"
import {SubtleButton} from "../UI/Base/SubtleButton"; import { SubtleButton } from "../UI/Base/SubtleButton"
import BaseUIElement from "../UI/BaseUIElement"; import BaseUIElement from "../UI/BaseUIElement"
import {UIEventSource} from "./UIEventSource"; import { UIEventSource } from "./UIEventSource"
import {LocalStorageSource} from "./Web/LocalStorageSource"; import { LocalStorageSource } from "./Web/LocalStorageSource"
import LZString from "lz-string"; import LZString from "lz-string"
import {FixLegacyTheme} from "../Models/ThemeConfig/Conversion/LegacyJsonConvert"; import { FixLegacyTheme } from "../Models/ThemeConfig/Conversion/LegacyJsonConvert"
import {LayerConfigJson} from "../Models/ThemeConfig/Json/LayerConfigJson"; import { LayerConfigJson } from "../Models/ThemeConfig/Json/LayerConfigJson"
import SharedTagRenderings from "../Customizations/SharedTagRenderings"; import SharedTagRenderings from "../Customizations/SharedTagRenderings"
import * as known_layers from "../assets/generated/known_layers.json" 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 * as licenses from "../assets/generated/license_info.json"
import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig"; import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig"
import {FixImages} from "../Models/ThemeConfig/Conversion/FixImages"; import { FixImages } from "../Models/ThemeConfig/Conversion/FixImages"
import Svg from "../Svg"; import Svg from "../Svg"
export default class DetermineLayout { 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 * Gets the correct layout for this website
*/ */
public static async GetLayout(): Promise<LayoutConfig> { public static async GetLayout(): Promise<LayoutConfig> {
const loadCustomThemeParam = QueryParameters.GetQueryParameter(
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") "userlayout",
const layoutFromBase64 = decodeURIComponent(loadCustomThemeParam.data); "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")) { if (layoutFromBase64.startsWith("http")) {
return await DetermineLayout.LoadRemoteTheme(layoutFromBase64) return await DetermineLayout.LoadRemoteTheme(layoutFromBase64)
@ -42,100 +44,111 @@ export default class DetermineLayout {
let layoutId: string = undefined 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 !== "") { if (path !== "theme.html" && path !== "") {
layoutId = path; layoutId = path
if (path.endsWith(".html")) { 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()) return AllKnownLayouts.allKnownLayouts.get(layoutId?.toLowerCase())
} }
public static LoadLayoutFromHash( public static LoadLayoutFromHash(userLayoutParam: UIEventSource<string>): LayoutConfig | null {
userLayoutParam: UIEventSource<string> let hash = location.hash.substr(1)
): LayoutConfig | null { let json: any
let hash = location.hash.substr(1);
let json: any;
try { try {
// layoutFromBase64 contains the name of the theme. This is partly to do tracking with goat counter // layoutFromBase64 contains the name of the theme. This is partly to do tracking with goat counter
const dedicatedHashFromLocalStorage = LocalStorageSource.Get( const dedicatedHashFromLocalStorage = LocalStorageSource.Get(
"user-layout-" + userLayoutParam.data?.replace(" ", "_") "user-layout-" + userLayoutParam.data?.replace(" ", "_")
); )
if (dedicatedHashFromLocalStorage.data?.length < 10) { if (dedicatedHashFromLocalStorage.data?.length < 10) {
dedicatedHashFromLocalStorage.setData(undefined); dedicatedHashFromLocalStorage.setData(undefined)
} }
const hashFromLocalStorage = LocalStorageSource.Get( const hashFromLocalStorage = LocalStorageSource.Get("last-loaded-user-layout")
"last-loaded-user-layout"
);
if (hash.length < 10) { if (hash.length < 10) {
hash = hash = dedicatedHashFromLocalStorage.data ?? hashFromLocalStorage.data
dedicatedHashFromLocalStorage.data ??
hashFromLocalStorage.data;
} else { } else {
console.log("Saving hash to local storage"); console.log("Saving hash to local storage")
hashFromLocalStorage.setData(hash); hashFromLocalStorage.setData(hash)
dedicatedHashFromLocalStorage.setData(hash); dedicatedHashFromLocalStorage.setData(hash)
} }
try { try {
json = JSON.parse(atob(hash)); json = JSON.parse(atob(hash))
} catch (e) { } catch (e) {
// We try to decode with lz-string // We try to decode with lz-string
try { try {
json = JSON.parse(Utils.UnMinify(LZString.decompressFromBase64(hash))) json = JSON.parse(Utils.UnMinify(LZString.decompressFromBase64(hash)))
} catch (e) { } catch (e) {
console.error(e) console.error(e)
DetermineLayout.ShowErrorOnCustomTheme("Could not decode the hash", new FixedUiElement("Not a valid (LZ-compressed) JSON")) DetermineLayout.ShowErrorOnCustomTheme(
return null; "Could not decode the hash",
new FixedUiElement("Not a valid (LZ-compressed) JSON")
)
return null
} }
} }
const layoutToUse = DetermineLayout.prepCustomTheme(json) const layoutToUse = DetermineLayout.prepCustomTheme(json)
userLayoutParam.setData(layoutToUse.id); userLayoutParam.setData(layoutToUse.id)
return layoutToUse return layoutToUse
} catch (e) { } catch (e) {
console.error(e) console.error(e)
if (hash === undefined || hash.length < 10) { 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) this.ShowErrorOnCustomTheme("Could not parse the hash", new FixedUiElement(e), json)
return null; return null
} }
} }
public static ShowErrorOnCustomTheme( public static ShowErrorOnCustomTheme(
intro: string = "Error: could not parse the custom layout:", intro: string = "Error: could not parse the custom layout:",
error: BaseUIElement, error: BaseUIElement,
json?: any) { json?: any
) {
new Combine([ new Combine([
intro, intro,
error.SetClass("alert"), error.SetClass("alert"),
new SubtleButton(Svg.back_svg(), new SubtleButton(Svg.back_svg(), "Go back to the theme overview", {
"Go back to the theme overview", url: window.location.protocol + "//" + window.location.host + "/index.html",
{url: window.location.protocol + "//" + window.location.host + "/index.html", newTab: false}), newTab: false,
json !== undefined ? new SubtleButton(Svg.download_svg(),"Download the JSON file").onClick(() => { }),
Utils.offerContentsAsDownloadableFile(JSON.stringify(json, null, " "), "theme_definition.json") json !== undefined
}) : 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") .SetClass("flex flex-col clickable")
.AttachTo("centermessage"); .AttachTo("centermessage")
} }
private static prepCustomTheme(json: any, sourceUrl?: string, forceId?: string): LayoutConfig { private static prepCustomTheme(json: any, sourceUrl?: string, forceId?: string): LayoutConfig {
if (json.layers === undefined && json.tagRenderings !== undefined) { if (json.layers === undefined && json.tagRenderings !== undefined) {
const iconTr = json.mapRendering.map(mr => mr.icon).find(icon => icon !== undefined) const iconTr = json.mapRendering.map((mr) => mr.icon).find((icon) => icon !== undefined)
const icon = new TagRenderingConfig(iconTr).render.txt const icon = new TagRenderingConfig(iconTr).render.txt
json = { json = {
id: json.id, id: json.id,
description: json.description, description: json.description,
descriptionTail: { 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, icon,
title: json.name, title: json.name,
@ -151,13 +164,16 @@ export default class DetermineLayout {
const converState = { const converState = {
tagRenderings: SharedTagRenderings.SharedTagRenderingJson, tagRenderings: SharedTagRenderings.SharedTagRenderingJson,
sharedLayers: knownLayersDict, sharedLayers: knownLayersDict,
publicLayers: new Set<string>() publicLayers: new Set<string>(),
} }
json = new FixLegacyTheme().convertStrict(json, "While loading a dynamic theme") 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 = new FixImages(DetermineLayout._knownImages).convertStrict(
json.enableNoteImports = json.enableNoteImports ?? false; json,
"While fixing the images"
)
json.enableNoteImports = json.enableNoteImports ?? false
json = new PrepareTheme(converState).convertStrict(json, "While preparing a dynamic theme") json = new PrepareTheme(converState).convertStrict(json, "While preparing a dynamic theme")
console.log("The layoutconfig is ", json) console.log("The layoutconfig is ", json)
@ -165,27 +181,27 @@ export default class DetermineLayout {
return new LayoutConfig(json, false, { return new LayoutConfig(json, false, {
definitionRaw: JSON.stringify(raw, null, " "), definitionRaw: JSON.stringify(raw, null, " "),
definedAtUrl: sourceUrl definedAtUrl: sourceUrl,
}) })
} }
private static async LoadRemoteTheme(link: string): Promise<LayoutConfig | null> { 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>...`) new FixedUiElement(`Downloading the theme from the <a href="${link}">link</a>...`).AttachTo(
.AttachTo("centermessage"); "centermessage"
)
try { try {
let parsed = await Utils.downloadJson(link) let parsed = await Utils.downloadJson(link)
try { try {
let forcedId = parsed.id let forcedId = parsed.id
const url = new URL(link) const url = new URL(link)
if (!(url.hostname === "localhost" || url.hostname === "127.0.0.1")) { if (!(url.hostname === "localhost" || url.hostname === "127.0.0.1")) {
forcedId = link; forcedId = link
} }
console.log("Loaded remote link:", link) console.log("Loaded remote link:", link)
return DetermineLayout.prepCustomTheme(parsed, link, forcedId); return DetermineLayout.prepCustomTheme(parsed, link, forcedId)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
DetermineLayout.ShowErrorOnCustomTheme( DetermineLayout.ShowErrorOnCustomTheme(
@ -193,17 +209,15 @@ export default class DetermineLayout {
new FixedUiElement(e), new FixedUiElement(e),
parsed parsed
) )
return null; return null
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e)
DetermineLayout.ShowErrorOnCustomTheme( DetermineLayout.ShowErrorOnCustomTheme(
`<a href="${link}">${link}</a> is invalid - probably not found or invalid JSON:`, `<a href="${link}">${link}</a> is invalid - probably not found or invalid JSON:`,
new FixedUiElement(e) new FixedUiElement(e)
) )
return null; return null
} }
} }
} }

View file

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

View file

@ -1,11 +1,11 @@
import {GeoOperations} from "./GeoOperations"; import { GeoOperations } from "./GeoOperations"
import Combine from "../UI/Base/Combine"; import Combine from "../UI/Base/Combine"
import RelationsTracker from "./Osm/RelationsTracker"; import RelationsTracker from "./Osm/RelationsTracker"
import BaseUIElement from "../UI/BaseUIElement"; import BaseUIElement from "../UI/BaseUIElement"
import List from "../UI/Base/List"; import List from "../UI/Base/List"
import Title from "../UI/Base/Title"; import Title from "../UI/Base/Title"
import {BBox} from "./BBox"; import { BBox } from "./BBox"
import {Feature, Geometry, MultiPolygon, Polygon} from "@turf/turf"; import { Feature, Geometry, MultiPolygon, Polygon } from "@turf/turf"
export interface ExtraFuncParams { export interface ExtraFuncParams {
/** /**
@ -13,7 +13,7 @@ export interface ExtraFuncParams {
* Note that more features then requested can be given back. * Note that more features then requested can be given back.
* Format: [ [ geojson, geojson, geojson, ... ], [geojson, ...], ...] * 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 memberships: RelationsTracker
getFeatureById: (id: string) => Feature<Geometry, { id: string }> 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 * Describes a function that is added to a geojson object in order to calculate calculated tags
*/ */
interface ExtraFunction { interface ExtraFunction {
readonly _name: string; readonly _name: string
readonly _args: string[]; readonly _args: string[]
readonly _doc: string; readonly _doc: string
readonly _f: (params: ExtraFuncParams, feat: Feature<Geometry, any>) => any; readonly _f: (params: ExtraFuncParams, feat: Feature<Geometry, any>) => any
} }
class EnclosingFunc implements ExtraFunction { class EnclosingFunc implements ExtraFunction {
_name = "enclosingFeatures" _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}[]`", "The result is a list of features: `{feat: Polygon}[]`",
"This function will never return the feature itself."].join("\n") "This function will never return the feature itself.",
_args = ["...layerIds - one or more layer ids of the layer from which every feature is checked for overlap)"] ].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>) { _f(params: ExtraFuncParams, feat: Feature<Geometry, any>) {
return (...layerIds: string[]) => { return (...layerIds: string[]) => {
@ -45,10 +49,10 @@ class EnclosingFunc implements ExtraFunction {
for (const layerId of layerIds) { for (const layerId of layerIds) {
const otherFeaturess = params.getFeaturesWithin(layerId, bbox) const otherFeaturess = params.getFeaturesWithin(layerId, bbox)
if (otherFeaturess === undefined) { if (otherFeaturess === undefined) {
continue; continue
} }
if (otherFeaturess.length === 0) { if (otherFeaturess.length === 0) {
continue; continue
} }
for (const otherFeatures of otherFeaturess) { for (const otherFeatures of otherFeaturess) {
for (const otherFeature of otherFeatures) { for (const otherFeature of otherFeatures) {
@ -56,26 +60,33 @@ class EnclosingFunc implements ExtraFunction {
continue continue
} }
seenIds.add(otherFeature.properties.id) seenIds.add(otherFeature.properties.id)
if (otherFeature.geometry.type !== "Polygon" && otherFeature.geometry.type !== "MultiPolygon") { if (
continue; otherFeature.geometry.type !== "Polygon" &&
otherFeature.geometry.type !== "MultiPolygon"
) {
continue
} }
if (GeoOperations.completelyWithin(feat, <Feature<Polygon | MultiPolygon, any>>otherFeature)) { if (
GeoOperations.completelyWithin(
feat,
<Feature<Polygon | MultiPolygon, any>>otherFeature
)
) {
result.push({ feat: otherFeature }) result.push({ feat: otherFeature })
} }
} }
} }
} }
return result; return result
} }
} }
} }
class OverlapFunc implements ExtraFunction { class OverlapFunc implements ExtraFunction {
_name = "overlapWith"
_doc = [
_name = "overlapWith"; "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.",
_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.", "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.", "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,22 +94,24 @@ 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')`", "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") ].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) { _f(params, feat) {
return (...layerIds: string[]) => { return (...layerIds: string[]) => {
const result: { feat: any, overlap: number }[] = [] const result: { feat: any; overlap: number }[] = []
const seenIds = new Set<string>() const seenIds = new Set<string>()
const bbox = BBox.get(feat) const bbox = BBox.get(feat)
for (const layerId of layerIds) { for (const layerId of layerIds) {
const otherFeaturess = params.getFeaturesWithin(layerId, bbox) const otherFeaturess = params.getFeaturesWithin(layerId, bbox)
if (otherFeaturess === undefined) { if (otherFeaturess === undefined) {
continue; continue
} }
if (otherFeaturess.length === 0) { if (otherFeaturess.length === 0) {
continue; continue
} }
for (const otherFeatures of otherFeaturess) { for (const otherFeatures of otherFeaturess) {
const overlap = GeoOperations.calculateOverlap(feat, otherFeatures) const overlap = GeoOperations.calculateOverlap(feat, otherFeatures)
@ -113,39 +126,38 @@ class OverlapFunc implements ExtraFunction {
} }
result.sort((a, b) => b.overlap - a.overlap) result.sort((a, b) => b.overlap - a.overlap)
return result; return result
} }
} }
} }
class IntersectionFunc implements ExtraFunction { class IntersectionFunc implements ExtraFunction {
_name = "intersectionsWith"
_doc =
_name = "intersectionsWith"; "Gives the intersection points with selected features. Only works with (Multi)Polygons and LineStrings.\n\n" +
_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" + "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" + "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." "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) { _f(params: ExtraFuncParams, feat) {
return (...layerIds: string[]) => { return (...layerIds: string[]) => {
const result: { feat: any, intersections: [number, number][] }[] = [] const result: { feat: any; intersections: [number, number][] }[] = []
const bbox = BBox.get(feat) const bbox = BBox.get(feat)
for (const layerId of layerIds) { for (const layerId of layerIds) {
const otherLayers = params.getFeaturesWithin(layerId, bbox) const otherLayers = params.getFeaturesWithin(layerId, bbox)
if (otherLayers === undefined) { if (otherLayers === undefined) {
continue; continue
} }
if (otherLayers.length === 0) { if (otherLayers.length === 0) {
continue; continue
} }
for (const tile of otherLayers) { for (const tile of otherLayers) {
for (const otherFeature of tile) { for (const otherFeature of tile) {
const intersections = GeoOperations.LineIntersections(feat, otherFeature) const intersections = GeoOperations.LineIntersections(feat, otherFeature)
if (intersections.length === 0) { if (intersections.length === 0) {
continue continue
@ -155,63 +167,72 @@ class IntersectionFunc implements ExtraFunction {
} }
} }
return result; return result
} }
} }
} }
class DistanceToFunc implements ExtraFunction { class DistanceToFunc implements ExtraFunction {
_name = "distanceTo"
_name = "distanceTo"; _doc =
_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"; "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"] _args = ["feature OR featureID OR longitude", "undefined OR latitude"]
_f(featuresPerLayer, feature) { _f(featuresPerLayer, feature) {
return (arg0, lat) => { return (arg0, lat) => {
if (arg0 === undefined) { if (arg0 === undefined) {
return undefined; return undefined
} }
if (typeof arg0 === "number") { if (typeof arg0 === "number") {
// Feature._lon and ._lat is conveniently place by one of the other metatags // Feature._lon and ._lat is conveniently place by one of the other metatags
return GeoOperations.distanceBetween([arg0, lat], GeoOperations.centerpointCoordinates(feature)); return GeoOperations.distanceBetween(
[arg0, lat],
GeoOperations.centerpointCoordinates(feature)
)
} }
if (typeof arg0 === "string") { if (typeof arg0 === "string") {
// This is an identifier // This is an identifier
const feature = featuresPerLayer.getFeatureById(arg0) const feature = featuresPerLayer.getFeatureById(arg0)
if (feature === undefined) { if (feature === undefined) {
return undefined; return undefined
} }
arg0 = feature; arg0 = feature
} }
// arg0 is probably a geojsonfeature // 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 { class ClosestObjectFunc implements ExtraFunction {
_name = "closest" _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"] _args = ["list of features or a layer name or '*' to get all features"]
_f(params, feature) { _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 { class ClosestNObjectFunc implements ExtraFunction {
_name = "closestn" _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" + "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)" "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. * Gets the closes N features, sorted by ascending distance.
@ -223,45 +244,61 @@ class ClosestNObjectFunc implements ExtraFunction {
* @constructor * @constructor
* @private * @private
*/ */
static GetClosestNFeatures(params: ExtraFuncParams, static GetClosestNFeatures(
params: ExtraFuncParams,
feature: any, feature: any,
features: string | any[], features: string | any[],
options?: { maxFeatures?: number, uniqueTag?: string | undefined, maxDistance?: number }): { feat: any, distance: number }[] { options?: { maxFeatures?: number; uniqueTag?: string | undefined; maxDistance?: number }
): { feat: any; distance: number }[] {
const maxFeatures = options?.maxFeatures ?? 1 const maxFeatures = options?.maxFeatures ?? 1
const maxDistance = options?.maxDistance ?? 500 const maxDistance = options?.maxDistance ?? 500
const uniqueTag: string | undefined = options?.uniqueTag const uniqueTag: string | undefined = options?.uniqueTag
if (typeof features === "string") { if (typeof features === "string") {
const name = features 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)) features = params.getFeaturesWithin(name, new BBox(bbox.geometry.coordinates))
} else { } else {
features = [features] features = [features]
} }
if (features === undefined) { if (features === undefined) {
return; return
} }
const selfCenter = GeoOperations.centerpointCoordinates(feature) const selfCenter = GeoOperations.centerpointCoordinates(feature)
let closestFeatures: { feat: any, distance: number }[] = []; let closestFeatures: { feat: any; distance: number }[] = []
for (const featureList of features) { for (const featureList of features) {
// Features is provided by 'getFeaturesWithin' which returns a list of lists of features, hence the double loop here // Features is provided by 'getFeaturesWithin' which returns a list of lists of features, hence the double loop here
for (const otherFeature of featureList) { for (const otherFeature of featureList) {
if (
if (otherFeature === feature || otherFeature.properties.id === feature.properties.id) { otherFeature === feature ||
continue; // We ignore self otherFeature.properties.id === feature.properties.id
) {
continue // We ignore self
} }
const distance = GeoOperations.distanceBetween( const distance = GeoOperations.distanceBetween(
GeoOperations.centerpointCoordinates(otherFeature), GeoOperations.centerpointCoordinates(otherFeature),
selfCenter selfCenter
) )
if (distance === undefined || distance === null || isNaN(distance)) { 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!" throw "Undefined distance!"
} }
if (distance === 0) { 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) { if (distance > maxDistance) {
@ -272,13 +309,15 @@ class ClosestNObjectFunc implements ExtraFunction {
// This is the first matching feature we find - always add it // This is the first matching feature we find - always add it
closestFeatures.push({ closestFeatures.push({
feat: otherFeature, feat: otherFeature,
distance: distance distance: distance,
}) })
continue; continue
} }
if (
if (closestFeatures.length >= maxFeatures && closestFeatures[maxFeatures - 1].distance < distance) { closestFeatures.length >= maxFeatures &&
closestFeatures[maxFeatures - 1].distance < distance
) {
// The last feature of the list (and thus the furthest away is still closer // 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! // No use for checking, as we already have plenty of features!
continue continue
@ -286,11 +325,13 @@ class ClosestNObjectFunc implements ExtraFunction {
let targetIndex = closestFeatures.length let targetIndex = closestFeatures.length
for (let i = 0; i < closestFeatures.length; i++) { for (let i = 0; i < closestFeatures.length; i++) {
const closestFeature = closestFeatures[i]; const closestFeature = closestFeatures[i]
if (uniqueTag !== undefined) { if (uniqueTag !== undefined) {
const uniqueTagsMatch = otherFeature.properties[uniqueTag] !== undefined && const uniqueTagsMatch =
closestFeature.feat.properties[uniqueTag] === otherFeature.properties[uniqueTag] otherFeature.properties[uniqueTag] !== undefined &&
closestFeature.feat.properties[uniqueTag] ===
otherFeature.properties[uniqueTag]
if (uniqueTagsMatch) { if (uniqueTagsMatch) {
targetIndex = -1 targetIndex = -1
if (closestFeature.distance > distance) { if (closestFeature.distance > distance) {
@ -300,7 +341,7 @@ class ClosestNObjectFunc implements ExtraFunction {
// so we replace directly // 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) { if (targetIndex == -1) {
continue; // value is already swapped by the unique tag continue // value is already swapped by the unique tag
} }
if (targetIndex < maxFeatures) { if (targetIndex < maxFeatures) {
// insert and drop one // insert and drop one
closestFeatures.splice(targetIndex, 0, { closestFeatures.splice(targetIndex, 0, {
feat: otherFeature, feat: otherFeature,
distance: distance distance: distance,
}) })
if (closestFeatures.length >= maxFeatures) { if (closestFeatures.length >= maxFeatures) {
closestFeatures.splice(maxFeatures, 1) closestFeatures.splice(maxFeatures, 1)
@ -337,19 +378,15 @@ class ClosestNObjectFunc implements ExtraFunction {
// Overwrite the last element // Overwrite the last element
closestFeatures[targetIndex] = { closestFeatures[targetIndex] = {
feat: otherFeature, feat: otherFeature,
distance: distance distance: distance,
}
}
} }
} }
return closestFeatures; }
}
return closestFeatures
} }
_f(params, feature) { _f(params, feature) {
return (features, amount, uniqueTag, maxDistanceInMeters) => { return (features, amount, uniqueTag, maxDistanceInMeters) => {
let distance: number = Number(maxDistanceInMeters) let distance: number = Number(maxDistanceInMeters)
if (isNaN(distance)) { if (isNaN(distance)) {
@ -358,60 +395,54 @@ class ClosestNObjectFunc implements ExtraFunction {
return ClosestNObjectFunc.GetClosestNFeatures(params, feature, features, { return ClosestNObjectFunc.GetClosestNFeatures(params, feature, features, {
maxFeatures: Number(amount), maxFeatures: Number(amount),
uniqueTag: uniqueTag, uniqueTag: uniqueTag,
maxDistance: distance maxDistance: distance,
}); })
} }
} }
} }
class Memberships implements ExtraFunction { class Memberships implements ExtraFunction {
_name = "memberships" _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" + "\n\n" +
"For example: `_part_of_walking_routes=feat.memberships().map(r => r.relation.tags.name).join(';')`" "For example: `_part_of_walking_routes=feat.memberships().map(r => r.relation.tags.name).join(';')`"
_args = [] _args = []
_f(params, feat) { _f(params, feat) {
return () => return () => params.memberships.knownRelations.data.get(feat.properties.id) ?? []
params.memberships.knownRelations.data.get(feat.properties.id) ?? []
} }
} }
class GetParsed implements ExtraFunction { class GetParsed implements ExtraFunction {
_name = "get" _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"] _args = ["key"]
_f(params, feat) { _f(params, feat) {
return key => { return (key) => {
const value = feat.properties[key] const value = feat.properties[key]
if (value === undefined) { if (value === undefined) {
return undefined; return undefined
} }
try { try {
const parsed = JSON.parse(value) const parsed = JSON.parse(value)
if (parsed === null) { if (parsed === null) {
return undefined; return undefined
} }
return parsed; return parsed
} catch (e) { } catch (e) {
console.warn("Could not parse property " + key + " due to: " + e + ", the value is " + value) console.warn(
return undefined; "Could not parse property " + key + " due to: " + e + ", the value is " + value
} )
return undefined
} }
}
} }
} }
export class ExtraFunctions { export class ExtraFunctions {
static readonly intro = new Combine([ static readonly intro = new Combine([
new Title("Calculating tags with Javascript", 2), 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.", "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([ new List([
"DO NOT DO THIS AS BEGINNER", "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", "**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.:", "To enable this feature, add a field `calculatedTags` in the layer object, e.g.:",
"````", "````",
"\"calculatedTags\": [", '"calculatedTags": [',
" \"_someKey=javascript-expression\",", ' "_someKey=javascript-expression",',
" \"name=feat.properties.name ?? feat.properties.ref ?? feat.properties.operator\",", ' "name=feat.properties.name ?? feat.properties.ref ?? feat.properties.operator",',
" \"_distanceCloserThen3Km=feat.distanceTo( some_lon, some_lat) < 3 ? 'yes' : 'no'\" ", " \"_distanceCloserThen3Km=feat.distanceTo( some_lon, some_lat) < 3 ? 'yes' : 'no'\" ",
" ]", " ]",
"````", "````",
@ -436,11 +467,12 @@ export class ExtraFunctions {
new List([ new List([
"`area` contains the surface area (in square meters) of the object", "`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:" "Some advanced functions are available on **feat** as well:",
]).SetClass("flex-col").AsMarkdown(); ])
.SetClass("flex-col")
.AsMarkdown()
private static readonly allFuncs: ExtraFunction[] = [ private static readonly allFuncs: ExtraFunction[] = [
new DistanceToFunc(), new DistanceToFunc(),
@ -450,8 +482,8 @@ export class ExtraFunctions {
new ClosestObjectFunc(), new ClosestObjectFunc(),
new ClosestNObjectFunc(), new ClosestNObjectFunc(),
new Memberships(), new Memberships(),
new GetParsed() new GetParsed(),
]; ]
public static FullPatchFeature(params: ExtraFuncParams, feature) { public static FullPatchFeature(params: ExtraFuncParams, feature) {
if (feature._is_patched) { if (feature._is_patched) {
@ -464,20 +496,15 @@ export class ExtraFunctions {
} }
public static HelpText(): BaseUIElement { public static HelpText(): BaseUIElement {
const elems = [] const elems = []
for (const func of ExtraFunctions.allFuncs) { for (const func of ExtraFunctions.allFuncs) {
elems.push(new Title(func._name, 3), elems.push(new Title(func._name, 3), func._doc, new List(func._args ?? [], true))
func._doc,
new List(func._args ?? [], true))
} }
return new Combine([ return new Combine([
ExtraFunctions.intro, ExtraFunctions.intro,
new List(ExtraFunctions.allFuncs.map(func => `[${func._name}](#${func._name})`)), new List(ExtraFunctions.allFuncs.map((func) => `[${func._name}](#${func._name})`)),
...elems ...elems,
]); ])
} }
} }

View file

@ -1,26 +1,30 @@
import {FeatureSourceForLayer, Tiled} from "../FeatureSource"; import { FeatureSourceForLayer, Tiled } from "../FeatureSource"
import MetaTagging from "../../MetaTagging"; import MetaTagging from "../../MetaTagging"
import {ElementStorage} from "../../ElementStorage"; import { ElementStorage } from "../../ElementStorage"
import {ExtraFuncParams} from "../../ExtraFunctions"; import { ExtraFuncParams } from "../../ExtraFunctions"
import FeaturePipeline from "../FeaturePipeline"; import FeaturePipeline from "../FeaturePipeline"
import {BBox} from "../../BBox"; import { BBox } from "../../BBox"
import {UIEventSource} from "../../UIEventSource"; import { UIEventSource } from "../../UIEventSource"
/**** /****
* Concerned with the logic of updating the right layer at the right time * Concerned with the logic of updating the right layer at the right time
*/ */
class MetatagUpdater { class MetatagUpdater {
public readonly neededLayerBboxes = new Map<string /*layerId*/, BBox>() public readonly neededLayerBboxes = new Map<string /*layerId*/, BBox>()
private source: FeatureSourceForLayer & Tiled; private source: FeatureSourceForLayer & Tiled
private readonly params: ExtraFuncParams private readonly params: ExtraFuncParams
private state: { allElements?: ElementStorage }; private state: { allElements?: ElementStorage }
private readonly isDirty = new UIEventSource(false) private readonly isDirty = new UIEventSource(false)
constructor(source: FeatureSourceForLayer & Tiled, state: { allElements?: ElementStorage }, featurePipeline: FeaturePipeline) { constructor(
this.state = state; source: FeatureSourceForLayer & Tiled,
this.source = source; state: { allElements?: ElementStorage },
const self = this; featurePipeline: FeaturePipeline
) {
this.state = state
this.source = source
const self = this
this.params = { this.params = {
getFeatureById(id) { getFeatureById(id) {
return state.allElements.ContainingFeatures.get(id) return state.allElements.ContainingFeatures.get(id)
@ -29,21 +33,20 @@ class MetatagUpdater {
// We keep track of the BBOX that this source needs // We keep track of the BBOX that this source needs
let oldBbox: BBox = self.neededLayerBboxes.get(layerId) let oldBbox: BBox = self.neededLayerBboxes.get(layerId)
if (oldBbox === undefined) { if (oldBbox === undefined) {
self.neededLayerBboxes.set(layerId, bbox); self.neededLayerBboxes.set(layerId, bbox)
} else if (!bbox.isContainedIn(oldBbox)) { } else if (!bbox.isContainedIn(oldBbox)) {
self.neededLayerBboxes.set(layerId, oldBbox.unionWith(bbox)) self.neededLayerBboxes.set(layerId, oldBbox.unionWith(bbox))
} }
return featurePipeline.GetFeaturesWithin(layerId, 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) { if (dirty) {
self.updateMetaTags() self.updateMetaTags()
} }
}) })
this.source.features.addCallbackAndRunD(_ => self.isDirty.setData(true)) this.source.features.addCallbackAndRunD((_) => self.isDirty.setData(true))
} }
public requestUpdate() { public requestUpdate() {
@ -57,56 +60,58 @@ class MetatagUpdater {
this.isDirty.setData(false) this.isDirty.setData(false)
return return
} }
MetaTagging.addMetatags( MetaTagging.addMetatags(features, this.params, this.source.layer.layerDef, this.state)
features,
this.params,
this.source.layer.layerDef,
this.state)
this.isDirty.setData(false) this.isDirty.setData(false)
} }
} }
export default class MetaTagRecalculator { export default class MetaTagRecalculator {
private _state: { private _state: {
allElements?: ElementStorage allElements?: ElementStorage
}; }
private _featurePipeline: FeaturePipeline; private _featurePipeline: FeaturePipeline
private readonly _alreadyRegistered: Set<FeatureSourceForLayer & Tiled> = new Set<FeatureSourceForLayer & Tiled>() private readonly _alreadyRegistered: Set<FeatureSourceForLayer & Tiled> = new Set<
FeatureSourceForLayer & Tiled
>()
private readonly _notifiers: MetatagUpdater[] = [] private readonly _notifiers: MetatagUpdater[] = []
/** /**
* The meta tag recalculator receives tiles of layers via the 'registerSource'-function. * 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 * 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) { constructor(
this._featurePipeline = featurePipeline; state: { allElements?: ElementStorage; currentView: FeatureSourceForLayer & Tiled },
this._state = state; featurePipeline: FeaturePipeline
) {
this._featurePipeline = featurePipeline
this._state = state
if (state.currentView !== undefined) { if (state.currentView !== undefined) {
const currentViewUpdater = new MetatagUpdater(state.currentView, this._state, this._featurePipeline) const currentViewUpdater = new MetatagUpdater(
state.currentView,
this._state,
this._featurePipeline
)
this._alreadyRegistered.add(state.currentView) this._alreadyRegistered.add(state.currentView)
this._notifiers.push(currentViewUpdater) this._notifiers.push(currentViewUpdater)
state.currentView.features.addCallback(_ => { state.currentView.features.addCallback((_) => {
console.debug("Requesting an update for currentView") console.debug("Requesting an update for currentView")
currentViewUpdater.updateMetaTags(); currentViewUpdater.updateMetaTags()
}) })
} }
} }
public registerSource(source: FeatureSourceForLayer & Tiled, recalculateOnEveryChange = false) { public registerSource(source: FeatureSourceForLayer & Tiled, recalculateOnEveryChange = false) {
if (source === undefined) { if (source === undefined) {
return; return
} }
if (this._alreadyRegistered.has(source)) { if (this._alreadyRegistered.has(source)) {
return; return
} }
this._alreadyRegistered.add(source) this._alreadyRegistered.add(source)
this._notifiers.push(new MetatagUpdater(source, this._state, this._featurePipeline)) this._notifiers.push(new MetatagUpdater(source, this._state, this._featurePipeline))
const self = this; const self = this
source.features.addCallbackAndRunD(_ => { source.features.addCallbackAndRunD((_) => {
const layerName = source.layer.layerDef.id const layerName = source.layer.layerDef.id
for (const updater of self._notifiers) { for (const updater of self._notifiers) {
const neededBbox = updater.neededLayerBboxes.get(layerName) 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 FeatureSource from "../FeatureSource"
import {Store} from "../../UIEventSource"; import { Store } from "../../UIEventSource"
import {ElementStorage} from "../../ElementStorage"; import { ElementStorage } from "../../ElementStorage"
/** /**
* Makes sure that every feature is added to the ElementsStorage, so that the tags-eventsource can be retrieved * Makes sure that every feature is added to the ElementsStorage, so that the tags-eventsource can be retrieved
*/ */
export default class RegisteringAllFromFeatureSourceActor { export default class RegisteringAllFromFeatureSourceActor {
public readonly features: Store<{ feature: any; freshness: Date }[]>; public readonly features: Store<{ feature: any; freshness: Date }[]>
public readonly name; public readonly name
constructor(source: FeatureSource, allElements: ElementStorage) { constructor(source: FeatureSource, allElements: ElementStorage) {
this.features = source.features; this.features = source.features
this.name = "RegisteringSource of " + source.name; this.name = "RegisteringSource of " + source.name
this.features.addCallbackAndRunD(features => { this.features.addCallbackAndRunD((features) => {
for (const feature of features) { for (const feature of features) {
allElements.addOrGetElement(feature.feature) allElements.addOrGetElement(feature.feature)
} }
}) })
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -1,99 +1,112 @@
import {UIEventSource} from "../../UIEventSource"; import { UIEventSource } from "../../UIEventSource"
import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource"; import FeatureSource, { FeatureSourceForLayer, IndexedFeatureSource, Tiled } from "../FeatureSource"
import FilteredLayer from "../../../Models/FilteredLayer"; import FilteredLayer from "../../../Models/FilteredLayer"
import {Tiles} from "../../../Models/TileRange"; import { Tiles } from "../../../Models/TileRange"
import {BBox} from "../../BBox"; import { BBox } from "../../BBox"
export default class FeatureSourceMerger
export default class FeatureSourceMerger implements FeatureSourceForLayer, Tiled, IndexedFeatureSource { implements FeatureSourceForLayer, Tiled, IndexedFeatureSource
{
public features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<{ feature: any; freshness: Date }[]>([]); public features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<
public readonly name; { feature: any; freshness: Date }[]
>([])
public readonly name
public readonly layer: FilteredLayer public readonly layer: FilteredLayer
public readonly tileIndex: number; public readonly tileIndex: number
public readonly bbox: BBox; public readonly bbox: BBox
public readonly containedIds: UIEventSource<Set<string>> = new UIEventSource<Set<string>>(new Set()) public readonly containedIds: UIEventSource<Set<string>> = new UIEventSource<Set<string>>(
private readonly _sources: UIEventSource<FeatureSource[]>; new Set()
)
private readonly _sources: UIEventSource<FeatureSource[]>
/** /**
* Merges features from different featureSources for a single layer * 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 * 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[]>) { constructor(
this.tileIndex = tileIndex; layer: FilteredLayer,
this.bbox = bbox; tileIndex: number,
this._sources = sources; bbox: BBox,
this.layer = layer; sources: UIEventSource<FeatureSource[]>
this.name = "FeatureSourceMerger(" + layer.layerDef.id + ", " + Tiles.tile_from_index(tileIndex).join(",") + ")" ) {
const self = this; 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 => { sources.addCallbackAndRunD((sources) => {
let newSourceRegistered = false; let newSourceRegistered = false
for (let i = 0; i < sources.length; i++) { for (let i = 0; i < sources.length; i++) {
let source = sources[i]; let source = sources[i]
if (handledSources.has(source)) { if (handledSources.has(source)) {
continue continue
} }
handledSources.add(source) handledSources.add(source)
newSourceRegistered = true newSourceRegistered = true
source.features.addCallback(() => { source.features.addCallback(() => {
self.Update(); self.Update()
}); })
if (newSourceRegistered) { if (newSourceRegistered) {
self.Update(); self.Update()
} }
} }
}) })
} }
private Update() { private Update() {
let somethingChanged = false
let somethingChanged = false; const all: Map<string, { feature: any; freshness: Date }> = new Map<
const all: Map<string, { feature: any, freshness: Date }> = new Map<string, { feature: any; freshness: Date }>(); string,
{ feature: any; freshness: Date }
>()
// We seed the dictionary with the previously loaded features // We seed the dictionary with the previously loaded features
const oldValues = this.features.data ?? []; const oldValues = this.features.data ?? []
for (const oldValue of oldValues) { for (const oldValue of oldValues) {
all.set(oldValue.feature.id, oldValue) all.set(oldValue.feature.id, oldValue)
} }
for (const source of this._sources.data) { for (const source of this._sources.data) {
if (source?.features?.data === undefined) { if (source?.features?.data === undefined) {
continue; continue
} }
for (const f of source.features.data) { for (const f of source.features.data) {
const id = f.feature.properties.id; const id = f.feature.properties.id
if (!all.has(id)) { if (!all.has(id)) {
// This is a new feature // This is a new feature
somethingChanged = true; somethingChanged = true
all.set(id, f); all.set(id, f)
continue; continue
} }
// This value has been seen already, either in a previous run or by a previous datasource // This value has been seen already, either in a previous run or by a previous datasource
// Let's figure out if something changed // Let's figure out if something changed
const oldV = all.get(id); const oldV = all.get(id)
if (oldV.freshness < f.freshness) { if (oldV.freshness < f.freshness) {
// Jup, this feature is fresher // Jup, this feature is fresher
all.set(id, f); all.set(id, f)
somethingChanged = true; somethingChanged = true
} }
} }
} }
if (!somethingChanged) { if (!somethingChanged) {
// We don't bother triggering an update // We don't bother triggering an update
return; return
} }
const newList = []; const newList = []
all.forEach((value, _) => { all.forEach((value, _) => {
newList.push(value) newList.push(value)
}) })
this.containedIds.setData(new Set(all.keys())) 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 { Store, UIEventSource } from "../../UIEventSource"
import FilteredLayer, {FilterState} from "../../../Models/FilteredLayer"; import FilteredLayer, { FilterState } from "../../../Models/FilteredLayer"
import {FeatureSourceForLayer, Tiled} from "../FeatureSource"; import { FeatureSourceForLayer, Tiled } from "../FeatureSource"
import {BBox} from "../../BBox"; import { BBox } from "../../BBox"
import {ElementStorage} from "../../ElementStorage"; import { ElementStorage } from "../../ElementStorage"
import {TagsFilter} from "../../Tags/TagsFilter"; import { TagsFilter } from "../../Tags/TagsFilter"
import {OsmFeature} from "../../../Models/OsmFeature"; import { OsmFeature } from "../../../Models/OsmFeature"
export default class FilteringFeatureSource implements FeatureSourceForLayer, Tiled { export default class FilteringFeatureSource implements FeatureSourceForLayer, Tiled {
public features: UIEventSource<{ feature: any; freshness: Date }[]> = public features: UIEventSource<{ feature: any; freshness: Date }[]> = new UIEventSource<
new UIEventSource<{ feature: any; freshness: Date }[]>([]); { feature: any; freshness: Date }[]
public readonly name; >([])
public readonly layer: FilteredLayer; public readonly name
public readonly layer: FilteredLayer
public readonly tileIndex: number public readonly tileIndex: number
public readonly bbox: BBox public readonly bbox: BBox
private readonly upstream: FeatureSourceForLayer; private readonly upstream: FeatureSourceForLayer
private readonly state: { private readonly state: {
locationControl: Store<{ zoom: number }>; locationControl: Store<{ zoom: number }>
selectedElement: Store<any>, selectedElement: Store<any>
globalFilters: Store<{ filter: FilterState }[]>, globalFilters: Store<{ filter: FilterState }[]>
allElements: ElementStorage allElements: ElementStorage
}; }
private readonly _alreadyRegistered = new Set<UIEventSource<any>>(); private readonly _alreadyRegistered = new Set<UIEventSource<any>>()
private readonly _is_dirty = new UIEventSource(false) private readonly _is_dirty = new UIEventSource(false)
private previousFeatureSet: Set<any> = undefined; private previousFeatureSet: Set<any> = undefined
constructor( constructor(
state: { state: {
locationControl: Store<{ zoom: number }>, locationControl: Store<{ zoom: number }>
selectedElement: Store<any>, selectedElement: Store<any>
allElements: ElementStorage, allElements: ElementStorage
globalFilters: Store<{ filter: FilterState }[]> globalFilters: Store<{ filter: FilterState }[]>
}, },
tileIndex, tileIndex,
@ -41,92 +42,95 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer, Ti
this.upstream = upstream this.upstream = upstream
this.state = state this.state = state
this.layer = upstream.layer; this.layer = upstream.layer
const layer = upstream.layer; const layer = upstream.layer
const self = this; const self = this
upstream.features.addCallback(() => { upstream.features.addCallback(() => {
self.update();
});
layer.appliedFilters.addCallback(_ => {
self.update() self.update()
}) })
this._is_dirty.stabilized(1000).addCallbackAndRunD(dirty => { layer.appliedFilters.addCallback((_) => {
self.update()
})
this._is_dirty.stabilized(1000).addCallbackAndRunD((dirty) => {
if (dirty) { if (dirty) {
self.update() self.update()
} }
}) })
metataggingUpdated?.addCallback(_ => { metataggingUpdated?.addCallback((_) => {
self._is_dirty.setData(true) self._is_dirty.setData(true)
}) })
state.globalFilters.addCallback(_ => { state.globalFilters.addCallback((_) => {
self.update() self.update()
}) })
this.update(); this.update()
} }
private update() { private update() {
const self = this; const self = this
const layer = this.upstream.layer; const layer = this.upstream.layer
const features: { feature: OsmFeature; freshness: Date }[] = (this.upstream.features.data ?? []); const features: { feature: OsmFeature; freshness: Date }[] =
const includedFeatureIds = new Set<string>(); this.upstream.features.data ?? []
const globalFilters = self.state.globalFilters.data.map(f => f.filter); const includedFeatureIds = new Set<string>()
const globalFilters = self.state.globalFilters.data.map((f) => f.filter)
const newFeatures = (features ?? []).filter((f) => { const newFeatures = (features ?? []).filter((f) => {
self.registerCallback(f.feature) self.registerCallback(f.feature)
const isShown: TagsFilter = layer.layerDef.isShown; const isShown: TagsFilter = layer.layerDef.isShown
const tags = f.feature.properties; const tags = f.feature.properties
if (isShown !== undefined && !isShown.matchesProperties(tags)) { if (isShown !== undefined && !isShown.matchesProperties(tags)) {
return false; return false
} }
const tagsFilter = Array.from(layer.appliedFilters?.data?.values() ?? []) const tagsFilter = Array.from(layer.appliedFilters?.data?.values() ?? [])
for (const filter of tagsFilter) { for (const filter of tagsFilter) {
const neededTags: TagsFilter = filter?.currentFilter 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 // 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) { for (const filter of globalFilters) {
const neededTags: TagsFilter = filter?.currentFilter 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 // 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) includedFeatureIds.add(f.feature.properties.id)
return true; return true
}); })
const previousSet = this.previousFeatureSet; const previousSet = this.previousFeatureSet
this._is_dirty.setData(false) this._is_dirty.setData(false)
// Is there any difference between the two sets? // Is there any difference between the two sets?
if (previousSet !== undefined && previousSet.size === includedFeatureIds.size) { if (previousSet !== undefined && previousSet.size === includedFeatureIds.size) {
// The size of the sets is the same - they _might_ be identical // 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) { if (!newItemFound) {
// We know that: // We know that:
// - The sets have the same size // - The sets have the same size
// - Every item from the new set has been found in the old set // - Every item from the new set has been found in the old set
// which means they are identical! // which means they are identical!
return; return
} }
} }
// Something new has been found! // Something new has been found!
this.features.setData(newFeatures); this.features.setData(newFeatures)
} }
private registerCallback(feature: any) { private registerCallback(feature: any) {
@ -139,11 +143,10 @@ export default class FilteringFeatureSource implements FeatureSourceForLayer, Ti
} }
this._alreadyRegistered.add(src) this._alreadyRegistered.add(src)
const self = this; const self = this
// Add a callback as a changed tag migh change the filter // Add a callback as a changed tag migh change the filter
src.addCallbackAndRunD(_ => { src.addCallbackAndRunD((_) => {
self._is_dirty.setData(true) self._is_dirty.setData(true)
}) })
} }
} }

View file

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

View file

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

View file

@ -2,34 +2,36 @@
* Every previously added point is remembered, but new points are added. * Every previously added point is remembered, but new points are added.
* Data coming from upstream will always overwrite a previous value * Data coming from upstream will always overwrite a previous value
*/ */
import FeatureSource, {Tiled} from "../FeatureSource"; import FeatureSource, { Tiled } from "../FeatureSource"
import {Store, UIEventSource} from "../../UIEventSource"; import { Store, UIEventSource } from "../../UIEventSource"
import {BBox} from "../../BBox"; import { BBox } from "../../BBox"
export default class RememberingSource implements FeatureSource, Tiled { export default class RememberingSource implements FeatureSource, Tiled {
public readonly features: Store<{ feature: any; freshness: Date }[]>
public readonly features: Store<{ feature: any, freshness: Date }[]>; public readonly name
public readonly name;
public readonly tileIndex: number public readonly tileIndex: number
public readonly bbox: BBox public readonly bbox: BBox
constructor(source: FeatureSource & Tiled) { constructor(source: FeatureSource & Tiled) {
const self = this; const self = this
this.name = "RememberingSource of " + source.name; this.name = "RememberingSource of " + source.name
this.tileIndex = source.tileIndex this.tileIndex = source.tileIndex
this.bbox = source.bbox; this.bbox = source.bbox
const empty = []; const empty = []
const featureSource = new UIEventSource<{feature: any, freshness: Date}[]>(empty) const featureSource = new UIEventSource<{ feature: any; freshness: Date }[]>(empty)
this.features = featureSource this.features = featureSource
source.features.addCallbackAndRunD(features => { source.features.addCallbackAndRunD((features) => {
const oldFeatures = self.features?.data ?? empty; const oldFeatures = self.features?.data ?? empty
// Then new ids // 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 // 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]) 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. * 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 { Store } from "../../UIEventSource"
import {GeoOperations} from "../../GeoOperations"; import { GeoOperations } from "../../GeoOperations"
import FeatureSource from "../FeatureSource"; import FeatureSource from "../FeatureSource"
import PointRenderingConfig from "../../../Models/ThemeConfig/PointRenderingConfig"; import PointRenderingConfig from "../../../Models/ThemeConfig/PointRenderingConfig"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"; import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import LineRenderingConfig from "../../../Models/ThemeConfig/LineRenderingConfig"; import LineRenderingConfig from "../../../Models/ThemeConfig/LineRenderingConfig"
export default class RenderingMultiPlexerFeatureSource { export default class RenderingMultiPlexerFeatureSource {
public readonly features: Store<(any & { pointRenderingIndex: number | undefined, lineRenderingIndex: number | undefined })[]>; public readonly features: Store<
private readonly pointRenderings: { rendering: PointRenderingConfig; index: number }[]; (any & {
private centroidRenderings: { rendering: PointRenderingConfig; index: number }[]; pointRenderingIndex: number | undefined
private projectedCentroidRenderings: { rendering: PointRenderingConfig; index: number }[]; lineRenderingIndex: number | undefined
private startRenderings: { rendering: PointRenderingConfig; index: number }[]; })[]
private endRenderings: { rendering: PointRenderingConfig; index: number }[]; >
private hasCentroid: boolean; private readonly pointRenderings: { rendering: PointRenderingConfig; index: number }[]
private lineRenderObjects: LineRenderingConfig[]; 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(
private inspectFeature(feat, addAsPoint: (feat, rendering, centerpoint: [number, number]) => void, withIndex: any[]){ feat,
addAsPoint: (feat, rendering, centerpoint: [number, number]) => void,
withIndex: any[]
) {
if (feat.geometry.type === "Point") { if (feat.geometry.type === "Point") {
for (const rendering of this.pointRenderings) { for (const rendering of this.pointRenderings) {
withIndex.push({ withIndex.push({
...feat, ...feat,
pointRenderingIndex: rendering.index pointRenderingIndex: rendering.index,
}) })
} }
} else { } else {
// This is a a line: add the centroids // This is a a line: add the centroids
let centerpoint: [number, number] = undefined; let centerpoint: [number, number] = undefined
let projectedCenterPoint: [number, number] = undefined let projectedCenterPoint: [number, number] = undefined
if (this.hasCentroid) { if (this.hasCentroid) {
centerpoint = GeoOperations.centerpointCoordinates(feat) centerpoint = GeoOperations.centerpointCoordinates(feat)
if (this.projectedCentroidRenderings.length > 0) { if (this.projectedCentroidRenderings.length > 0) {
projectedCenterPoint = <[number,number]> GeoOperations.nearestPoint(feat, centerpoint).geometry.coordinates projectedCenterPoint = <[number, number]>(
GeoOperations.nearestPoint(feat, centerpoint).geometry.coordinates
)
} }
} }
for (const rendering of this.centroidRenderings) { for (const rendering of this.centroidRenderings) {
addAsPoint(feat, rendering, centerpoint) addAsPoint(feat, rendering, centerpoint)
} }
if (feat.geometry.type === "LineString") { if (feat.geometry.type === "LineString") {
for (const rendering of this.projectedCentroidRenderings) { for (const rendering of this.projectedCentroidRenderings) {
addAsPoint(feat, rendering, projectedCenterPoint) addAsPoint(feat, rendering, projectedCenterPoint)
} }
@ -58,7 +65,6 @@ export default class RenderingMultiPlexerFeatureSource {
const coordinate = coordinates[coordinates.length - 1] const coordinate = coordinates[coordinates.length - 1]
addAsPoint(feat, rendering, coordinate) addAsPoint(feat, rendering, coordinate)
} }
} else { } else {
for (const rendering of this.projectedCentroidRenderings) { for (const rendering of this.projectedCentroidRenderings) {
addAsPoint(feat, rendering, centerpoint) addAsPoint(feat, rendering, centerpoint)
@ -69,62 +75,59 @@ export default class RenderingMultiPlexerFeatureSource {
for (let i = 0; i < this.lineRenderObjects.length; i++) { for (let i = 0; i < this.lineRenderObjects.length; i++) {
withIndex.push({ withIndex.push({
...feat, ...feat,
lineRenderingIndex: i lineRenderingIndex: i,
}) })
} }
} }
} }
constructor(upstream: FeatureSource, layer: LayerConfig) { constructor(upstream: FeatureSource, layer: LayerConfig) {
const pointRenderObjects: { rendering: PointRenderingConfig; index: number }[] =
const pointRenderObjects: { rendering: PointRenderingConfig, index: number }[] = layer.mapRendering.map((r, i) => ({ layer.mapRendering.map((r, i) => ({
rendering: r, rendering: r,
index: i index: i,
})) }))
this.pointRenderings = pointRenderObjects.filter(r => r.rendering.location.has("point")) this.pointRenderings = pointRenderObjects.filter((r) => r.rendering.location.has("point"))
this.centroidRenderings = pointRenderObjects.filter(r => r.rendering.location.has("centroid")) this.centroidRenderings = pointRenderObjects.filter((r) =>
this.projectedCentroidRenderings = pointRenderObjects.filter(r => r.rendering.location.has("projected_centerpoint")) r.rendering.location.has("centroid")
this.startRenderings = pointRenderObjects.filter(r => r.rendering.location.has("start")) )
this.endRenderings = pointRenderObjects.filter(r => r.rendering.location.has("end")) this.projectedCentroidRenderings = pointRenderObjects.filter((r) =>
this.hasCentroid = this.centroidRenderings.length > 0 || this.projectedCentroidRenderings.length > 0 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.lineRenderObjects = layer.lineRendering
this.features = upstream.features.map( this.features = upstream.features.map((features) => {
features => {
if (features === undefined) { if (features === undefined) {
return undefined; return undefined
} }
const withIndex: any[] = []
const withIndex: any[] = [];
function addAsPoint(feat, rendering, coordinate) { function addAsPoint(feat, rendering, coordinate) {
const patched = { const patched = {
...feat, ...feat,
pointRenderingIndex: rendering.index pointRenderingIndex: rendering.index,
} }
patched.geometry = { patched.geometry = {
type: "Point", type: "Point",
coordinates: coordinate coordinates: coordinate,
} }
withIndex.push(patched) withIndex.push(patched)
} }
for (const f of features) { for (const f of features) {
const feat = f.feature; const feat = f.feature
if (feat === undefined) { if (feat === undefined) {
continue continue
} }
this.inspectFeature(feat, addAsPoint, withIndex) this.inspectFeature(feat, addAsPoint, withIndex)
} }
return withIndex
return withIndex; })
} }
);
}
} }

View file

@ -1,21 +1,24 @@
import {UIEventSource} from "../../UIEventSource"; import { UIEventSource } from "../../UIEventSource"
import FilteredLayer from "../../../Models/FilteredLayer"; import FilteredLayer from "../../../Models/FilteredLayer"
import {FeatureSourceForLayer, Tiled} from "../FeatureSource"; import { FeatureSourceForLayer, Tiled } from "../FeatureSource"
import {BBox} from "../../BBox"; import { BBox } from "../../BBox"
export default class SimpleFeatureSource implements FeatureSourceForLayer, Tiled { export default class SimpleFeatureSource implements FeatureSourceForLayer, Tiled {
public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>; public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>
public readonly name: string = "SimpleFeatureSource"; public readonly name: string = "SimpleFeatureSource"
public readonly layer: FilteredLayer; public readonly layer: FilteredLayer
public readonly bbox: BBox = BBox.global; public readonly bbox: BBox = BBox.global
public readonly tileIndex: number; 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.name = "SimpleFeatureSource(" + layer.layerDef.id + ")"
this.layer = layer this.layer = layer
this.tileIndex = tileIndex ?? 0; this.tileIndex = tileIndex ?? 0
this.bbox = BBox.fromTileIndex(this.tileIndex) 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 FeatureSource, { FeatureSourceForLayer, Tiled } from "../FeatureSource"
import {ImmutableStore, Store, UIEventSource} from "../../UIEventSource"; import { ImmutableStore, Store, UIEventSource } from "../../UIEventSource"
import {stat} from "fs"; import { stat } from "fs"
import FilteredLayer from "../../../Models/FilteredLayer"; import FilteredLayer from "../../../Models/FilteredLayer"
import {BBox} from "../../BBox"; import { BBox } from "../../BBox"
import {Feature} from "@turf/turf"; import { Feature } from "@turf/turf"
/** /**
* A simple, read only feature store. * A simple, read only feature store.
*/ */
export default class StaticFeatureSource implements FeatureSource { 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 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) { if (features === undefined) {
throw "Static feature source received undefined as source" throw "Static feature source received undefined as source"
} }
this.name = name; this.name = name
this.features = features; this.features = features
} }
public static fromGeojsonAndDate(features: { feature: Feature, freshness: Date }[], name = "StaticFeatureSourceFromGeojsonAndDate"): StaticFeatureSource { public static fromGeojsonAndDate(
return new StaticFeatureSource(new ImmutableStore(features), name); features: { feature: Feature; freshness: Date }[],
name = "StaticFeatureSourceFromGeojsonAndDate"
): StaticFeatureSource {
return new StaticFeatureSource(new ImmutableStore(features), name)
} }
public static fromGeojson(
public static fromGeojson(geojson: Feature[], name = "StaticFeatureSourceFromGeojson"): StaticFeatureSource { geojson: Feature[],
const now = new Date(); name = "StaticFeatureSourceFromGeojson"
return StaticFeatureSource.fromGeojsonAndDate(geojson.map(feature => ({feature, freshness: now})), name); ): StaticFeatureSource {
const now = new Date()
return StaticFeatureSource.fromGeojsonAndDate(
geojson.map((feature) => ({ feature, freshness: now })),
name
)
} }
public static fromGeojsonStore(geojson: Store<Feature[]>, name = "StaticFeatureSourceFromGeojson"): StaticFeatureSource { public static fromGeojsonStore(
const now = new Date(); geojson: Store<Feature[]>,
const mapped : Store<{feature: Feature, freshness: Date}[]> = geojson.map(features => features.map(feature => ({feature, freshness: now}))) name = "StaticFeatureSourceFromGeojson"
return new StaticFeatureSource(mapped, name); ): 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") { static fromDateless(
const now = new Date(); featureSource: Store<{ feature: Feature }[]>,
return new StaticFeatureSource(featureSource.map(features => features.map(feature => ({ name = "StaticFeatureSourceFromDateless"
) {
const now = new Date()
return new StaticFeatureSource(
featureSource.map((features) =>
features.map((feature) => ({
feature: feature.feature, feature: feature.feature,
freshness: now freshness: now,
}))), name); }))
),
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; constructor(
public readonly tileIndex: number; features: Store<{ feature: any; freshness: Date }[]>,
public readonly layer: FilteredLayer; layer: FilteredLayer,
tileIndex: number = 0
constructor(features: Store<{ feature: any, freshness: Date }[]>, layer: FilteredLayer ,tileIndex : number = 0) { ) {
super(features); super(features)
this.tileIndex = tileIndex ; this.tileIndex = tileIndex
this.layer= layer; this.layer = layer
this.bbox = BBox.fromTileIndex(this.tileIndex) 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 { export default class TileFreshnessCalculator {
/** /**
* All the freshnesses per tile index * All the freshnesses per tile index
* @private * @private
*/ */
private readonly freshnesses = new Map<number, Date>(); private readonly freshnesses = new Map<number, Date>()
/** /**
* Marks that some data got loaded for this layer * Marks that some data got loaded for this layer
@ -16,14 +15,14 @@ export default class TileFreshnessCalculator {
public addTileLoad(tileId: number, freshness: Date) { public addTileLoad(tileId: number, freshness: Date) {
const existingFreshness = this.freshnessFor(...Tiles.tile_from_index(tileId)) const existingFreshness = this.freshnessFor(...Tiles.tile_from_index(tileId))
if (existingFreshness >= freshness) { if (existingFreshness >= freshness) {
return; return
} }
this.freshnesses.set(tileId, freshness) this.freshnesses.set(tileId, freshness)
// Do we have freshness for the neighbouring tiles? If so, we can mark the tile above as loaded too! // 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) let [z, x, y] = Tiles.tile_from_index(tileId)
if (z === 0) { if (z === 0) {
return; return
} }
x = x - (x % 2) // Make the tiles always even x = x - (x % 2) // Make the tiles always even
y = y - (y % 2) y = y - (y % 2)
@ -48,11 +47,7 @@ export default class TileFreshnessCalculator {
const leastFresh = Math.min(ul, ur, ll, lr) const leastFresh = Math.min(ul, ur, ll, lr)
const date = new Date() const date = new Date()
date.setTime(leastFresh) date.setTime(leastFresh)
this.addTileLoad( this.addTileLoad(Tiles.tile_index(z - 1, Math.floor(x / 2), Math.floor(y / 2)), date)
Tiles.tile_index(z - 1, Math.floor(x / 2), Math.floor(y / 2)),
date
)
} }
public freshnessFor(z: number, x: number, y: number): Date { public freshnessFor(z: number, x: number, y: number): Date {
@ -65,7 +60,5 @@ export default class TileFreshnessCalculator {
} }
// recurse up // recurse up
return this.freshnessFor(z - 1, Math.floor(x / 2), Math.floor(y / 2)) 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 FilteredLayer from "../../../Models/FilteredLayer"
import {FeatureSourceForLayer, Tiled} from "../FeatureSource"; import { FeatureSourceForLayer, Tiled } from "../FeatureSource"
import {UIEventSource} from "../../UIEventSource"; import { UIEventSource } from "../../UIEventSource"
import DynamicTileSource from "./DynamicTileSource"; import DynamicTileSource from "./DynamicTileSource"
import {Utils} from "../../../Utils"; import { Utils } from "../../../Utils"
import GeoJsonSource from "../Sources/GeoJsonSource"; import GeoJsonSource from "../Sources/GeoJsonSource"
import {BBox} from "../../BBox"; import { BBox } from "../../BBox"
export default class DynamicGeoJsonTileSource extends DynamicTileSource { export default class DynamicGeoJsonTileSource extends DynamicTileSource {
private static whitelistCache = new Map<string, any>() private static whitelistCache = new Map<string, any>()
constructor(layer: FilteredLayer, constructor(
layer: FilteredLayer,
registerLayer: (layer: FeatureSourceForLayer & Tiled) => void, registerLayer: (layer: FeatureSourceForLayer & Tiled) => void,
state: { state: {
locationControl?: UIEventSource<{ zoom?: number }> locationControl?: UIEventSource<{ zoom?: number }>
currentBounds: UIEventSource<BBox> currentBounds: UIEventSource<BBox>
}) { }
) {
const source = layer.layerDef.source const source = layer.layerDef.source
if (source.geojsonZoomLevel === undefined) { if (source.geojsonZoomLevel === undefined) {
throw "Invalid layer: geojsonZoomLevel expected" throw "Invalid layer: geojsonZoomLevel expected"
@ -26,7 +27,6 @@ export default class DynamicGeoJsonTileSource extends DynamicTileSource {
let whitelist = undefined let whitelist = undefined
if (source.geojsonSource.indexOf("{x}_{y}.geojson") > 0) { if (source.geojsonSource.indexOf("{x}_{y}.geojson") > 0) {
const whitelistUrl = source.geojsonSource const whitelistUrl = source.geojsonSource
.replace("{z}", "" + source.geojsonZoomLevel) .replace("{z}", "" + source.geojsonZoomLevel)
.replace("{x}_{y}.geojson", "overview.json") .replace("{x}_{y}.geojson", "overview.json")
@ -35,26 +35,33 @@ export default class DynamicGeoJsonTileSource extends DynamicTileSource {
if (DynamicGeoJsonTileSource.whitelistCache.has(whitelistUrl)) { if (DynamicGeoJsonTileSource.whitelistCache.has(whitelistUrl)) {
whitelist = DynamicGeoJsonTileSource.whitelistCache.get(whitelistUrl) whitelist = DynamicGeoJsonTileSource.whitelistCache.get(whitelistUrl)
} else { } else {
Utils.downloadJsonCached(whitelistUrl, 1000 * 60 * 60).then( Utils.downloadJsonCached(whitelistUrl, 1000 * 60 * 60)
json => { .then((json) => {
const data = new Map<number, Set<number>>(); const data = new Map<number, Set<number>>()
for (const x in json) { for (const x in json) {
if (x === "zoom") { if (x === "zoom") {
continue continue
} }
data.set(Number(x), new Set(json[x])) 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 whitelist = data
DynamicGeoJsonTileSource.whitelistCache.set(whitelistUrl, whitelist) DynamicGeoJsonTileSource.whitelistCache.set(whitelistUrl, whitelist)
} })
).catch(err => { .catch((err) => {
console.warn("No whitelist found for ", layer.layerDef.id, err) console.warn("No whitelist found for ", layer.layerDef.id, err)
}) })
} }
} }
const blackList = (new Set<string>()) const blackList = new Set<string>()
super( super(
layer, layer,
source.geojsonZoomLevel, source.geojsonZoomLevel,
@ -62,29 +69,28 @@ export default class DynamicGeoJsonTileSource extends DynamicTileSource {
if (whitelist !== undefined) { if (whitelist !== undefined) {
const isWhiteListed = whitelist.get(zxy[1])?.has(zxy[2]) const isWhiteListed = whitelist.get(zxy[1])?.has(zxy[2])
if (!isWhiteListed) { if (!isWhiteListed) {
console.debug("Not downloading tile", ...zxy, "as it is not on the whitelist") console.debug(
return undefined; "Not downloading tile",
...zxy,
"as it is not on the whitelist"
)
return undefined
} }
} }
const src = new GeoJsonSource( const src = new GeoJsonSource(layer, zxy, {
layer, featureIdBlacklist: blackList,
zxy, })
{
featureIdBlacklist: blackList
}
)
registerLayer(src) registerLayer(src)
return src return src
}, },
state state
); )
} }
public static RegisterWhitelist(url: string, json: any) { 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) { for (const x in json) {
if (x === "zoom") { if (x === "zoom") {
continue continue
@ -93,5 +99,4 @@ export default class DynamicGeoJsonTileSource extends DynamicTileSource {
} }
DynamicGeoJsonTileSource.whitelistCache.set(url, data) DynamicGeoJsonTileSource.whitelistCache.set(url, data)
} }
} }

View file

@ -1,31 +1,32 @@
import FilteredLayer from "../../../Models/FilteredLayer"; import FilteredLayer from "../../../Models/FilteredLayer"
import {FeatureSourceForLayer, Tiled} from "../FeatureSource"; import { FeatureSourceForLayer, Tiled } from "../FeatureSource"
import {UIEventSource} from "../../UIEventSource"; import { UIEventSource } from "../../UIEventSource"
import TileHierarchy from "./TileHierarchy"; import TileHierarchy from "./TileHierarchy"
import {Tiles} from "../../../Models/TileRange"; import { Tiles } from "../../../Models/TileRange"
import {BBox} from "../../BBox"; import { BBox } from "../../BBox"
/*** /***
* A tiled source which dynamically loads the required tiles at a fixed zoom level * A tiled source which dynamically loads the required tiles at a fixed zoom level
*/ */
export default class DynamicTileSource implements TileHierarchy<FeatureSourceForLayer & Tiled> { export default class DynamicTileSource implements TileHierarchy<FeatureSourceForLayer & Tiled> {
public readonly loadedTiles: Map<number, FeatureSourceForLayer & Tiled>; public readonly loadedTiles: Map<number, FeatureSourceForLayer & Tiled>
private readonly _loadedTiles = new Set<number>(); private readonly _loadedTiles = new Set<number>()
constructor( constructor(
layer: FilteredLayer, layer: FilteredLayer,
zoomlevel: number, zoomlevel: number,
constructTile: (zxy: [number, number, number]) => (FeatureSourceForLayer & Tiled), constructTile: (zxy: [number, number, number]) => FeatureSourceForLayer & Tiled,
state: { state: {
currentBounds: UIEventSource<BBox>; currentBounds: UIEventSource<BBox>
locationControl?: UIEventSource<{ zoom?: number }> locationControl?: UIEventSource<{ zoom?: number }>
} }
) { ) {
const self = this; const self = this
this.loadedTiles = new Map<number, FeatureSourceForLayer & Tiled>() this.loadedTiles = new Map<number, FeatureSourceForLayer & Tiled>()
const neededTiles = state.currentBounds.map( const neededTiles = state.currentBounds
bounds => { .map(
(bounds) => {
if (bounds === undefined) { if (bounds === undefined) {
// We'll retry later // We'll retry later
return undefined return undefined
@ -33,32 +34,47 @@ export default class DynamicTileSource implements TileHierarchy<FeatureSourceFor
if (!layer.isDisplayed.data && !layer.layerDef.forceLoad) { if (!layer.isDisplayed.data && !layer.layerDef.forceLoad) {
// No need to download! - the layer is disabled // No need to download! - the layer is disabled
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 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 return undefined
} }
const needed = Tiles.MapRange(tileRange, (x, y) => Tiles.tile_index(zoomlevel, x, y)).filter(i => !self._loadedTiles.has(i)) if (
state.locationControl?.data?.zoom !== undefined &&
state.locationControl.data.zoom < layer.layerDef.minzoom
) {
// 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
}
const needed = Tiles.MapRange(tileRange, (x, y) =>
Tiles.tile_index(zoomlevel, x, y)
).filter((i) => !self._loadedTiles.has(i))
if (needed.length === 0) { if (needed.length === 0) {
return undefined return undefined
} }
return needed return needed
} },
, [layer.isDisplayed, state.locationControl]).stabilized(250); [layer.isDisplayed, state.locationControl]
)
.stabilized(250)
neededTiles.addCallbackAndRunD(neededIndexes => { neededTiles.addCallbackAndRunD((neededIndexes) => {
console.log("Tiled geojson source ", layer.layerDef.id, " needs", neededIndexes) console.log("Tiled geojson source ", layer.layerDef.id, " needs", neededIndexes)
if (neededIndexes === undefined) { if (neededIndexes === undefined) {
return; return
} }
for (const neededIndex of neededIndexes) { for (const neededIndex of neededIndexes) {
self._loadedTiles.add(neededIndex) 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 TileHierarchy from "./TileHierarchy"
import FeatureSource, {FeatureSourceForLayer, Tiled} from "../FeatureSource"; import FeatureSource, { FeatureSourceForLayer, Tiled } from "../FeatureSource"
import {OsmNode, OsmObject, OsmWay} from "../../Osm/OsmObject"; import { OsmNode, OsmObject, OsmWay } from "../../Osm/OsmObject"
import SimpleFeatureSource from "../Sources/SimpleFeatureSource"; import SimpleFeatureSource from "../Sources/SimpleFeatureSource"
import FilteredLayer from "../../../Models/FilteredLayer"; import FilteredLayer from "../../../Models/FilteredLayer"
import {UIEventSource} from "../../UIEventSource"; import { UIEventSource } from "../../UIEventSource"
export default class FullNodeDatabaseSource implements TileHierarchy<FeatureSource & Tiled> { export default class FullNodeDatabaseSource implements TileHierarchy<FeatureSource & Tiled> {
public readonly loadedTiles = new Map<number, 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 layer: FilteredLayer
private readonly nodeByIds = new Map<number, OsmNode>(); private readonly nodeByIds = new Map<number, OsmNode>()
private readonly parentWays = new Map<number, UIEventSource<OsmWay[]>>() private readonly parentWays = new Map<number, UIEventSource<OsmWay[]>>()
constructor( constructor(layer: FilteredLayer, onTileLoaded: (tile: Tiled & FeatureSourceForLayer) => void) {
layer: FilteredLayer,
onTileLoaded: ((tile: Tiled & FeatureSourceForLayer) => void)) {
this.onTileLoaded = onTileLoaded this.onTileLoaded = onTileLoaded
this.layer = layer; this.layer = layer
if (this.layer === undefined) { if (this.layer === undefined) {
throw "Layer is undefined" throw "Layer is undefined"
} }
} }
public handleOsmJson(osmJson: any, tileId: number) { public handleOsmJson(osmJson: any, tileId: number) {
const allObjects = OsmObject.ParseObjects(osmJson.elements) const allObjects = OsmObject.ParseObjects(osmJson.elements)
const nodesById = new Map<number, OsmNode>() const nodesById = new Map<number, OsmNode>()
@ -32,7 +28,7 @@ export default class FullNodeDatabaseSource implements TileHierarchy<FeatureSour
if (osmObj.type !== "node") { if (osmObj.type !== "node") {
continue continue
} }
const osmNode = <OsmNode>osmObj; const osmNode = <OsmNode>osmObj
nodesById.set(osmNode.id, osmNode) nodesById.set(osmNode.id, osmNode)
this.nodeByIds.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") { if (osmObj.type !== "way") {
continue continue
} }
const osmWay = <OsmWay>osmObj; const osmWay = <OsmWay>osmObj
for (const nodeId of osmWay.nodes) { for (const nodeId of osmWay.nodes) {
if (!this.parentWays.has(nodeId)) { if (!this.parentWays.has(nodeId)) {
const src = new UIEventSource<OsmWay[]>([]) const src = new UIEventSource<OsmWay[]>([])
this.parentWays.set(nodeId, src) this.parentWays.set(nodeId, src)
src.addCallback(parentWays => { src.addCallback((parentWays) => {
const tgs = nodesById.get(nodeId).tags const tgs = nodesById.get(nodeId).tags
tgs ["parent_ways"] = JSON.stringify(parentWays.map(w => w.tags)) tgs["parent_ways"] = JSON.stringify(parentWays.map((w) => w.tags))
tgs["parent_way_ids"] = JSON.stringify(parentWays.map(w => w.id)) tgs["parent_way_ids"] = JSON.stringify(parentWays.map((w) => w.id))
}) })
} }
const src = this.parentWays.get(nodeId) const src = this.parentWays.get(nodeId)
src.data.push(osmWay) src.data.push(osmWay)
src.ping(); src.ping()
} }
} }
const now = new Date() const now = new Date()
const asGeojsonFeatures = Array.from(nodesById.values()).map(osmNode => ({ const asGeojsonFeatures = Array.from(nodesById.values()).map((osmNode) => ({
feature: osmNode.asGeoJson(), freshness: now feature: osmNode.asGeoJson(),
freshness: now,
})) }))
const featureSource = new SimpleFeatureSource(this.layer, tileId) const featureSource = new SimpleFeatureSource(this.layer, tileId)
featureSource.features.setData(asGeojsonFeatures) featureSource.features.setData(asGeojsonFeatures)
this.loadedTiles.set(tileId, featureSource) this.loadedTiles.set(tileId, featureSource)
this.onTileLoaded(featureSource) this.onTileLoaded(featureSource)
} }
/** /**
@ -88,6 +83,4 @@ export default class FullNodeDatabaseSource implements TileHierarchy<FeatureSour
public GetParentWays(nodeId: number): UIEventSource<OsmWay[]> { public GetParentWays(nodeId: number): UIEventSource<OsmWay[]> {
return this.parentWays.get(nodeId) return this.parentWays.get(nodeId)
} }
} }

View file

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

View file

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

View file

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

View file

@ -1,53 +1,65 @@
import FeatureSource, {FeatureSourceForLayer, IndexedFeatureSource, Tiled} from "../FeatureSource"; import FeatureSource, { FeatureSourceForLayer, IndexedFeatureSource, Tiled } from "../FeatureSource"
import {Store, UIEventSource} from "../../UIEventSource"; import { Store, UIEventSource } from "../../UIEventSource"
import FilteredLayer from "../../../Models/FilteredLayer"; import FilteredLayer from "../../../Models/FilteredLayer"
import TileHierarchy from "./TileHierarchy"; import TileHierarchy from "./TileHierarchy"
import {Tiles} from "../../../Models/TileRange"; import { Tiles } from "../../../Models/TileRange"
import {BBox} from "../../BBox"; import { BBox } from "../../BBox"
/** /**
* Contains all features in a tiled fashion. * 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 * 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> { export default class TiledFeatureSource
public readonly z: number; implements
public readonly x: number; Tiled,
public readonly y: number; IndexedFeatureSource,
public readonly parent: TiledFeatureSource; 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 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. /* 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 maxFeatureCount: number
public readonly name; public readonly name
public readonly features: UIEventSource<{ feature: any, freshness: Date }[]> public readonly features: UIEventSource<{ feature: any; freshness: Date }[]>
public readonly containedIds: Store<Set<string>> public readonly containedIds: Store<Set<string>>
public readonly bbox: BBox; public readonly bbox: BBox
public readonly tileIndex: number; public readonly tileIndex: number
private upper_left: TiledFeatureSource private upper_left: TiledFeatureSource
private upper_right: TiledFeatureSource private upper_right: TiledFeatureSource
private lower_left: TiledFeatureSource private lower_left: TiledFeatureSource
private lower_right: TiledFeatureSource private lower_right: TiledFeatureSource
private readonly maxzoom: number; private readonly maxzoom: number
private readonly options: TiledFeatureSourceOptions private readonly options: TiledFeatureSourceOptions
private constructor(z: number, x: number, y: number, parent: TiledFeatureSource, options?: TiledFeatureSourceOptions) { private constructor(
this.z = z; z: number,
this.x = x; x: number,
this.y = y; y: number,
parent: TiledFeatureSource,
options?: TiledFeatureSourceOptions
) {
this.z = z
this.x = x
this.y = y
this.bbox = BBox.fromTile(z, x, y) this.bbox = BBox.fromTile(z, x, y)
this.tileIndex = Tiles.tile_index(z, x, y) this.tileIndex = Tiles.tile_index(z, x, y)
this.name = `TiledFeatureSource(${z},${x},${y})` this.name = `TiledFeatureSource(${z},${x},${y})`
this.parent = parent; this.parent = parent
this.layer = options.layer this.layer = options.layer
options = options ?? {} options = options ?? {}
this.maxFeatureCount = options?.maxFeatureCount ?? 250; this.maxFeatureCount = options?.maxFeatureCount ?? 250
this.maxzoom = options.maxZoomLevel ?? 18 this.maxzoom = options.maxZoomLevel ?? 18
this.options = options; this.options = options
if (parent === undefined) { if (parent === undefined) {
throw "Parent is not allowed to be undefined. Use null instead" 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" throw "Invalid root tile: z, x and y should all be null"
} }
if (parent === null) { if (parent === null) {
this.root = this; this.root = this
this.loadedTiles = new Map() this.loadedTiles = new Map()
} else { } else {
this.root = this.parent.root; this.root = this.parent.root
this.loadedTiles = this.root.loadedTiles; this.loadedTiles = this.root.loadedTiles
const i = Tiles.tile_index(z, x, y) const i = Tiles.tile_index(z, x, y)
this.root.loadedTiles.set(i, this) this.root.loadedTiles.set(i, this)
} }
this.features = new UIEventSource<any[]>([]) this.features = new UIEventSource<any[]>([])
this.containedIds = this.features.map(features => { this.containedIds = this.features.map((features) => {
if (features === undefined) { 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 // We register this tile, but only when there is some data in it
if (this.options.registerTile !== undefined) { if (this.options.registerTile !== undefined) {
this.features.addCallbackAndRunD(features => { this.features.addCallbackAndRunD((features) => {
if (features.length === 0) { if (features.length === 0) {
return; return
} }
this.options.registerTile(this) 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 = {
...options, ...options,
layer: features["layer"] ?? options.layer layer: features["layer"] ?? options.layer,
} }
const root = new TiledFeatureSource(0, 0, 0, null, options) const root = new TiledFeatureSource(0, 0, 0, null, options)
features.features?.addCallbackAndRunD(feats => root.addFeatures(feats)) features.features?.addCallbackAndRunD((feats) => root.addFeatures(feats))
return root; return root
} }
private isSplitNeeded(featureCount: number) { private isSplitNeeded(featureCount: number) {
if (this.upper_left !== undefined) { if (this.upper_left !== undefined) {
// This tile has been split previously, so we keep on splitting // This tile has been split previously, so we keep on splitting
return true; return true
} }
if (this.z >= this.maxzoom) { if (this.z >= this.maxzoom) {
// We are not allowed to split any further // We are not allowed to split any further
@ -111,7 +124,6 @@ export default class TiledFeatureSource implements Tiled, IndexedFeatureSource,
// To much features - we split // To much features - we split
return featureCount > this.maxFeatureCount return featureCount > this.maxFeatureCount
} }
/*** /***
@ -120,21 +132,45 @@ export default class TiledFeatureSource implements Tiled, IndexedFeatureSource,
* @param features * @param features
* @private * @private
*/ */
private addFeatures(features: { feature: any, freshness: Date }[]) { private addFeatures(features: { feature: any; freshness: Date }[]) {
if (features === undefined || features.length === 0) { if (features === undefined || features.length === 0) {
return; return
} }
if (!this.isSplitNeeded(features.length)) { if (!this.isSplitNeeded(features.length)) {
this.features.setData(features) this.features.setData(features)
return; return
} }
if (this.upper_left === undefined) { if (this.upper_left === undefined) {
this.upper_left = new TiledFeatureSource(this.z + 1, this.x * 2, this.y * 2, this, this.options) this.upper_left = new TiledFeatureSource(
this.upper_right = new TiledFeatureSource(this.z + 1, this.x * 2 + 1, this.y * 2, this, this.options) this.z + 1,
this.lower_left = new TiledFeatureSource(this.z + 1, this.x * 2, this.y * 2 + 1, this, this.options) this.x * 2,
this.lower_right = new TiledFeatureSource(this.z + 1, this.x * 2 + 1, this.y * 2 + 1, this, this.options) 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 = [] const ulf = []
@ -195,19 +231,18 @@ export default class TiledFeatureSource implements Tiled, IndexedFeatureSource,
this.lower_left.addFeatures(llf) this.lower_left.addFeatures(llf)
this.lower_right.addFeatures(lrf) this.lower_right.addFeatures(lrf)
this.features.setData(overlapsboundary) this.features.setData(overlapsboundary)
} }
} }
export interface TiledFeatureSourceOptions { export interface TiledFeatureSourceOptions {
readonly maxFeatureCount?: number, readonly maxFeatureCount?: number
readonly maxZoomLevel?: number, readonly maxZoomLevel?: number
readonly minZoomLevel?: number, readonly minZoomLevel?: number
/** /**
* IF minZoomLevel is set, and if a feature runs through a tile boundary, it would normally be duplicated. * 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. * Setting 'dontEnforceMinZoomLevel' will assign to feature to some matching subtile.
*/ */
readonly noDuplicates?: boolean, readonly noDuplicates?: boolean
readonly registerTile?: (tile: TiledFeatureSource & FeatureSourceForLayer & Tiled) => void, readonly registerTile?: (tile: TiledFeatureSource & FeatureSourceForLayer & Tiled) => void
readonly layer?: FilteredLayer readonly layer?: FilteredLayer
} }

View file

@ -1,17 +1,25 @@
import * as turf from '@turf/turf' import * as turf from "@turf/turf"
import {BBox} from "./BBox"; import { BBox } from "./BBox"
import togpx from "togpx" import togpx from "togpx"
import Constants from "../Models/Constants"; import Constants from "../Models/Constants"
import LayerConfig from "../Models/ThemeConfig/LayerConfig"; import LayerConfig from "../Models/ThemeConfig/LayerConfig"
import {AllGeoJSON, booleanWithin, Coord, Feature, Geometry, MultiPolygon, Polygon, Properties} from "@turf/turf"; import {
AllGeoJSON,
booleanWithin,
Coord,
Feature,
Geometry,
MultiPolygon,
Polygon,
Properties,
} from "@turf/turf"
export class GeoOperations { export class GeoOperations {
private static readonly _earthRadius = 6378137
private static readonly _earthRadius = 6378137; private static readonly _originShift = (2 * Math.PI * GeoOperations._earthRadius) / 2
private static readonly _originShift = 2 * Math.PI * GeoOperations._earthRadius / 2;
static surfaceAreaInSqMeters(feature: any) { static surfaceAreaInSqMeters(feature: any) {
return turf.area(feature); return turf.area(feature)
} }
/** /**
@ -19,10 +27,10 @@ export class GeoOperations {
* @param feature * @param feature
*/ */
static centerpoint(feature: any) { static centerpoint(feature: any) {
const newFeature = turf.center(feature); const newFeature = turf.center(feature)
newFeature.properties = feature.properties; newFeature.properties = feature.properties
newFeature.id = feature.id; newFeature.id = feature.id
return newFeature; return newFeature
} }
/** /**
@ -30,7 +38,7 @@ export class GeoOperations {
* @param feature * @param feature
*/ */
static centerpointCoordinates(feature: AllGeoJSON): [number, number] { static centerpointCoordinates(feature: AllGeoJSON): [number, number] {
return <[number, number]>turf.center(feature).geometry.coordinates; return <[number, number]>turf.center(feature).geometry.coordinates
} }
/** /**
@ -69,16 +77,17 @@ export class GeoOperations {
* const overlap0 = GeoOperations.calculateOverlap(line0, [polygon]); * const overlap0 = GeoOperations.calculateOverlap(line0, [polygon]);
* overlap.length // => 1 * overlap.length // => 1
*/ */
static calculateOverlap(feature: any, otherFeatures: any[]): { feat: any, overlap: number }[] { static calculateOverlap(feature: any, otherFeatures: any[]): { feat: any; overlap: number }[] {
const featureBBox = BBox.get(feature)
const featureBBox = BBox.get(feature); const result: { feat: any; overlap: number }[] = []
const result: { feat: any, overlap: number }[] = [];
if (feature.geometry.type === "Point") { if (feature.geometry.type === "Point") {
const coor = feature.geometry.coordinates; const coor = feature.geometry.coordinates
for (const otherFeature of otherFeatures) { for (const otherFeature of otherFeatures) {
if (
if (feature.properties.id !== undefined && feature.properties.id === otherFeature.properties.id) { feature.properties.id !== undefined &&
continue; feature.properties.id === otherFeature.properties.id
) {
continue
} }
if (otherFeature.geometry === undefined) { if (otherFeature.geometry === undefined) {
@ -90,73 +99,92 @@ export class GeoOperations {
result.push({ feat: otherFeature, overlap: undefined }) result.push({ feat: otherFeature, overlap: undefined })
} }
} }
return result; return result
} }
if (feature.geometry.type === "LineString") { if (feature.geometry.type === "LineString") {
for (const otherFeature of otherFeatures) { for (const otherFeature of otherFeatures) {
if (
if (feature.properties.id !== undefined && feature.properties.id === otherFeature.properties.id) { feature.properties.id !== undefined &&
continue; feature.properties.id === otherFeature.properties.id
) {
continue
} }
const intersection = GeoOperations.calculateInstersection(feature, otherFeature, featureBBox) const intersection = GeoOperations.calculateInstersection(
feature,
otherFeature,
featureBBox
)
if (intersection === null) { if (intersection === null) {
continue 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") { if (feature.geometry.type === "Polygon" || feature.geometry.type === "MultiPolygon") {
for (const otherFeature of otherFeatures) { for (const otherFeature of otherFeatures) {
if (
if (feature.properties.id !== undefined && feature.properties.id === otherFeature.properties.id) { feature.properties.id !== undefined &&
continue; feature.properties.id === otherFeature.properties.id
) {
continue
} }
if (otherFeature.geometry.type === "Point") { if (otherFeature.geometry.type === "Point") {
if (this.inside(otherFeature, feature)) { 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 // Calculate the surface area of the intersection
const intersection = this.calculateInstersection(feature, otherFeature, featureBBox) const intersection = this.calculateInstersection(feature, otherFeature, featureBBox)
if (intersection === null) { 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") console.error(
return result; "Could not correctly calculate the overlap of ",
feature,
": unsupported type"
)
return result
} }
/** /**
* Helper function which does the heavy lifting for 'inside' * Helper function which does the heavy lifting for 'inside'
*/ */
private static pointInPolygonCoordinates(x: number, y: number, coordinates: [number, number][][]) { private static pointInPolygonCoordinates(
const inside = GeoOperations.pointWithinRing(x, y, /*This is the outer ring of the polygon */coordinates[0]) 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) { if (!inside) {
return false; return false
} }
for (let i = 1; i < coordinates.length; i++) { 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) { if (inHole) {
return false; return false
} }
} }
return true; return true
} }
/** /**
@ -186,37 +214,32 @@ export class GeoOperations {
// http://www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/pnpoly.html // http://www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/pnpoly.html
if (feature.geometry.type === "Point") { if (feature.geometry.type === "Point") {
return false; return false
} }
if (pointCoordinate.geometry !== undefined) { if (pointCoordinate.geometry !== undefined) {
pointCoordinate = pointCoordinate.geometry.coordinates pointCoordinate = pointCoordinate.geometry.coordinates
} }
const x: number = pointCoordinate[0]; const x: number = pointCoordinate[0]
const y: number = pointCoordinate[1]; const y: number = pointCoordinate[1]
if (feature.geometry.type === "MultiPolygon") { if (feature.geometry.type === "MultiPolygon") {
const coordinatess = feature.geometry.coordinates; const coordinatess = feature.geometry.coordinates
for (const coordinates of coordinatess) { for (const coordinates of coordinatess) {
const inThisPolygon = GeoOperations.pointInPolygonCoordinates(x, y, coordinates) const inThisPolygon = GeoOperations.pointInPolygonCoordinates(x, y, coordinates)
if (inThisPolygon) { if (inThisPolygon) {
return true; return true
} }
} }
return false; return false
} }
if (feature.geometry.type === "Polygon") { if (feature.geometry.type === "Polygon") {
return GeoOperations.pointInPolygonCoordinates(x, y, feature.geometry.coordinates) 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) { static lengthInMeters(feature: any) {
@ -225,39 +248,24 @@ export class GeoOperations {
static buffer(feature: any, bufferSizeInMeter: number) { static buffer(feature: any, bufferSizeInMeter: number) {
return turf.buffer(feature, bufferSizeInMeter / 1000, { return turf.buffer(feature, bufferSizeInMeter / 1000, {
units: 'kilometers' units: "kilometers",
}) })
} }
static bbox(feature: any) { static bbox(feature: any) {
const [lon, lat, lon0, lat0] = turf.bbox(feature) const [lon, lat, lon0, lat0] = turf.bbox(feature)
return { return {
"type": "Feature", type: "Feature",
"geometry": { geometry: {
"type": "LineString", type: "LineString",
"coordinates": [ coordinates: [
[ [lon, lat],
lon, [lon0, lat],
lat [lon0, lat0],
[lon, lat0],
[lon, lat],
], ],
[ },
lon0,
lat
],
[
lon0,
lat0
],
[
lon,
lat0
],
[
lon,
lat
],
]
}
} }
} }
@ -279,12 +287,11 @@ export class GeoOperations {
way.geometry.coordinates = way.geometry.coordinates[0] 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 { public static toCSV(features: any[]): string {
const headerValuesSeen = new Set<string>()
const headerValuesSeen = new Set<string>();
const headerValuesOrdered: string[] = [] const headerValuesOrdered: string[] = []
function addH(key) { function addH(key) {
@ -300,18 +307,17 @@ export class GeoOperations {
const lines: string[] = [] const lines: string[] = []
for (const feature of features) { for (const feature of features) {
const properties = feature.properties; const properties = feature.properties
for (const key in properties) { for (const key in properties) {
if (!properties.hasOwnProperty(key)) { if (!properties.hasOwnProperty(key)) {
continue; continue
} }
addH(key) addH(key)
} }
} }
headerValuesOrdered.sort() headerValuesOrdered.sort()
for (const feature of features) { for (const feature of features) {
const properties = feature.properties; const properties = feature.properties
let line = "" let line = ""
for (const key of headerValuesOrdered) { for (const key of headerValuesOrdered) {
const value = properties[key] const value = properties[key]
@ -324,27 +330,27 @@ export class GeoOperations {
lines.push(line) 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 //Converts given lat/lon in WGS84 Datum to XY in Spherical Mercator EPSG:900913
public static ConvertWgs84To900913(lonLat: [number, number]): [number, number] { public static ConvertWgs84To900913(lonLat: [number, number]): [number, number] {
const lon = lonLat[0]; const lon = lonLat[0]
const lat = lonLat[1]; const lat = lonLat[1]
const x = lon * GeoOperations._originShift / 180; const x = (lon * GeoOperations._originShift) / 180
let y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) / (Math.PI / 180); let y = Math.log(Math.tan(((90 + lat) * Math.PI) / 360)) / (Math.PI / 180)
y = y * GeoOperations._originShift / 180; y = (y * GeoOperations._originShift) / 180
return [x, y]; return [x, y]
} }
//Converts XY point from (Spherical) Web Mercator EPSG:3785 (unofficially EPSG:900913) to lat/lon in WGS84 Datum //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] { public static Convert900913ToWgs84(lonLat: [number, number]): [number, number] {
const lon = lonLat[0] const lon = lonLat[0]
const lat = lonLat[1] const lat = lonLat[1]
const x = 180 * lon / GeoOperations._originShift; const x = (180 * lon) / GeoOperations._originShift
let y = 180 * lat / GeoOperations._originShift; let y = (180 * lat) / GeoOperations._originShift
y = 180 / Math.PI * (2 * Math.atan(Math.exp(y * Math.PI / 180)) - Math.PI / 2); y = (180 / Math.PI) * (2 * Math.atan(Math.exp((y * Math.PI) / 180)) - Math.PI / 2)
return [x, y]; return [x, y]
} }
public static GeoJsonToWGS84(geojson) { public static GeoJsonToWGS84(geojson) {
@ -360,7 +366,7 @@ export class GeoOperations {
public static SimplifyCoordinates(coordinates: [number, number][]) { public static SimplifyCoordinates(coordinates: [number, number][]) {
const newCoordinates = [] const newCoordinates = []
for (let i = 1; i < coordinates.length - 1; i++) { for (let i = 1; i < coordinates.length - 1; i++) {
const coordinate = coordinates[i]; const coordinate = coordinates[i]
const prev = coordinates[i - 1] const prev = coordinates[i - 1]
const next = coordinates[i + 1] const next = coordinates[i + 1]
const b0 = turf.bearing(prev, coordinate, { final: true }) const b0 = turf.bearing(prev, coordinate, { final: true })
@ -373,27 +379,27 @@ export class GeoOperations {
newCoordinates.push(coordinate) newCoordinates.push(coordinate)
} }
return newCoordinates return newCoordinates
} }
/** /**
* Calculates line intersection between two features. * Calculates line intersection between two features.
*/ */
public static LineIntersections(feature, otherFeature): [number, number][] { 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) { public static AsGpx(feature, generatedWithLayer?: LayerConfig) {
const metadata = {} const metadata = {}
const tags = feature.properties const tags = feature.properties
if (generatedWithLayer !== undefined) { if (generatedWithLayer !== undefined) {
metadata["name"] = generatedWithLayer.title?.GetRenderValue(tags)?.Subs(tags)?.txt metadata["name"] = generatedWithLayer.title?.GetRenderValue(tags)?.Subs(tags)?.txt
metadata["desc"] = "Generated with MapComplete layer " + generatedWithLayer.id metadata["desc"] = "Generated with MapComplete layer " + generatedWithLayer.id
if (tags._backend?.contains("openstreetmap")) { 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["author"] = tags["_last_edit:contributor"]
metadata["link"] = "https://www.openstreetmap.org/" + tags.id metadata["link"] = "https://www.openstreetmap.org/" + tags.id
metadata["time"] = tags["_last_edit:timestamp"] metadata["time"] = tags["_last_edit:timestamp"]
@ -404,18 +410,22 @@ export class GeoOperations {
return togpx(feature, { return togpx(feature, {
creator: "MapComplete " + Constants.vNumber, creator: "MapComplete " + Constants.vNumber,
metadata metadata,
}) })
} }
public static IdentifieCommonSegments(coordinatess: [number, number][][]): { public static IdentifieCommonSegments(coordinatess: [number, number][][]): {
originalIndex: number, originalIndex: number
segmentShardWith: number[], segmentShardWith: number[]
coordinates: [] 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]) // 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: // The strategy:
// 1. Index _all_ edges from _every_ linestring. Index them by starting key, gather which relations run over them // 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>() const allEdgesByKey = new Map<string, edge>()
for (let index = 0; index < coordinatess.length; index++) { 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++) { for (let i = 0; i < coordinates.length - 1; i++) {
const c0 = coordinates[i]
const c0 = coordinates[i];
const c1 = coordinates[i + 1] 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 let key: string
if (isReversed) { if (isReversed) {
@ -444,34 +453,32 @@ export class GeoOperations {
continue continue
} }
let edge: edge; let edge: edge
if (!isReversed) { if (!isReversed) {
edge = { edge = {
start: c0, start: c0,
end: c1, end: c1,
members: [member], members: [member],
intermediate: [] intermediate: [],
} }
} else { } else {
edge = { edge = {
start: c1, start: c1,
end: c0, end: c0,
members: [member], members: [member],
intermediate: [] intermediate: [],
} }
} }
allEdgesByKey.set(key, edge) allEdgesByKey.set(key, edge)
} }
} }
// Lets merge them back together! // Lets merge them back together!
let didMergeSomething = false; let didMergeSomething = false
let allMergedEdges = Array.from(allEdgesByKey.values()) let allMergedEdges = Array.from(allEdgesByKey.values())
const allEdgesByStartPoint = new Map<string, edge[]>() const allEdgesByStartPoint = new Map<string, edge[]>()
for (const edge of allMergedEdges) { for (const edge of allMergedEdges) {
edge.members.sort((m0, m1) => m0.index - m1.index) edge.members.sort((m0, m1) => m0.index - m1.index)
const kstart = edge.start + "" const kstart = edge.start + ""
@ -481,7 +488,6 @@ export class GeoOperations {
allEdgesByStartPoint.get(kstart).push(edge) allEdgesByStartPoint.get(kstart).push(edge)
} }
function membersAreCompatible(first: edge, second: edge): boolean { function membersAreCompatible(first: edge, second: edge): boolean {
// There must be an exact match between the members // There must be an exact match between the members
if (first.members === second.members) { if (first.members === second.members) {
@ -504,7 +510,6 @@ export class GeoOperations {
// Allrigth, they are the same, lets mark this permanently // Allrigth, they are the same, lets mark this permanently
second.members = first.members second.members = first.members
return true return true
} }
do { do {
@ -524,9 +529,8 @@ export class GeoOperations {
continue continue
} }
for (let i = 0; i < matchingEndEdges.length; i++) { for (let i = 0; i < matchingEndEdges.length; i++) {
const endEdge = matchingEndEdges[i]; const endEdge = matchingEndEdges[i]
if (consumed.has(endEdge)) { if (consumed.has(endEdge)) {
continue continue
@ -543,12 +547,11 @@ export class GeoOperations {
edge.end = endEdge.end edge.end = endEdge.end
consumed.add(endEdge) consumed.add(endEdge)
matchingEndEdges.splice(i, 1) matchingEndEdges.splice(i, 1)
break; break
} }
} }
allMergedEdges = allMergedEdges.filter(edge => !consumed.has(edge)); allMergedEdges = allMergedEdges.filter((edge) => !consumed.has(edge))
} while (didMergeSomething) } while (didMergeSomething)
return [] return []
@ -569,7 +572,7 @@ export class GeoOperations {
const copy = { const copy = {
...feature, ...feature,
geometry: {...feature.geometry} geometry: { ...feature.geometry },
} }
let coordinates: [number, number][] let coordinates: [number, number][]
if (feature.geometry.type === "LineString") { if (feature.geometry.type === "LineString") {
@ -582,7 +585,7 @@ export class GeoOperations {
// inline replacement in the coordinates list // inline replacement in the coordinates list
for (let i = coordinates.length - 2; i >= 1; i--) { for (let i = coordinates.length - 2; i >= 1; i--) {
const coordinate = coordinates[i]; const coordinate = coordinates[i]
const nextCoordinate = coordinates[i + 1] const nextCoordinate = coordinates[i + 1]
const prevCoordinate = 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 // In case that the line is going south, e.g. bearingN = 179, bearingP = -179
coordinates.splice(i, 1) coordinates.splice(i, 1)
} }
} }
return copy; return copy
} }
private static pointWithinRing(x: number, y: number, ring: [number, number][]) { 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++) { for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const coori = ring[i]; const coori = ring[i]
const coorj = ring[j]; const coorj = ring[j]
const xi = coori[0]; const xi = coori[0]
const yi = coori[1]; const yi = coori[1]
const xj = coorj[0]; const xj = coorj[0]
const yj = coorj[1]; const yj = coorj[1]
const intersect = ((yi > y) != (yj > y)) const intersect = yi > y != yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi
&& (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
if (intersect) { if (intersect) {
inside = !inside; inside = !inside
} }
} }
return inside; return inside
} }
/** /**
@ -642,46 +642,47 @@ export class GeoOperations {
* Returns 0 if both are linestrings * Returns 0 if both are linestrings
* Returns null if the features are not intersecting * 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") { if (feature.geometry.type === "LineString") {
otherFeatureBBox = otherFeatureBBox ?? BBox.get(otherFeature)
otherFeatureBBox = otherFeatureBBox ?? BBox.get(otherFeature);
const overlaps = featureBBox.overlapsWith(otherFeatureBBox) const overlaps = featureBBox.overlapsWith(otherFeatureBBox)
if (!overlaps) { if (!overlaps) {
return null; return null
} }
// Calculate the length of the intersection // Calculate the length of the intersection
let intersectionPoints = turf.lineIntersect(feature, otherFeature)
let intersectionPoints = turf.lineIntersect(feature, otherFeature);
if (intersectionPoints.features.length == 0) { if (intersectionPoints.features.length == 0) {
// No intersections. // No intersections.
// If one point is inside of the polygon, all points are // 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] const startCoor = coors[0]
if (this.inside(startCoor, otherFeature)) { if (this.inside(startCoor, otherFeature)) {
return this.lengthInMeters(feature) return this.lengthInMeters(feature)
} }
return null; return null
} }
let intersectionPointsArray = intersectionPoints.features.map(d => { let intersectionPointsArray = intersectionPoints.features.map((d) => {
return d.geometry.coordinates return d.geometry.coordinates
}); })
if (otherFeature.geometry.type === "LineString") { if (otherFeature.geometry.type === "LineString") {
if (intersectionPointsArray.length > 0) { if (intersectionPointsArray.length > 0) {
return 0 return 0
} }
return null; return null
} }
if (intersectionPointsArray.length == 1) { if (intersectionPointsArray.length == 1) {
// We need to add the start- or endpoint of the current feature, depending on which one is embedded // 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] const startCoor = coors[0]
if (this.inside(startCoor, otherFeature)) { if (this.inside(startCoor, otherFeature)) {
// The startpoint is embedded // 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) { if (intersection == null) {
return null; return null
} }
const intersectionSize = turf.length(intersection); // in km const intersectionSize = turf.length(intersection) // in km
return intersectionSize * 1000 return intersectionSize * 1000
} }
if (feature.geometry.type === "Polygon" || feature.geometry.type === "MultiPolygon") { if (feature.geometry.type === "Polygon" || feature.geometry.type === "MultiPolygon") {
const otherFeatureBBox = BBox.get(otherFeature); const otherFeatureBBox = BBox.get(otherFeature)
const overlaps = featureBBox.overlapsWith(otherFeatureBBox) const overlaps = featureBBox.overlapsWith(otherFeatureBBox)
if (!overlaps) { if (!overlaps) {
return null; return null
} }
if (otherFeature.geometry.type === "LineString") { if (otherFeature.geometry.type === "LineString") {
return this.calculateInstersection(otherFeature, feature, otherFeatureBBox, featureBBox) return this.calculateInstersection(
otherFeature,
feature,
otherFeatureBBox,
featureBBox
)
} }
try { try {
const intersection = turf.intersect(feature, otherFeature)
const intersection = turf.intersect(feature, otherFeature);
if (intersection == null) { if (intersection == null) {
return null; return null
} }
return turf.area(intersection); // in m² return turf.area(intersection) // in m²
} catch (e) { } catch (e) {
if (e.message === "Each LinearRing of a Polygon must have 4 or more Positions.") { if (e.message === "Each LinearRing of a Polygon must have 4 or more Positions.") {
// WORKAROUND TIME! // WORKAROUND TIME!
// See https://github.com/Turfjs/turf/pull/2238 // 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" throw "CalculateIntersection fallthrough: can not calculate an intersection between features"
} }
/** /**
@ -769,9 +774,10 @@ export class GeoOperations {
* GeoOperations.completelyWithin(pond, park) // => true * GeoOperations.completelyWithin(pond, park) // => true
* GeoOperations.completelyWithin(park, pond) // => false * GeoOperations.completelyWithin(park, pond) // => false
*/ */
static completelyWithin(feature: Feature<Geometry, any>, possiblyEncloingFeature: Feature<Polygon | MultiPolygon, any>) : boolean { static completelyWithin(
return booleanWithin(feature, possiblyEncloingFeature); 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 { Mapillary } from "./Mapillary"
import {WikimediaImageProvider} from "./WikimediaImageProvider"; import { WikimediaImageProvider } from "./WikimediaImageProvider"
import {Imgur} from "./Imgur"; import { Imgur } from "./Imgur"
import GenericImageProvider from "./GenericImageProvider"; import GenericImageProvider from "./GenericImageProvider"
import {Store, UIEventSource} from "../UIEventSource"; import { Store, UIEventSource } from "../UIEventSource"
import ImageProvider, {ProvidedImage} from "./ImageProvider"; import ImageProvider, { ProvidedImage } from "./ImageProvider"
import {WikidataImageProvider} from "./WikidataImageProvider"; import { WikidataImageProvider } from "./WikidataImageProvider"
/** /**
* A generic 'from the interwebz' image picker, without attribution * A generic 'from the interwebz' image picker, without attribution
*/ */
export default class AllImageProviders { export default class AllImageProviders {
public static ImageAttributionSource: ImageProvider[] = [ public static ImageAttributionSource: ImageProvider[] = [
Imgur.singleton, Imgur.singleton,
Mapillary.singleton, Mapillary.singleton,
WikidataImageProvider.singleton, WikidataImageProvider.singleton,
WikimediaImageProvider.singleton, WikimediaImageProvider.singleton,
new GenericImageProvider( new GenericImageProvider(
[].concat(...Imgur.defaultValuePrefix, ...WikimediaImageProvider.commonsPrefixes, ...Mapillary.valuePrefixes) [].concat(
...Imgur.defaultValuePrefix,
...WikimediaImageProvider.commonsPrefixes,
...Mapillary.valuePrefixes
) )
),
] ]
private static providersByName = { private static providersByName = {
"imgur": Imgur.singleton, imgur: Imgur.singleton,
"mapillary": Mapillary.singleton, mapillary: Mapillary.singleton,
"wikidata": WikidataImageProvider.singleton, wikidata: WikidataImageProvider.singleton,
"wikimedia": WikimediaImageProvider.singleton wikimedia: WikimediaImageProvider.singleton,
} }
public static byName(name: string) { public static byName(name: string) {
return AllImageProviders.providersByName[name.toLowerCase()] 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<
private static _cache: Map<string, UIEventSource<ProvidedImage[]>> = new Map<string, UIEventSource<ProvidedImage[]>>() string,
UIEventSource<ProvidedImage[]>
>()
public static LoadImagesFor(tags: Store<any>, tagKey?: string[]): Store<ProvidedImage[]> { public static LoadImagesFor(tags: Store<any>, tagKey?: string[]): Store<ProvidedImage[]> {
if (tags.data.id === undefined) { if (tags.data.id === undefined) {
return undefined; return undefined
} }
const cacheKey = tags.data.id + tagKey const cacheKey = tags.data.id + tagKey
@ -48,23 +55,21 @@ export default class AllImageProviders {
return cached return cached
} }
const source = new UIEventSource([]) const source = new UIEventSource([])
this._cache.set(cacheKey, source) this._cache.set(cacheKey, source)
const allSources = [] const allSources = []
for (const imageProvider of AllImageProviders.ImageAttributionSource) { for (const imageProvider of AllImageProviders.ImageAttributionSource) {
let prefixes = imageProvider.defaultKeyPrefixes let prefixes = imageProvider.defaultKeyPrefixes
if (tagKey !== undefined) { if (tagKey !== undefined) {
prefixes = tagKey prefixes = tagKey
} }
const singleSource = imageProvider.GetRelevantUrls(tags, { const singleSource = imageProvider.GetRelevantUrls(tags, {
prefixes: prefixes prefixes: prefixes,
}) })
allSources.push(singleSource) allSources.push(singleSource)
singleSource.addCallbackAndRunD(_ => { singleSource.addCallbackAndRunD((_) => {
const all: ProvidedImage[] = [].concat(...allSources.map(source => source.data)) const all: ProvidedImage[] = [].concat(...allSources.map((source) => source.data))
const uniq = [] const uniq = []
const seen = new Set<string>() const seen = new Set<string>()
for (const img of all) { for (const img of all) {
@ -77,7 +82,6 @@ export default class AllImageProviders {
source.setData(uniq) 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 { 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[]) { public constructor(valuePrefixBlacklist: string[]) {
super(); super()
this._valuePrefixBlacklist = valuePrefixBlacklist; this._valuePrefixBlacklist = valuePrefixBlacklist
} }
async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> { 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 [] return []
} }
@ -23,20 +22,20 @@ export default class GenericImageProvider extends ImageProvider {
return [] return []
} }
return [Promise.resolve({ return [
Promise.resolve({
key: key, key: key,
url: value, url: value,
provider: this provider: this,
})] }),
]
} }
SourceIcon(backlinkSource?: string) { SourceIcon(backlinkSource?: string) {
return undefined; return undefined
} }
public DownloadAttribution(url: string) { public DownloadAttribution(url: string) {
return undefined return undefined
} }
} }

View file

@ -1,50 +1,53 @@
import {Store, UIEventSource} from "../UIEventSource"; import { Store, UIEventSource } from "../UIEventSource"
import BaseUIElement from "../../UI/BaseUIElement"; import BaseUIElement from "../../UI/BaseUIElement"
import {LicenseInfo} from "./LicenseInfo"; import { LicenseInfo } from "./LicenseInfo"
import {Utils} from "../../Utils"; import { Utils } from "../../Utils"
export interface ProvidedImage { export interface ProvidedImage {
url: string, url: string
key: string, key: string
provider: ImageProvider provider: ImageProvider
} }
export default abstract class ImageProvider { export default abstract class ImageProvider {
public abstract readonly defaultKeyPrefixes: string[] 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 * Given a properies object, maps it onto _all_ the available pictures for this imageProvider
*/ */
public GetRelevantUrls(allTags: Store<any>, options?: { public GetRelevantUrls(
allTags: Store<any>,
options?: {
prefixes?: string[] prefixes?: string[]
}): UIEventSource<ProvidedImage[]> { }
): UIEventSource<ProvidedImage[]> {
const prefixes = options?.prefixes ?? this.defaultKeyPrefixes const prefixes = options?.prefixes ?? this.defaultKeyPrefixes
if (prefixes === undefined) { if (prefixes === undefined) {
throw "No `defaultKeyPrefixes` defined by this image provider" 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>() const seenValues = new Set<string>()
allTags.addCallbackAndRunD(tags => { allTags.addCallbackAndRunD((tags) => {
for (const key in tags) { for (const key in tags) {
if (!prefixes.some(prefix => key.startsWith(prefix))) { if (!prefixes.some((prefix) => key.startsWith(prefix))) {
continue 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) { for (const value of values) {
if (seenValues.has(value)) { if (seenValues.has(value)) {
continue continue
} }
seenValues.add(value) seenValues.add(value)
this.ExtractUrls(key, value).then(promises => { this.ExtractUrls(key, value).then((promises) => {
for (const promise of promises ?? []) { for (const promise of promises ?? []) {
if (promise === undefined) { if (promise === undefined) {
continue continue
} }
promise.then(providedImage => { promise.then((providedImage) => {
if (providedImage === undefined) { if (providedImage === undefined) {
return return
} }
@ -54,15 +57,12 @@ export default abstract class ImageProvider {
} }
}) })
} }
} }
}) })
return relevantUrls 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,100 +1,105 @@
import $ from "jquery" import ImageProvider, { ProvidedImage } from "./ImageProvider"
import ImageProvider, {ProvidedImage} from "./ImageProvider"; import BaseUIElement from "../../UI/BaseUIElement"
import BaseUIElement from "../../UI/BaseUIElement"; import { Utils } from "../../Utils"
import {Utils} from "../../Utils"; import Constants from "../../Models/Constants"
import Constants from "../../Models/Constants"; import { LicenseInfo } from "./LicenseInfo"
import {LicenseInfo} from "./LicenseInfo";
export class Imgur extends ImageProvider { export class Imgur extends ImageProvider {
public static readonly defaultValuePrefix = ["https://i.imgur.com"] public static readonly defaultValuePrefix = ["https://i.imgur.com"]
public static readonly singleton = new Imgur(); public static readonly singleton = new Imgur()
public readonly defaultKeyPrefixes: string[] = ["image"]; public readonly defaultKeyPrefixes: string[] = ["image"]
private constructor() { private constructor() {
super(); super()
} }
static uploadMultiple( static uploadMultiple(
title: string, description: string, blobs: FileList, title: string,
handleSuccessfullUpload: ((imageURL: string) => Promise<void>), description: string,
allDone: (() => void), blobs: FileList,
onFail: ((reason: string) => void), handleSuccessfullUpload: (imageURL: string) => Promise<void>,
offset: number = 0) { allDone: () => void,
onFail: (reason: string) => void,
offset: number = 0
) {
if (blobs.length == offset) { if (blobs.length == offset) {
allDone(); allDone()
return; return
} }
const blob = blobs.item(offset); const blob = blobs.item(offset)
const self = this; const self = this
this.uploadImage(title, description, blob, this.uploadImage(
title,
description,
blob,
async (imageUrl) => { async (imageUrl) => {
await handleSuccessfullUpload(imageUrl); await handleSuccessfullUpload(imageUrl)
self.uploadMultiple( self.uploadMultiple(
title, description, blobs, title,
description,
blobs,
handleSuccessfullUpload, handleSuccessfullUpload,
allDone, allDone,
onFail, onFail,
offset + 1); offset + 1
)
}, },
onFail onFail
); )
} }
static uploadImage(title: string, description: string, blob: File, static uploadImage(
handleSuccessfullUpload: ((imageURL: string) => Promise<void>), title: string,
onFail: (reason: string) => void) { 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 formData = new FormData()
const apiKey = Constants.ImgurApiKey; formData.append("image", blob)
formData.append("title", title)
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);
formData.append("description", description) 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 // Response contains stringified JSON
// Image URL available at response.data.link // Image URL available at response.data.link
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 // @ts-ignore
$.ajax(settings).done(async function (response) { onFail(reason)
response = JSON.parse(response); })
await handleSuccessfullUpload(response.data.link);
}).fail((reason) => {
console.log("Uploading to IMGUR failed", reason);
// @ts-ignore
onFail(reason);
});
} }
SourceIcon(): BaseUIElement { SourceIcon(): BaseUIElement {
return undefined; return undefined
} }
public async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> { public async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
if (Imgur.defaultValuePrefix.some(prefix => value.startsWith(prefix))) { if (Imgur.defaultValuePrefix.some((prefix) => value.startsWith(prefix))) {
return [Promise.resolve({ return [
Promise.resolve({
url: value, url: value,
key: key, key: key,
provider: this provider: this,
})] }),
]
} }
return [] return []
} }
@ -111,28 +116,26 @@ export class Imgur extends ImageProvider {
* licenseInfo // => expected * licenseInfo // => expected
*/ */
public async DownloadAttribution(url: string): Promise<LicenseInfo> { public async DownloadAttribution(url: string): Promise<LicenseInfo> {
const hash = url.substr("https://i.imgur.com/".length).split(".jpg")[0]; const hash = url.substr("https://i.imgur.com/".length).split(".jpg")[0]
const apiUrl = 'https://api.imgur.com/3/image/' + hash; const apiUrl = "https://api.imgur.com/3/image/" + hash
const response = await Utils.downloadJsonCached(apiUrl, 365*24*60*60, const response = await Utils.downloadJsonCached(apiUrl, 365 * 24 * 60 * 60, {
{Authorization: 'Client-ID ' + Constants.ImgurApiKey}) Authorization: "Client-ID " + Constants.ImgurApiKey,
})
const descr: string = response.data.description ?? ""; const descr: string = response.data.description ?? ""
const data: any = {}; const data: any = {}
for (const tag of descr.split("\n")) { for (const tag of descr.split("\n")) {
const kv = tag.split(":"); const kv = tag.split(":")
const k = kv[0]; const k = kv[0]
data[k] = kv[1]?.replace(/\r/g, ""); 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 return licenseInfo
} }
} }

View file

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

View file

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

View file

@ -1,15 +1,20 @@
import ImageProvider, {ProvidedImage} from "./ImageProvider"; import ImageProvider, { ProvidedImage } from "./ImageProvider"
import BaseUIElement from "../../UI/BaseUIElement"; import BaseUIElement from "../../UI/BaseUIElement"
import Svg from "../../Svg"; import Svg from "../../Svg"
import {Utils} from "../../Utils"; import { Utils } from "../../Utils"
import {LicenseInfo} from "./LicenseInfo"; import { LicenseInfo } from "./LicenseInfo"
import Constants from "../../Models/Constants"; import Constants from "../../Models/Constants"
export class Mapillary extends ImageProvider { 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" 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"] defaultKeyPrefixes = ["mapillary", "image"]
/** /**
@ -28,9 +33,9 @@ export class Mapillary extends ImageProvider {
const aUrl = new URL(a) const aUrl = new URL(a)
const bUrl = new URL(b) const bUrl = new URL(b)
if (aUrl.host !== bUrl.host || aUrl.pathname !== bUrl.pathname) { if (aUrl.host !== bUrl.host || aUrl.pathname !== bUrl.pathname) {
return false; return false
} }
let allSame = true; let allSame = true
aUrl.searchParams.forEach((value, key) => { aUrl.searchParams.forEach((value, key) => {
if (key === "stp") { if (key === "stp") {
// This is the key indicating the image size on mapillary; we ignore it // This is the key indicating the image size on mapillary; we ignore it
@ -41,20 +46,18 @@ export class Mapillary extends ImageProvider {
return return
} }
}) })
return allSame; return allSame
} catch (e) { } catch (e) {
console.debug("Could not compare ", a, "and", b, "due to", e) console.debug("Could not compare ", a, "and", b, "due to", e)
} }
return false; return false
} }
/** /**
* Returns the correct key for API v4.0 * Returns the correct key for API v4.0
*/ */
private static ExtractKeyFromURL(value: string): number { private static ExtractKeyFromURL(value: string): number {
let key: string; let key: string
const newApiFormat = value.match(/https?:\/\/www.mapillary.com\/app\/\?pKey=([0-9]*)/) const newApiFormat = value.match(/https?:\/\/www.mapillary.com\/app\/\?pKey=([0-9]*)/)
if (newApiFormat !== null) { if (newApiFormat !== null) {
@ -62,7 +65,7 @@ export class Mapillary extends ImageProvider {
} else if (value.startsWith(Mapillary.valuePrefix)) { } else if (value.startsWith(Mapillary.valuePrefix)) {
key = value.substring(0, value.lastIndexOf("?")).substring(value.lastIndexOf("/") + 1) key = value.substring(0, value.lastIndexOf("?")).substring(value.lastIndexOf("/") + 1)
} else if (value.match("[0-9]*")) { } else if (value.match("[0-9]*")) {
key = value; key = value
} }
const keyAsNumber = Number(key) const keyAsNumber = Number(key)
@ -74,7 +77,7 @@ export class Mapillary extends ImageProvider {
} }
SourceIcon(backlinkSource?: string): BaseUIElement { SourceIcon(backlinkSource?: string): BaseUIElement {
return Svg.mapillary_svg(); return Svg.mapillary_svg()
} }
async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> { 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> { public async DownloadAttribution(url: string): Promise<LicenseInfo> {
const license = new LicenseInfo() const license = new LicenseInfo()
license.artist = "Contributor name unavailable"; license.artist = "Contributor name unavailable"
license.license = "CC BY-SA 4.0"; license.license = "CC BY-SA 4.0"
// license.license = "Creative Commons Attribution-ShareAlike 4.0 International License"; // license.license = "Creative Commons Attribution-ShareAlike 4.0 International License";
license.attributionRequired = true; license.attributionRequired = true
return license return license
} }
private async PrepareUrlAsync(key: string, value: string): Promise<ProvidedImage> { private async PrepareUrlAsync(key: string, value: string): Promise<ProvidedImage> {
const mapillaryId = Mapillary.ExtractKeyFromURL(value) const mapillaryId = Mapillary.ExtractKeyFromURL(value)
if (mapillaryId === undefined) { 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 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 response = await Utils.downloadJsonCached(metadataUrl, 60 * 60)
const url = <string>response["thumb_1024_url"]; const url = <string>response["thumb_1024_url"]
return { return {
url: url, url: url,
provider: this, provider: this,
key: key key: key,
} }
} }
} }

View file

@ -1,11 +1,10 @@
import ImageProvider, {ProvidedImage} from "./ImageProvider"; import ImageProvider, { ProvidedImage } from "./ImageProvider"
import BaseUIElement from "../../UI/BaseUIElement"; import BaseUIElement from "../../UI/BaseUIElement"
import Svg from "../../Svg"; import Svg from "../../Svg"
import {WikimediaImageProvider} from "./WikimediaImageProvider"; import { WikimediaImageProvider } from "./WikimediaImageProvider"
import Wikidata from "../Web/Wikidata"; import Wikidata from "../Web/Wikidata"
export class WikidataImageProvider extends ImageProvider { export class WikidataImageProvider extends ImageProvider {
public static readonly singleton = new WikidataImageProvider() public static readonly singleton = new WikidataImageProvider()
public readonly defaultKeyPrefixes = ["wikidata"] public readonly defaultKeyPrefixes = ["wikidata"]
@ -14,7 +13,7 @@ export class WikidataImageProvider extends ImageProvider {
} }
public SourceIcon(backlinkSource?: string): BaseUIElement { public SourceIcon(backlinkSource?: string): BaseUIElement {
throw Svg.wikidata_svg(); throw Svg.wikidata_svg()
} }
public async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> { public async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
@ -39,7 +38,10 @@ export class WikidataImageProvider extends ImageProvider {
} }
const commons = entity.commons 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) const promises = await WikimediaImageProvider.singleton.ExtractUrls(undefined, commons)
allImages.push(...promises) allImages.push(...promises)
} }
@ -47,7 +49,6 @@ export class WikidataImageProvider extends ImageProvider {
} }
public DownloadAttribution(url: string): Promise<any> { 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 ImageProvider, { ProvidedImage } from "./ImageProvider"
import BaseUIElement from "../../UI/BaseUIElement"; import BaseUIElement from "../../UI/BaseUIElement"
import Svg from "../../Svg"; import Svg from "../../Svg"
import Link from "../../UI/Base/Link"; import Link from "../../UI/Base/Link"
import {Utils} from "../../Utils"; import { Utils } from "../../Utils"
import {LicenseInfo} from "./LicenseInfo"; import { LicenseInfo } from "./LicenseInfo"
import Wikimedia from "../Web/Wikimedia"; import Wikimedia from "../Web/Wikimedia"
/** /**
* This module provides endpoints for wikimedia and others * This module provides endpoints for wikimedia and others
*/ */
export class WikimediaImageProvider extends ImageProvider { export class WikimediaImageProvider extends ImageProvider {
public static readonly singleton = new WikimediaImageProvider()
public static readonly commonsPrefixes = [
public static readonly singleton = new WikimediaImageProvider(); "https://commons.wikimedia.org/wiki/",
public static readonly commonsPrefixes = ["https://commons.wikimedia.org/wiki/", "https://upload.wikimedia.org", "File:"] "https://upload.wikimedia.org",
"File:",
]
private readonly commons_key = "wikimedia_commons" private readonly commons_key = "wikimedia_commons"
public readonly defaultKeyPrefixes = [this.commons_key, "image"] public readonly defaultKeyPrefixes = [this.commons_key, "image"]
private constructor() { private constructor() {
super(); super()
} }
private static ExtractFileName(url: string) { private static ExtractFileName(url: string) {
if (!url.startsWith("http")) { if (!url.startsWith("http")) {
return url; return url
} }
const path = new URL(url).pathname const path = new URL(url).pathname
return path.substring(path.lastIndexOf("/") + 1); return path.substring(path.lastIndexOf("/") + 1)
} }
private static PrepareUrl(value: string): string { private static PrepareUrl(value: string): string {
if (value.toLowerCase().startsWith("https://commons.wikimedia.org/wiki/")) { 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 { 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 { private static removeCommonsPrefix(value: string): string {
@ -49,7 +51,7 @@ export class WikimediaImageProvider extends ImageProvider {
if (!value.startsWith("File:")) { if (!value.startsWith("File:")) {
value = "File:" + value value = "File:" + value
} }
return value; return value
} }
for (const prefix of WikimediaImageProvider.commonsPrefixes) { for (const prefix of WikimediaImageProvider.commonsPrefixes) {
@ -61,21 +63,20 @@ export class WikimediaImageProvider extends ImageProvider {
return part return part
} }
} }
return value; return value
} }
SourceIcon(backlink: string): BaseUIElement { SourceIcon(backlink: string): BaseUIElement {
const img = Svg.wikimedia_commons_white_svg() const img = Svg.wikimedia_commons_white_svg().SetStyle("width:2em;height: 2em")
.SetStyle("width:2em;height: 2em");
if (backlink === undefined) { if (backlink === undefined) {
return img return img
} }
return new Link(
return new Link(Svg.wikimedia_commons_white_img, Svg.wikimedia_commons_white_img,
`https://commons.wikimedia.org/wiki/${backlink}`, true) `https://commons.wikimedia.org/wiki/${backlink}`,
true
)
} }
public PrepUrl(value: string): ProvidedImage { public PrepUrl(value: string): ProvidedImage {
@ -99,7 +100,9 @@ export class WikimediaImageProvider extends ImageProvider {
value = WikimediaImageProvider.removeCommonsPrefix(value) value = WikimediaImageProvider.removeCommonsPrefix(value)
if (value.startsWith("Category:")) { if (value.startsWith("Category:")) {
const urls = await Wikimedia.GetCategoryContents(value) 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:")) { if (value.startsWith("File:")) {
return [Promise.resolve(this.UrlForImage(value))] return [Promise.resolve(this.UrlForImage(value))]
@ -116,24 +119,30 @@ export class WikimediaImageProvider extends ImageProvider {
filename = WikimediaImageProvider.ExtractFileName(filename) filename = WikimediaImageProvider.ExtractFileName(filename)
if (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&" + "api.php?action=query&prop=imageinfo&iiprop=extmetadata&" +
"titles=" + filename + "titles=" +
"&format=json&origin=*"; filename +
"&format=json&origin=*"
const data = await Utils.downloadJsonCached(url, 365 * 24 * 60 * 60) const data = await Utils.downloadJsonCached(url, 365 * 24 * 60 * 60)
const licenseInfo = new LicenseInfo(); const licenseInfo = new LicenseInfo()
const pageInfo = data.query.pages[-1] const pageInfo = data.query.pages[-1]
if (pageInfo === undefined) { if (pageInfo === undefined) {
return undefined; return undefined
} }
const license = (pageInfo.imageinfo ?? [])[0]?.extmetadata; const license = (pageInfo.imageinfo ?? [])[0]?.extmetadata
if (license === undefined) { if (license === undefined) {
console.warn("The file", filename, "has no usable metedata or license attached... Please fix the license info file yourself!") console.warn(
return undefined; "The file",
filename,
"has no usable metedata or license attached... Please fix the license info file yourself!"
)
return undefined
} }
let title = pageInfo.title let title = pageInfo.title
@ -145,17 +154,16 @@ export class WikimediaImageProvider extends ImageProvider {
} }
licenseInfo.title = title licenseInfo.title = title
licenseInfo.artist = license.Artist?.value; licenseInfo.artist = license.Artist?.value
licenseInfo.license = license.License?.value; licenseInfo.license = license.License?.value
licenseInfo.copyrighted = license.Copyrighted?.value; licenseInfo.copyrighted = license.Copyrighted?.value
licenseInfo.attributionRequired = license.AttributionRequired?.value; licenseInfo.attributionRequired = license.AttributionRequired?.value
licenseInfo.usageTerms = license.UsageTerms?.value; licenseInfo.usageTerms = license.UsageTerms?.value
licenseInfo.licenseShortName = license.LicenseShortName?.value; licenseInfo.licenseShortName = license.LicenseShortName?.value
licenseInfo.credit = license.Credit?.value; licenseInfo.credit = license.Credit?.value
licenseInfo.description = license.ImageDescription?.value; licenseInfo.description = license.ImageDescription?.value
licenseInfo.informationLocation = new URL("https://en.wikipedia.org/wiki/" + pageInfo.title) licenseInfo.informationLocation = new URL("https://en.wikipedia.org/wiki/" + pageInfo.title)
return licenseInfo; return licenseInfo
} }
private UrlForImage(image: string): ProvidedImage { private UrlForImage(image: string): ProvidedImage {
@ -164,7 +172,4 @@ export class WikimediaImageProvider extends ImageProvider {
} }
return { url: WikimediaImageProvider.PrepareUrl(image), key: undefined, provider: this } return { url: WikimediaImageProvider.PrepareUrl(image), key: undefined, provider: this }
} }
} }

View file

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

View file

@ -1,8 +1,7 @@
import SimpleMetaTaggers, {SimpleMetaTagger} from "./SimpleMetaTagger"; import SimpleMetaTaggers, { SimpleMetaTagger } from "./SimpleMetaTagger"
import {ExtraFuncParams, ExtraFunctions} from "./ExtraFunctions"; import { ExtraFuncParams, ExtraFunctions } from "./ExtraFunctions"
import LayerConfig from "../Models/ThemeConfig/LayerConfig"; import LayerConfig from "../Models/ThemeConfig/LayerConfig"
import {ElementStorage} from "./ElementStorage"; import { ElementStorage } from "./ElementStorage"
/** /**
* Metatagging adds various tags to the elements, e.g. lat, lon, surface area, ... * 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 * All metatags start with an underscore
*/ */
export default class MetaTagging { 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)[]>() 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 * Returns true if at least one feature has changed properties
*/ */
public static addMetatags(features: { feature: any; freshness: Date }[], public static addMetatags(
features: { feature: any; freshness: Date }[],
params: ExtraFuncParams, params: ExtraFuncParams,
layer: LayerConfig, layer: LayerConfig,
state?: { allElements?: ElementStorage }, state?: { allElements?: ElementStorage },
options?: { options?: {
includeDates?: true | boolean, includeDates?: true | boolean
includeNonDates?: true | boolean, includeNonDates?: true | boolean
evaluateStrict?: false | boolean evaluateStrict?: false | boolean
}): boolean { }
): boolean {
if (features === undefined || features.length === 0) { if (features === undefined || features.length === 0) {
return; return
} }
console.log("Recalculating metatags...") console.log("Recalculating metatags...")
@ -52,27 +51,27 @@ export default class MetaTagging {
// The calculated functions - per layer - which add the new keys // The calculated functions - per layer - which add the new keys
const layerFuncs = this.createRetaggingFunc(layer, state) const layerFuncs = this.createRetaggingFunc(layer, state)
let atLeastOneFeatureChanged = false; let atLeastOneFeatureChanged = false
for (let i = 0; i < features.length; i++) { for (let i = 0; i < features.length; i++) {
const ff = features[i]; const ff = features[i]
const feature = ff.feature const feature = ff.feature
const freshness = ff.freshness const freshness = ff.freshness
let somethingChanged = false let somethingChanged = false
let definedTags = new Set(Object.getOwnPropertyNames(feature.properties)) let definedTags = new Set(Object.getOwnPropertyNames(feature.properties))
for (const metatag of metatagsToApply) { for (const metatag of metatagsToApply) {
try { 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 // All keys are already defined, we probably already ran this one
continue continue
} }
if (metatag.isLazy) { 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! // All keys are defined - lets skip!
continue continue
} }
somethingChanged = true; somethingChanged = true
metatag.applyMetaTagsOnFeature(feature, freshness, layer, state) metatag.applyMetaTagsOnFeature(feature, freshness, layer, state)
if (options?.evaluateStrict) { if (options?.evaluateStrict) {
for (const key of metatag.keys) { for (const key of metatag.keys) {
@ -80,7 +79,12 @@ export default class MetaTagging {
} }
} }
} else { } else {
const newValueAdded = metatag.applyMetaTagsOnFeature(feature, freshness, layer, state) const newValueAdded = metatag.applyMetaTagsOnFeature(
feature,
freshness,
layer,
state
)
/* Note that the expression: /* Note that the expression:
* `somethingChanged = newValueAdded || metatag.applyMetaTagsOnFeature(feature, freshness)` * `somethingChanged = newValueAdded || metatag.applyMetaTagsOnFeature(feature, freshness)`
* Is WRONG * Is WRONG
@ -91,12 +95,18 @@ export default class MetaTagging {
somethingChanged = newValueAdded || somethingChanged somethingChanged = newValueAdded || somethingChanged
} }
} catch (e) { } 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) { if (layerFuncs !== undefined) {
let retaggingChanged = false; let retaggingChanged = false
try { try {
retaggingChanged = layerFuncs(params, feature) retaggingChanged = layerFuncs(params, feature)
} catch (e) { } catch (e) {
@ -113,42 +123,62 @@ export default class MetaTagging {
return atLeastOneFeatureChanged return atLeastOneFeatureChanged
} }
private static createFunctionsForFeature(layerId: string, calculatedTags: [string, string, boolean][]): ((feature: any) => void)[] { private static createFunctionsForFeature(
const functions: ((feature: any) => any)[] = []; layerId: string,
calculatedTags: [string, string, boolean][]
): ((feature: any) => void)[] {
const functions: ((feature: any) => any)[] = []
for (const entry of calculatedTags) { for (const entry of calculatedTags) {
const key = entry[0] const key = entry[0]
const code = entry[1]; const code = entry[1]
const isStrict = entry[2] const isStrict = entry[2]
if (code === undefined) { if (code === undefined) {
continue; continue
} }
const calculateAndAssign: ((feat: any) => any) = (feat) => { const calculateAndAssign: (feat: any) => any = (feat) => {
try { try {
let result = new Function("feat", "return " + code + ";")(feat); let result = new Function("feat", "return " + code + ";")(feat)
if (result === "") { if (result === "") {
result === undefined result === undefined
} }
if (result !== undefined && typeof result !== "string") { if (result !== undefined && typeof result !== "string") {
// Make sure it is a string! // Make sure it is a string!
result = JSON.stringify(result); result = JSON.stringify(result)
} }
delete feat.properties[key] delete feat.properties[key]
feat.properties[key] = result; feat.properties[key] = result
return result return result
} catch (e) { } catch (e) {
if (MetaTagging.errorPrintCount < MetaTagging.stopErrorOutputAt) { 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) console.warn(
MetaTagging.errorPrintCount++; "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) { 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) { if (isStrict) {
functions.push(calculateAndAssign) functions.push(calculateAndAssign)
continue continue
@ -162,15 +192,14 @@ export default class MetaTagging {
enumerable: false, // By setting this as not enumerable, the localTileSaver will _not_ calculate this enumerable: false, // By setting this as not enumerable, the localTileSaver will _not_ calculate this
get: function () { get: function () {
return calculateAndAssign(feature) return calculateAndAssign(feature)
} },
}) })
return undefined return undefined
} }
functions.push(f) functions.push(f)
} }
return functions; return functions
} }
/** /**
@ -179,39 +208,37 @@ export default class MetaTagging {
* @param state * @param state
* @private * @private
*/ */
private static createRetaggingFunc(layer: LayerConfig, state): private static createRetaggingFunc(
((params: ExtraFuncParams, feature: any) => boolean) { layer: LayerConfig,
state
const calculatedTags: [string, string, boolean][] = layer.calculatedTags; ): (params: ExtraFuncParams, feature: any) => boolean {
const calculatedTags: [string, string, boolean][] = layer.calculatedTags
if (calculatedTags === undefined || calculatedTags.length === 0) { 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) { if (functions === undefined) {
functions = MetaTagging.createFunctionsForFeature(layer.id, calculatedTags) functions = MetaTagging.createFunctionsForFeature(layer.id, calculatedTags)
MetaTagging.retaggingFuncCache.set(layer.id, functions) MetaTagging.retaggingFuncCache.set(layer.id, functions)
} }
return (params: ExtraFuncParams, feature) => { return (params: ExtraFuncParams, feature) => {
const tags = feature.properties const tags = feature.properties
if (tags === undefined) { if (tags === undefined) {
return; return
} }
try { try {
ExtraFunctions.FullPatchFeature(params, feature); ExtraFunctions.FullPatchFeature(params, feature)
for (const f of functions) { 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) { } catch (e) {
console.error("Invalid syntax in calculated tags or some other error: ", e) console.error("Invalid syntax in calculated tags or some other error: ", e)
} }
return true; // Something changed return true // Something changed
} }
} }
} }

View file

@ -1,10 +1,9 @@
import {OsmNode, OsmRelation, OsmWay} from "../OsmObject"; import { OsmNode, OsmRelation, OsmWay } from "../OsmObject"
/** /**
* Represents a single change to an object * Represents a single change to an object
*/ */
export interface ChangeDescription { export interface ChangeDescription {
/** /**
* Metadata to be included in the changeset * Metadata to be included in the changeset
*/ */
@ -12,7 +11,7 @@ export interface ChangeDescription {
/* /*
* The theme with which this changeset was made * The theme with which this changeset was made
*/ */
theme: string, theme: string
/** /**
* The type of the change * 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' * THe motivation for the change, e.g. 'deleted because does not exist anymore'
*/ */
specialMotivation?: string, specialMotivation?: string
/** /**
* Added by Changes.ts * Added by Changes.ts
*/ */
distanceToObject?: number distanceToObject?: number
}, }
/** /**
* Identifier of the object * Identifier of the object
*/ */
type: "node" | "way" | "relation", type: "node" | "way" | "relation"
/** /**
* Identifier of the object * Identifier of the object
* Negative for new objects * Negative for new objects
*/ */
id: number, id: number
/** /**
* All changes to tags * 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 * 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: * A change to the geometry:
@ -51,16 +50,19 @@ export interface ChangeDescription {
* 2) Change of way geometry * 2) Change of way geometry
* 3) Change of relation members (untested) * 3) Change of relation members (untested)
*/ */
changes?: { changes?:
lat: number, | {
lat: number
lon: number lon: number
} | { }
| {
/* Coordinates are only used for rendering. They should be LON, LAT /* Coordinates are only used for rendering. They should be LON, LAT
* */ * */
coordinates: [number, number][] coordinates: [number, number][]
nodes: number[], nodes: number[]
} | { }
members: { type: "node" | "way" | "relation", ref: number, role: string }[] | {
members: { type: "node" | "way" | "relation"; ref: number; role: string }[]
} }
/* /*
@ -70,7 +72,6 @@ export interface ChangeDescription {
} }
export class ChangeDescriptionTools { export class ChangeDescriptionTools {
/** /**
* Rewrites all the ids in a changeDescription * Rewrites all the ids in a changeDescription
* *
@ -130,15 +131,20 @@ export class ChangeDescriptionTools {
* rewritten.changes["members"] // => [{type: "way", ref: 42, role: "outer"},{type: "way", ref: 48, role: "outer"}] * 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 key = change.type + "/" + change.id
const wayHasChangedNode = ((change.changes ?? {})["nodes"] ?? []).some(id => mappings.has("node/" + id)); const wayHasChangedNode = ((change.changes ?? {})["nodes"] ?? []).some((id) =>
const relationHasChangedMembers = ((change.changes ?? {})["members"] ?? []) mappings.has("node/" + id)
.some((obj:{type: string, ref: number}) => mappings.has(obj.type+"/" + obj.ref)); )
const relationHasChangedMembers = ((change.changes ?? {})["members"] ?? []).some(
(obj: { type: string; ref: number }) => mappings.has(obj.type + "/" + obj.ref)
)
const hasSomeChange = mappings.has(key) const hasSomeChange = mappings.has(key) || wayHasChangedNode || relationHasChangedMembers
|| wayHasChangedNode || relationHasChangedMembers
if (hasSomeChange) { if (hasSomeChange) {
change = { ...change } change = { ...change }
} }
@ -149,7 +155,7 @@ export class ChangeDescriptionTools {
} }
if (wayHasChangedNode) { if (wayHasChangedNode) {
change.changes = { ...change.changes } change.changes = { ...change.changes }
change.changes["nodes"] = change.changes["nodes"].map(id => { change.changes["nodes"] = change.changes["nodes"].map((id) => {
const key = "node/" + id const key = "node/" + id
if (!mappings.has(key)) { if (!mappings.has(key)) {
return id return id
@ -161,8 +167,8 @@ export class ChangeDescriptionTools {
if (relationHasChangedMembers) { if (relationHasChangedMembers) {
change.changes = { ...change.changes } change.changes = { ...change.changes }
change.changes["members"] = change.changes["members"].map( change.changes["members"] = change.changes["members"].map(
(obj:{type: string, ref: number}) => { (obj: { type: string; ref: number }) => {
const key = obj.type+"/"+obj.ref; const key = obj.type + "/" + obj.ref
if (!mappings.has(key)) { if (!mappings.has(key)) {
return obj return obj
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,44 +1,55 @@
import {TagsFilter} from "../Tags/TagsFilter"; import { TagsFilter } from "../Tags/TagsFilter"
import RelationsTracker from "./RelationsTracker"; import RelationsTracker from "./RelationsTracker"
import {Utils} from "../../Utils"; import { Utils } from "../../Utils"
import {ImmutableStore, Store} from "../UIEventSource"; import { ImmutableStore, Store } from "../UIEventSource"
import {BBox} from "../BBox"; import { BBox } from "../BBox"
import * as osmtogeojson from "osmtogeojson"; import * as osmtogeojson from "osmtogeojson"
import {FeatureCollection} from "@turf/turf"; import { FeatureCollection } from "@turf/turf"
/** /**
* Interfaces overpass to get all the latest data * Interfaces overpass to get all the latest data
*/ */
export class Overpass { export class Overpass {
private _filter: TagsFilter private _filter: TagsFilter
private readonly _interpreterUrl: string; private readonly _interpreterUrl: string
private readonly _timeout: Store<number>; private readonly _timeout: Store<number>
private readonly _extraScripts: string[]; private readonly _extraScripts: string[]
private _includeMeta: boolean; private _includeMeta: boolean
private _relationTracker: RelationsTracker; private _relationTracker: RelationsTracker
constructor(filter: TagsFilter, constructor(
filter: TagsFilter,
extraScripts: string[], extraScripts: string[],
interpreterUrl: string, interpreterUrl: string,
timeout?: Store<number>, timeout?: Store<number>,
relationTracker?: RelationsTracker, relationTracker?: RelationsTracker,
includeMeta = true) { includeMeta = true
this._timeout = timeout ?? new ImmutableStore<number>(90); ) {
this._interpreterUrl = interpreterUrl; this._timeout = timeout ?? new ImmutableStore<number>(90)
this._interpreterUrl = interpreterUrl
const optimized = filter.optimize() const optimized = filter.optimize()
if (optimized === true || optimized === false) { if (optimized === true || optimized === false) {
throw "Invalid filter: optimizes to true of false" throw "Invalid filter: optimizes to true of false"
} }
this._filter = optimized this._filter = optimized
this._extraScripts = extraScripts; this._extraScripts = extraScripts
this._includeMeta = includeMeta; this._includeMeta = includeMeta
this._relationTracker = relationTracker this._relationTracker = relationTracker
} }
public async queryGeoJson(bounds: BBox): Promise<[FeatureCollection, Date]> { 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) const query = this.buildScript(bbox)
return this.ExecuteQuery(query); return this.ExecuteQuery(query)
} }
public buildUrl(query: string) { public buildUrl(query: string) {
@ -46,11 +57,11 @@ export class Overpass {
} }
public async ExecuteQuery(query: string): Promise<[FeatureCollection, Date]> { public async ExecuteQuery(query: string): Promise<[FeatureCollection, Date]> {
const self = this; const self = this
const json = await Utils.downloadJson(this.buildUrl(query)) const json = await Utils.downloadJson(this.buildUrl(query))
if (json.elements.length === 0 && json.remark !== undefined) { 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}` throw `Runtime error (timeout or similar)${json.remark}`
} }
if (json.elements.length === 0) { if (json.elements.length === 0) {
@ -58,9 +69,9 @@ export class Overpass {
} }
self._relationTracker?.RegisterRelations(json) self._relationTracker?.RegisterRelations(json)
const geojson = osmtogeojson.default(json); const geojson = osmtogeojson.default(json)
const osmTime = new Date(json.osm3s.timestamp_osm_base); const osmTime = new Date(json.osm3s.timestamp_osm_base)
return [<any> geojson, osmTime]; return [<any>geojson, osmTime]
} }
/** /**
@ -78,47 +89,51 @@ export class Overpass {
if (pretty) { if (pretty) {
filter += " " filter += " "
} }
filter += 'nwr' + filterOr + postCall + ';' filter += "nwr" + filterOr + postCall + ";"
if (pretty) { if (pretty) {
filter += "\n" filter += "\n"
} }
} }
for (const extraScript of this._extraScripts) { 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 * Constructs the actual script to execute on Overpass with geocoding
* 'PostCall' can be used to set an extra range, see 'AsOverpassTurboLink' * '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() const filters = this._filter.asOverpass()
let filter = "" let filter = ""
for (const filterOr of filters) { for (const filterOr of filters) {
if (pretty) { if (pretty) {
filter += " " filter += " "
} }
filter += 'nwr' + filterOr + '(area.searchArea);' filter += "nwr" + filterOr + "(area.searchArea);"
if (pretty) { if (pretty) {
filter += "\n" filter += "\n"
} }
} }
for (const extraScript of this._extraScripts) { for (const extraScript of this._extraScripts) {
filter += '(' + extraScript + ');'; filter += "(" + extraScript + ");"
} }
let id = area.osm_id; let id = area.osm_id
if (area.osm_type === "relation") { if (area.osm_type === "relation") {
id += 3600000000 id += 3600000000
} }
return `[out:json][timeout:${this._timeout.data}]; return `[out:json][timeout:${this._timeout.data}];
area(id:${id})->.searchArea; area(id:${id})->.searchArea;
(${filter}); (${filter});
out body;${this._includeMeta ? 'out meta;' : ''}>;out skel qt;` out body;${this._includeMeta ? "out meta;" : ""}>;out skel qt;`
} }
public buildQuery(bbox: string) { public buildQuery(bbox: string) {
return this.buildUrl(this.buildScript(bbox)) return this.buildUrl(this.buildScript(bbox))
} }

View file

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

View file

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

View file

@ -1,107 +1,125 @@
import {GeoOperations} from "./GeoOperations"; import { GeoOperations } from "./GeoOperations"
import {Utils} from "../Utils"; import { Utils } from "../Utils"
import opening_hours from "opening_hours"; import opening_hours from "opening_hours"
import Combine from "../UI/Base/Combine"; import Combine from "../UI/Base/Combine"
import BaseUIElement from "../UI/BaseUIElement"; import BaseUIElement from "../UI/BaseUIElement"
import Title from "../UI/Base/Title"; import Title from "../UI/Base/Title"
import {FixedUiElement} from "../UI/Base/FixedUiElement"; import { FixedUiElement } from "../UI/Base/FixedUiElement"
import LayerConfig from "../Models/ThemeConfig/LayerConfig"; import LayerConfig from "../Models/ThemeConfig/LayerConfig"
import { CountryCoder } from "latlon2country" import { CountryCoder } from "latlon2country"
import Constants from "../Models/Constants"; import Constants from "../Models/Constants"
import {TagUtils} from "./Tags/TagUtils"; import { TagUtils } from "./Tags/TagUtils"
export class SimpleMetaTagger { export class SimpleMetaTagger {
public readonly keys: string[]; public readonly keys: string[]
public readonly doc: string; public readonly doc: string
public readonly isLazy: boolean; public readonly isLazy: boolean
public readonly includesDates: 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 * A function that adds some extra data to a feature
* @param docs: what does this extra data do? * @param docs: what does this extra data do?
* @param f: apply the changes. Returns true if something changed * @param f: apply the changes. Returns true if something changed
*/ */
constructor(docs: { keys: string[], doc: string, includesDates?: boolean, isLazy?: boolean, cleanupRetagger?: boolean }, constructor(
f: ((feature: any, freshness: Date, layer: LayerConfig, state) => boolean)) { docs: {
this.keys = docs.keys; keys: string[]
this.doc = docs.doc; 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.isLazy = docs.isLazy
this.applyMetaTagsOnFeature = f; this.applyMetaTagsOnFeature = f
this.includesDates = docs.includesDates ?? false; this.includesDates = docs.includesDates ?? false
if (!docs.cleanupRetagger) { if (!docs.cleanupRetagger) {
for (const key of docs.keys) { 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 (_)` throw `Incorrect key for a calculated meta value '${key}': it should start with underscore (_)`
} }
} }
} }
} }
} }
export class CountryTagger extends SimpleMetaTagger { export class CountryTagger extends SimpleMetaTagger {
private static readonly coder = new CountryCoder(Constants.countryCoderEndpoint, Utils.downloadJson); private static readonly coder = new CountryCoder(
public runningTasks: Set<any>; Constants.countryCoderEndpoint,
Utils.downloadJson
)
public runningTasks: Set<any>
constructor() { constructor() {
const runningTasks = new Set<any>(); const runningTasks = new Set<any>()
super super(
(
{ {
keys: ["_country"], keys: ["_country"],
doc: "The country code of the property (with latlon2country)", doc: "The country code of the property (with latlon2country)",
includesDates: false includesDates: false,
}, },
((feature, _, __, state) => { (feature, _, __, state) => {
let centerPoint: any = GeoOperations.centerpoint(feature); let centerPoint: any = GeoOperations.centerpoint(feature)
const lat = centerPoint.geometry.coordinates[1]; const lat = centerPoint.geometry.coordinates[1]
const lon = centerPoint.geometry.coordinates[0]; const lon = centerPoint.geometry.coordinates[0]
runningTasks.add(feature) runningTasks.add(feature)
CountryTagger.coder.GetCountryCodeAsync(lon, lat).then( CountryTagger.coder
countries => { .GetCountryCodeAsync(lon, lat)
.then((countries) => {
runningTasks.delete(feature) runningTasks.delete(feature)
try { try {
const oldCountry = feature.properties["_country"]; const oldCountry = feature.properties["_country"]
feature.properties["_country"] = countries[0].trim().toLowerCase(); feature.properties["_country"] = countries[0].trim().toLowerCase()
if (oldCountry !== feature.properties["_country"]) { if (oldCountry !== feature.properties["_country"]) {
const tagsSource = state?.allElements?.getEventSourceById(feature.properties.id); const tagsSource = state?.allElements?.getEventSourceById(
tagsSource?.ping(); feature.properties.id
)
tagsSource?.ping()
} }
} catch (e) { } catch (e) {
console.warn(e) console.warn(e)
} }
} })
).catch(_ => { .catch((_) => {
runningTasks.delete(feature) runningTasks.delete(feature)
}) })
return false; return false
}) }
) )
this.runningTasks = runningTasks; this.runningTasks = runningTasks
} }
} }
export default class SimpleMetaTaggers { export default class SimpleMetaTaggers {
public static readonly objectMetaInfo = new SimpleMetaTagger( public static readonly objectMetaInfo = new SimpleMetaTagger(
{ {
keys: ["_last_edit:contributor", keys: [
"_last_edit:contributor",
"_last_edit:contributor:uid", "_last_edit:contributor:uid",
"_last_edit:changeset", "_last_edit:changeset",
"_last_edit:timestamp", "_last_edit:timestamp",
"_version_number", "_version_number",
"_backend"], "_backend",
doc: "Information about the last edit of this object." ],
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) { function move(src: string, target: string) {
if (tgs[src] === undefined) { if (tgs[src] === undefined) {
return; return
} }
tgs[target] = tgs[src] tgs[target] = tgs[src]
delete tgs[src] delete tgs[src]
@ -112,7 +130,7 @@ export default class SimpleMetaTaggers {
move("changeset", "_last_edit:changeset") move("changeset", "_last_edit:changeset")
move("timestamp", "_last_edit:timestamp") move("timestamp", "_last_edit:timestamp")
move("version", "_version_number") move("version", "_version_number")
return true; return true
} }
) )
public static country = new CountryTagger() 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`", 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, _) => { (feature, _) => {
const changed = feature.properties["_geometry:type"] === feature.geometry.type; const changed = feature.properties["_geometry:type"] === feature.geometry.type
feature.properties["_geometry:type"] = feature.geometry.type; feature.properties["_geometry:type"] = feature.geometry.type
return changed return changed
} }
) )
private static readonly cardinalDirections = { private static readonly cardinalDirections = {
N: 0, NNE: 22.5, NE: 45, ENE: 67.5, N: 0,
E: 90, ESE: 112.5, SE: 135, SSE: 157.5, NNE: 22.5,
S: 180, SSW: 202.5, SW: 225, WSW: 247.5, NE: 45,
W: 270, WNW: 292.5, NW: 315, NNW: 337.5 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"], 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 => { (feature) => {
const centerPoint = GeoOperations.centerpoint(feature); const centerPoint = GeoOperations.centerpoint(feature)
const lat = centerPoint.geometry.coordinates[1]; const lat = centerPoint.geometry.coordinates[1]
const lon = centerPoint.geometry.coordinates[0]; const lon = centerPoint.geometry.coordinates[0]
feature.properties["_lat"] = "" + lat; feature.properties["_lat"] = "" + lat
feature.properties["_lon"] = "" + lon; feature.properties["_lon"] = "" + lon
feature._lon = lon; // This is dirty, I know feature._lon = lon // This is dirty, I know
feature._lat = lat; feature._lat = lat
return true; return true
}) }
); )
private static layerInfo = new SimpleMetaTagger( 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.", 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) => { (feature, freshness, layer) => {
if (feature.properties._layer === layer.id) { if (feature.properties._layer === layer.id) {
return false; return false
} }
feature.properties._layer = layer.id feature.properties._layer = layer.id
return true; return true
} }
) )
private static noBothButLeftRight = new SimpleMetaTagger( 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", 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, includesDates: false,
cleanupRetagger: true cleanupRetagger: true,
}, },
((feature, state, layer) => { (feature, state, layer) => {
if (!layer.lineRendering.some((lr) => lr.leftRightSensitive)) {
if (!layer.lineRendering.some(lr => lr.leftRightSensitive)) { return
return;
} }
return SimpleMetaTaggers.removeBothTagging(feature.properties) return SimpleMetaTaggers.removeBothTagging(feature.properties)
}) }
) )
private static surfaceArea = new SimpleMetaTagger( private static surfaceArea = new SimpleMetaTagger(
{ {
keys: ["_surface", "_surface:ha"], keys: ["_surface", "_surface:ha"],
doc: "The surface area of the feature, in square meters and in hectare. Not set on points and ways", 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", { Object.defineProperty(feature.properties, "_surface", {
enumerable: false, enumerable: false,
configurable: true, configurable: true,
get: () => { get: () => {
const sqMeters = "" + GeoOperations.surfaceAreaInSqMeters(feature); const sqMeters = "" + GeoOperations.surfaceAreaInSqMeters(feature)
delete feature.properties["_surface"] delete feature.properties["_surface"]
feature.properties["_surface"] = sqMeters; feature.properties["_surface"] = sqMeters
return sqMeters return sqMeters
} },
}) })
Object.defineProperty(feature.properties, "_surface:ha", { Object.defineProperty(feature.properties, "_surface:ha", {
enumerable: false, enumerable: false,
configurable: true, configurable: true,
get: () => { get: () => {
const sqMeters = GeoOperations.surfaceAreaInSqMeters(feature); const sqMeters = GeoOperations.surfaceAreaInSqMeters(feature)
const sqMetersHa = "" + Math.floor(sqMeters / 1000) / 10; const sqMetersHa = "" + Math.floor(sqMeters / 1000) / 10
delete feature.properties["_surface:ha"] delete feature.properties["_surface:ha"]
feature.properties["_surface:ha"] = sqMetersHa; feature.properties["_surface:ha"] = sqMetersHa
return sqMetersHa return sqMetersHa
} },
}) })
return true; return true
}) }
); )
private static levels = new SimpleMetaTagger( private static levels = new SimpleMetaTagger(
{ {
doc: "Extract the 'level'-tag into a normalized, ';'-separated value", doc: "Extract the 'level'-tag into a normalized, ';'-separated value",
keys: ["_level"] keys: ["_level"],
}, },
((feature) => { (feature) => {
if (feature.properties["level"] === undefined) { if (feature.properties["level"] === undefined) {
return false; return false
} }
const l = feature.properties["level"] const l = feature.properties["level"]
const newValue = TagUtils.LevelsParser(l).join(";") const newValue = TagUtils.LevelsParser(l).join(";")
if (l === newValue) { if (l === newValue) {
return false; return false
} }
feature.properties["level"] = newValue feature.properties["level"] = newValue
return true return true
}
})
) )
private static canonicalize = new SimpleMetaTagger( 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)", 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"], keys: ["Theme-defined keys"],
}, },
((feature, _, __, state) => { (feature, _, __, state) => {
const units = Utils.NoNull([].concat(...state?.layoutToUse?.layers?.map(layer => layer.units) ?? [])); const units = Utils.NoNull(
[].concat(...(state?.layoutToUse?.layers?.map((layer) => layer.units) ?? []))
)
if (units.length == 0) { if (units.length == 0) {
return; return
} }
let rewritten = false; let rewritten = false
for (const key in feature.properties) { for (const key in feature.properties) {
if (!feature.properties.hasOwnProperty(key)) { if (!feature.properties.hasOwnProperty(key)) {
continue; continue
} }
for (const unit of units) { for (const unit of units) {
if (unit === undefined) { if (unit === undefined) {
@ -258,56 +292,59 @@ export default class SimpleMetaTaggers {
continue continue
} }
if (!unit.appliesToKeys.has(key)) { if (!unit.appliesToKeys.has(key)) {
continue; continue
} }
const value = feature.properties[key] const value = feature.properties[key]
const denom = unit.findDenomination(value, () => feature.properties["_country"]) const denom = unit.findDenomination(value, () => feature.properties["_country"])
if (denom === undefined) { if (denom === undefined) {
// no valid value found // no valid value found
break; break
} }
const [, denomination] = denom; const [, denomination] = denom
const defaultDenom = unit.getDefaultDenomination(() => feature.properties["_country"]) const defaultDenom = unit.getDefaultDenomination(
let canonical = denomination?.canonicalValue(value, defaultDenom == denomination) ?? undefined; () => feature.properties["_country"]
)
let canonical =
denomination?.canonicalValue(value, defaultDenom == denomination) ??
undefined
if (canonical === value) { if (canonical === value) {
break; break
} }
console.log("Rewritten ", key, ` from '${value}' into '${canonical}'`) console.log("Rewritten ", key, ` from '${value}' into '${canonical}'`)
if (canonical === undefined && !unit.eraseInvalid) { if (canonical === undefined && !unit.eraseInvalid) {
break; break
} }
feature.properties[key] = canonical; feature.properties[key] = canonical
rewritten = true; rewritten = true
break; break
} }
} }
return rewritten return rewritten
}) }
) )
private static lngth = new SimpleMetaTagger( private static lngth = new SimpleMetaTagger(
{ {
keys: ["_length", "_length:km"], 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) const l = GeoOperations.lengthInMeters(feature)
feature.properties["_length"] = "" + l feature.properties["_length"] = "" + l
const km = Math.floor(l / 1000) const km = Math.floor(l / 1000)
const kmRest = Math.round((l - km * 1000) / 100) const kmRest = Math.round((l - km * 1000) / 100)
feature.properties["_length:km"] = "" + km + "." + kmRest feature.properties["_length:km"] = "" + km + "." + kmRest
return true; return true
}) }
) )
private static isOpen = new SimpleMetaTagger( private static isOpen = new SimpleMetaTagger(
{ {
keys: ["_isOpen"], keys: ["_isOpen"],
doc: "If 'opening_hours' is present, it will add the current state of the feature (being 'yes' or 'no')", doc: "If 'opening_hours' is present, it will add the current state of the feature (being 'yes' or 'no')",
includesDates: true, includesDates: true,
isLazy: true isLazy: true,
}, },
((feature, _, __, state) => { (feature, _, __, state) => {
if (Utils.runningFromConsole) { if (Utils.runningFromConsole) {
// We are running from console, thus probably creating a cache // We are running from console, thus probably creating a cache
// isOpen is irrelevant // isOpen is irrelevant
@ -315,7 +352,7 @@ export default class SimpleMetaTaggers {
} }
if (feature.properties.opening_hours === "24/7") { if (feature.properties.opening_hours === "24/7") {
feature.properties._isOpen = "yes" feature.properties._isOpen = "yes"
return true; return true
} }
// _isOpen is calculated dynamically on every call // _isOpen is calculated dynamically on every call
@ -325,92 +362,92 @@ export default class SimpleMetaTaggers {
get: () => { get: () => {
const tags = feature.properties const tags = feature.properties
if (tags.opening_hours === undefined) { if (tags.opening_hours === undefined) {
return; return
} }
if (tags._country === undefined) { if (tags._country === undefined) {
return; return
} }
try { try {
const [lon, lat] = GeoOperations.centerpointCoordinates(feature) const [lon, lat] = GeoOperations.centerpointCoordinates(feature)
const oh = new opening_hours(tags["opening_hours"], { const oh = new opening_hours(
tags["opening_hours"],
{
lat: lat, lat: lat,
lon: lon, lon: lon,
address: { address: {
country_code: tags._country.toLowerCase(), country_code: tags._country.toLowerCase(),
state: undefined state: undefined,
} },
}, <any>{tag_key: "opening_hours"}); },
<any>{ tag_key: "opening_hours" }
)
// Recalculate! // Recalculate!
return oh.getState() ? "yes" : "no"; return oh.getState() ? "yes" : "no"
} catch (e) { } 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 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( private static directionSimplified = new SimpleMetaTagger(
{ {
keys: ["_direction:numerical", "_direction:leftright"], 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 => { (feature) => {
const tags = feature.properties; const tags = feature.properties
const direction = tags["camera:direction"] ?? tags["direction"]; const direction = tags["camera:direction"] ?? tags["direction"]
if (direction === undefined) { if (direction === undefined) {
return false; return false
} }
const n = SimpleMetaTaggers.cardinalDirections[direction] ?? Number(direction); const n = SimpleMetaTaggers.cardinalDirections[direction] ?? Number(direction)
if (isNaN(n)) { if (isNaN(n)) {
return false; return false
} }
// The % operator has range (-360, 360). We apply a trick to get [0, 360). // 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:numerical"] = normalized
tags["_direction:leftright"] = normalized <= 180 ? "right" : "left"; tags["_direction:leftright"] = normalized <= 180 ? "right" : "left"
return true; return true
}) }
) )
private static currentTime = new SimpleMetaTagger( private static currentTime = new SimpleMetaTagger(
{ {
keys: ["_now:date", "_now:datetime", "_loaded:date", "_loaded:_datetime"], 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", 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) => { (feature, freshness) => {
const now = new Date(); const now = new Date()
if (typeof freshness === "string") { if (typeof freshness === "string") {
freshness = new Date(freshness) freshness = new Date(freshness)
} }
function date(d: Date) { function date(d: Date) {
return d.toISOString().slice(0, 10); return d.toISOString().slice(0, 10)
} }
function datetime(d: Date) { 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:date"] = date(now)
feature.properties["_now:datetime"] = datetime(now); feature.properties["_now:datetime"] = datetime(now)
feature.properties["_loaded:date"] = date(freshness); feature.properties["_loaded:date"] = date(freshness)
feature.properties["_loaded:datetime"] = datetime(freshness); feature.properties["_loaded:datetime"] = datetime(freshness)
return true; return true
} }
); )
public static metatags: SimpleMetaTagger[] = [ public static metatags: SimpleMetaTagger[] = [
SimpleMetaTaggers.latlon, SimpleMetaTaggers.latlon,
SimpleMetaTaggers.layerInfo, SimpleMetaTaggers.layerInfo,
@ -424,11 +461,11 @@ export default class SimpleMetaTaggers {
SimpleMetaTaggers.objectMetaInfo, SimpleMetaTaggers.objectMetaInfo,
SimpleMetaTaggers.noBothButLeftRight, SimpleMetaTaggers.noBothButLeftRight,
SimpleMetaTaggers.geometryType, SimpleMetaTaggers.geometryType,
SimpleMetaTaggers.levels SimpleMetaTaggers.levels,
]
]; public static readonly lazyTags: string[] = [].concat(
public static readonly lazyTags: string[] = [].concat(...SimpleMetaTaggers.metatags.filter(tagger => tagger.isLazy) ...SimpleMetaTaggers.metatags.filter((tagger) => tagger.isLazy).map((tagger) => tagger.keys)
.map(tagger => tagger.keys)); )
/** /**
* Edits the given object to rewrite 'both'-tagging into a 'left-right' tagging scheme. * 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"]) { if (tags["sidewalk"]) {
const v = tags["sidewalk"] const v = tags["sidewalk"]
switch (v) { switch (v) {
case "none": case "none":
case "no": case "no":
set("sidewalk:left", "no"); set("sidewalk:left", "no")
set("sidewalk:right", "no"); set("sidewalk:right", "no")
break break
case "both": case "both":
set("sidewalk:left", "yes"); set("sidewalk:left", "yes")
set("sidewalk:right", "yes"); set("sidewalk:right", "yes")
break; break
case "left": case "left":
set("sidewalk:left", "yes"); set("sidewalk:left", "yes")
set("sidewalk:right", "no"); set("sidewalk:right", "no")
break; break
case "right": case "right":
set("sidewalk:left", "no"); set("sidewalk:left", "no")
set("sidewalk:right", "yes"); set("sidewalk:right", "yes")
break; break
default: default:
set("sidewalk:left", v); set("sidewalk:left", v)
set("sidewalk:right", v); set("sidewalk:right", v)
break; break
} }
delete tags["sidewalk"] delete tags["sidewalk"]
somethingChanged = true somethingChanged = true
} }
const regex = /\([^:]*\):both:\(.*\)/ const regex = /\([^:]*\):both:\(.*\)/
for (const key in tags) { for (const key in tags) {
const v = tags[key] const v = tags[key]
@ -503,7 +538,6 @@ export default class SimpleMetaTaggers {
} }
} }
return somethingChanged return somethingChanged
} }
@ -512,13 +546,16 @@ export default class SimpleMetaTaggers {
new Combine([ new Combine([
"Metatags are extra tags available, in order to display more data or to give better questions.", "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.", "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" "**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") ]).SetClass("flex-col"),
]
];
subElements.push(new Title("Metatags calculated by MapComplete", 2)) 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) { for (const metatag of SimpleMetaTaggers.metatags) {
subElements.push( subElements.push(
new Title(metatag.keys.join(", "), 3), new Title(metatag.keys.join(", "), 3),
@ -529,5 +566,4 @@ export default class SimpleMetaTaggers {
return new Combine(subElements).SetClass("flex-col") return new Combine(subElements).SetClass("flex-col")
} }
} }

View file

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

View file

@ -1,37 +1,39 @@
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import FeaturePipeline from "../FeatureSource/FeaturePipeline"; import FeaturePipeline from "../FeatureSource/FeaturePipeline"
import {Tiles} from "../../Models/TileRange"; import { Tiles } from "../../Models/TileRange"
import ShowDataLayer from "../../UI/ShowDataLayer/ShowDataLayer"; import ShowDataLayer from "../../UI/ShowDataLayer/ShowDataLayer"
import {TileHierarchyAggregator} from "../../UI/ShowDataLayer/TileHierarchyAggregator"; import { TileHierarchyAggregator } from "../../UI/ShowDataLayer/TileHierarchyAggregator"
import ShowTileInfo from "../../UI/ShowDataLayer/ShowTileInfo"; import ShowTileInfo from "../../UI/ShowDataLayer/ShowTileInfo"
import {UIEventSource} from "../UIEventSource"; import { UIEventSource } from "../UIEventSource"
import MapState from "./MapState"; import MapState from "./MapState"
import SelectedFeatureHandler from "../Actors/SelectedFeatureHandler"; import SelectedFeatureHandler from "../Actors/SelectedFeatureHandler"
import Hash from "../Web/Hash"; import Hash from "../Web/Hash"
import {BBox} from "../BBox"; import { BBox } from "../BBox"
import FeatureInfoBox from "../../UI/Popup/FeatureInfoBox"; import FeatureInfoBox from "../../UI/Popup/FeatureInfoBox"
import {FeatureSourceForLayer, Tiled} from "../FeatureSource/FeatureSource"; import { FeatureSourceForLayer, Tiled } from "../FeatureSource/FeatureSource"
import MetaTagRecalculator from "../FeatureSource/Actors/MetaTagRecalculator"; import MetaTagRecalculator from "../FeatureSource/Actors/MetaTagRecalculator"
import ScrollableFullScreen from "../../UI/Base/ScrollableFullScreen"; import ScrollableFullScreen from "../../UI/Base/ScrollableFullScreen"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"; import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
export default class FeaturePipelineState extends MapState { export default class FeaturePipelineState extends MapState {
/** /**
* The piece of code which fetches data from various sources and shows it on the background map * The piece of code which fetches data from various sources and shows it on the background map
*/ */
public readonly featurePipeline: FeaturePipeline; public readonly featurePipeline: FeaturePipeline
private readonly featureAggregator: TileHierarchyAggregator; private readonly featureAggregator: TileHierarchyAggregator
private readonly metatagRecalculator: MetaTagRecalculator 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) { constructor(layoutToUse: LayoutConfig) {
super(layoutToUse); super(layoutToUse)
const clustering = layoutToUse?.clustering const clustering = layoutToUse?.clustering
this.featureAggregator = TileHierarchyAggregator.createHierarchy(this); this.featureAggregator = TileHierarchyAggregator.createHierarchy(this)
const clusterCounter = this.featureAggregator const clusterCounter = this.featureAggregator
const self = this; const self = this
/** /**
* We are a bit in a bind: * We are a bit in a bind:
@ -52,25 +54,25 @@ export default class FeaturePipelineState extends MapState {
} }
} }
function registerSource(source: FeatureSourceForLayer & Tiled) { function registerSource(source: FeatureSourceForLayer & Tiled) {
clusterCounter.addTile(source) 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 // Do show features indicates if the respective 'showDataLayer' should be shown. It can be hidden by e.g. clustering
const doShowFeatures = source.features.map( const doShowFeatures = source.features.map(
f => { (f) => {
const z = self.locationControl.data.zoom const z = self.locationControl.data.zoom
if (!source.layer.isDisplayed.data) { if (!source.layer.isDisplayed.data) {
return false; return false
} }
const bounds = self.currentBounds.data const bounds = self.currentBounds.data
if (bounds === undefined) { if (bounds === undefined) {
// Map is not yet displayed // Map is not yet displayed
return false; return false
} }
if (!sourceBBox.data.overlapsWith(bounds)) { if (!sourceBBox.data.overlapsWith(bounds)) {
@ -78,10 +80,9 @@ export default class FeaturePipelineState extends MapState {
return false return false
} }
if (z < source.layer.layerDef.minzoom) { if (z < source.layer.layerDef.minzoom) {
// Layer is always hidden for this zoom level // Layer is always hidden for this zoom level
return false; return false
} }
if (z > clustering.maxZoom) { if (z > clustering.maxZoom) {
@ -93,50 +94,50 @@ export default class FeaturePipelineState extends MapState {
return false 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) { if (tileZ >= z) {
while (tileZ > z) { while (tileZ > z) {
tileZ-- tileZ--
tileX = Math.floor(tileX / 2) tileX = Math.floor(tileX / 2)
tileY = Math.floor(tileY / 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 // To much elements
return false return false
} }
} }
return true return true
}, [self.currentBounds, source.layer.isDisplayed, sourceBBox] },
[self.currentBounds, source.layer.isDisplayed, sourceBBox]
) )
new ShowDataLayer( new ShowDataLayer({
{
features: source, features: source,
leafletMap: self.leafletMap, leafletMap: self.leafletMap,
layerToShow: source.layer.layerDef, layerToShow: source.layer.layerDef,
doShowLayer: doShowFeatures, doShowLayer: doShowFeatures,
selectedElement: self.selectedElement, selectedElement: self.selectedElement,
state: self, state: self,
popup: (tags, layer) => self.CreatePopup(tags, layer) popup: (tags, layer) => self.CreatePopup(tags, layer),
} })
)
} }
this.featurePipeline = new FeaturePipeline(registerSource, this, {
this.featurePipeline = new FeaturePipeline(registerSource, this, {handleRawFeatureSource: registerRaw}); handleRawFeatureSource: registerRaw,
})
this.metatagRecalculator = new MetaTagRecalculator(this, this.featurePipeline) this.metatagRecalculator = new MetaTagRecalculator(this, this.featurePipeline)
this.metatagRecalculator.registerSource(this.currentView, true) 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) new SelectedFeatureHandler(Hash.hash, this)
this.AddClusteringToMap(this.leafletMap) this.AddClusteringToMap(this.leafletMap)
} }
public CreatePopup(tags: UIEventSource<any>, layer: LayerConfig): ScrollableFullScreen { public CreatePopup(tags: UIEventSource<any>, layer: LayerConfig): ScrollableFullScreen {
@ -155,15 +156,19 @@ export default class FeaturePipelineState extends MapState {
*/ */
public AddClusteringToMap(leafletMap: UIEventSource<any>) { public AddClusteringToMap(leafletMap: UIEventSource<any>) {
const clustering = this.layoutToUse.clustering const clustering = this.layoutToUse.clustering
const self = this; const self = this
new ShowDataLayer({ new ShowDataLayer({
features: this.featureAggregator.getCountsForZoom(clustering, this.locationControl, clustering.minNeededElements), features: this.featureAggregator.getCountsForZoom(
clustering,
this.locationControl,
clustering.minNeededElements
),
leafletMap: leafletMap, leafletMap: leafletMap,
layerToShow: ShowTileInfo.styling, layerToShow: ShowTileInfo.styling,
popup: this.featureSwitchIsDebugging.data ? (tags, layer) => new FeatureInfoBox(tags, layer, self) : undefined, popup: this.featureSwitchIsDebugging.data
state: this ? (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 * 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 LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import {UIEventSource} from "../UIEventSource"; import { UIEventSource } from "../UIEventSource"
import {QueryParameters} from "../Web/QueryParameters"; import { QueryParameters } from "../Web/QueryParameters"
import Constants from "../../Models/Constants"; import Constants from "../../Models/Constants"
import {Utils} from "../../Utils"; import { Utils } from "../../Utils"
export default class FeatureSwitchState { export default class FeatureSwitchState {
/** /**
* The layout that is being used in this run * The layout that is being used in this run
*/ */
public readonly layoutToUse: LayoutConfig; public readonly layoutToUse: LayoutConfig
public readonly featureSwitchUserbadge: UIEventSource<boolean>; public readonly featureSwitchUserbadge: UIEventSource<boolean>
public readonly featureSwitchSearch: UIEventSource<boolean>; public readonly featureSwitchSearch: UIEventSource<boolean>
public readonly featureSwitchBackgroundSelection: UIEventSource<boolean>; public readonly featureSwitchBackgroundSelection: UIEventSource<boolean>
public readonly featureSwitchAddNew: UIEventSource<boolean>; public readonly featureSwitchAddNew: UIEventSource<boolean>
public readonly featureSwitchWelcomeMessage: UIEventSource<boolean>; public readonly featureSwitchWelcomeMessage: UIEventSource<boolean>
public readonly featureSwitchExtraLinkEnabled: UIEventSource<boolean>; public readonly featureSwitchExtraLinkEnabled: UIEventSource<boolean>
public readonly featureSwitchMoreQuests: UIEventSource<boolean>; public readonly featureSwitchMoreQuests: UIEventSource<boolean>
public readonly featureSwitchShareScreen: UIEventSource<boolean>; public readonly featureSwitchShareScreen: UIEventSource<boolean>
public readonly featureSwitchGeolocation: UIEventSource<boolean>; public readonly featureSwitchGeolocation: UIEventSource<boolean>
public readonly featureSwitchIsTesting: UIEventSource<boolean>; public readonly featureSwitchIsTesting: UIEventSource<boolean>
public readonly featureSwitchIsDebugging: UIEventSource<boolean>; public readonly featureSwitchIsDebugging: UIEventSource<boolean>
public readonly featureSwitchShowAllQuestions: UIEventSource<boolean>; public readonly featureSwitchShowAllQuestions: UIEventSource<boolean>
public readonly featureSwitchApiURL: UIEventSource<string>; public readonly featureSwitchApiURL: UIEventSource<string>
public readonly featureSwitchFilter: UIEventSource<boolean>; public readonly featureSwitchFilter: UIEventSource<boolean>
public readonly featureSwitchEnableExport: UIEventSource<boolean>; public readonly featureSwitchEnableExport: UIEventSource<boolean>
public readonly featureSwitchFakeUser: UIEventSource<boolean>; public readonly featureSwitchFakeUser: UIEventSource<boolean>
public readonly featureSwitchExportAsPdf: UIEventSource<boolean>; public readonly featureSwitchExportAsPdf: UIEventSource<boolean>
public readonly overpassUrl: UIEventSource<string[]>; public readonly overpassUrl: UIEventSource<string[]>
public readonly overpassTimeout: UIEventSource<number>; public readonly overpassTimeout: UIEventSource<number>
public readonly overpassMaxZoom: UIEventSource<number>; public readonly overpassMaxZoom: UIEventSource<number>
public readonly osmApiTileSize: UIEventSource<number>; public readonly osmApiTileSize: UIEventSource<number>
public readonly backgroundLayerId: UIEventSource<string>; public readonly backgroundLayerId: UIEventSource<string>
public constructor(layoutToUse: LayoutConfig) { public constructor(layoutToUse: LayoutConfig) {
this.layoutToUse = layoutToUse; this.layoutToUse = layoutToUse
// Helper function to initialize feature switches // Helper function to initialize feature switches
function featSw( function featSw(
@ -47,104 +45,104 @@ export default class FeatureSwitchState {
deflt: (layout: LayoutConfig) => boolean, deflt: (layout: LayoutConfig) => boolean,
documentation: string documentation: string
): UIEventSource<boolean> { ): UIEventSource<boolean> {
const defaultValue = deflt(layoutToUse)
const defaultValue = deflt(layoutToUse);
const queryParam = QueryParameters.GetQueryParameter( const queryParam = QueryParameters.GetQueryParameter(
key, key,
"" + defaultValue, "" + defaultValue,
documentation 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( this.featureSwitchUserbadge = featSw(
"fs-userbadge", "fs-userbadge",
(layoutToUse) => layoutToUse?.enableUserBadge ?? true, (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." "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( this.featureSwitchSearch = featSw(
"fs-search", "fs-search",
(layoutToUse) => layoutToUse?.enableSearch ?? true, (layoutToUse) => layoutToUse?.enableSearch ?? true,
"Disables/Enables the search bar" "Disables/Enables the search bar"
); )
this.featureSwitchBackgroundSelection = featSw( this.featureSwitchBackgroundSelection = featSw(
"fs-background", "fs-background",
(layoutToUse) => layoutToUse?.enableBackgroundLayerSelection ?? true, (layoutToUse) => layoutToUse?.enableBackgroundLayerSelection ?? true,
"Disables/Enables the background layer control" "Disables/Enables the background layer control"
); )
this.featureSwitchFilter = featSw( this.featureSwitchFilter = featSw(
"fs-filter", "fs-filter",
(layoutToUse) => layoutToUse?.enableLayers ?? true, (layoutToUse) => layoutToUse?.enableLayers ?? true,
"Disables/Enables the filter view" "Disables/Enables the filter view"
); )
this.featureSwitchAddNew = featSw( this.featureSwitchAddNew = featSw(
"fs-add-new", "fs-add-new",
(layoutToUse) => layoutToUse?.enableAddNewPoints ?? true, (layoutToUse) => layoutToUse?.enableAddNewPoints ?? true,
"Disables/Enables the 'add new feature'-popup. (A theme without presets might not have it in the first place)" "Disables/Enables the 'add new feature'-popup. (A theme without presets might not have it in the first place)"
); )
this.featureSwitchWelcomeMessage = featSw( this.featureSwitchWelcomeMessage = featSw(
"fs-welcome-message", "fs-welcome-message",
() => true, () => true,
"Disables/enables the help menu or welcome message" "Disables/enables the help menu or welcome message"
); )
this.featureSwitchExtraLinkEnabled = featSw( this.featureSwitchExtraLinkEnabled = featSw(
"fs-iframe-popout", "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)" "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( this.featureSwitchMoreQuests = featSw(
"fs-more-quests", "fs-more-quests",
(layoutToUse) => layoutToUse?.enableMoreQuests ?? true, (layoutToUse) => layoutToUse?.enableMoreQuests ?? true,
"Disables/Enables the 'More Quests'-tab in the welcome message" "Disables/Enables the 'More Quests'-tab in the welcome message"
); )
this.featureSwitchShareScreen = featSw( this.featureSwitchShareScreen = featSw(
"fs-share-screen", "fs-share-screen",
(layoutToUse) => layoutToUse?.enableShareScreen ?? true, (layoutToUse) => layoutToUse?.enableShareScreen ?? true,
"Disables/Enables the 'Share-screen'-tab in the welcome message" "Disables/Enables the 'Share-screen'-tab in the welcome message"
); )
this.featureSwitchGeolocation = featSw( this.featureSwitchGeolocation = featSw(
"fs-geolocation", "fs-geolocation",
(layoutToUse) => layoutToUse?.enableGeolocation ?? true, (layoutToUse) => layoutToUse?.enableGeolocation ?? true,
"Disables/Enables the geolocation button" "Disables/Enables the geolocation button"
); )
this.featureSwitchShowAllQuestions = featSw( this.featureSwitchShowAllQuestions = featSw(
"fs-all-questions", "fs-all-questions",
(layoutToUse) => layoutToUse?.enableShowAllQuestions ?? false, (layoutToUse) => layoutToUse?.enableShowAllQuestions ?? false,
"Always show all questions" "Always show all questions"
); )
this.featureSwitchEnableExport = featSw( this.featureSwitchEnableExport = featSw(
"fs-export", "fs-export",
(layoutToUse) => layoutToUse?.enableExportButton ?? false, (layoutToUse) => layoutToUse?.enableExportButton ?? false,
"Enable the export as GeoJSON and CSV button" "Enable the export as GeoJSON and CSV button"
); )
this.featureSwitchExportAsPdf = featSw( this.featureSwitchExportAsPdf = featSw(
"fs-pdf", "fs-pdf",
(layoutToUse) => layoutToUse?.enablePdfDownload ?? false, (layoutToUse) => layoutToUse?.enablePdfDownload ?? false,
"Enable the PDF download button" "Enable the PDF download button"
); )
this.featureSwitchApiURL = QueryParameters.GetQueryParameter( this.featureSwitchApiURL = QueryParameters.GetQueryParameter(
"backend", "backend",
"osm", "osm",
"The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using 'osm-test'" "The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using 'osm-test'"
); )
let testingDefaultValue = false
let testingDefaultValue = false; if (
if (this.featureSwitchApiURL.data !== "osm-test" && !Utils.runningFromConsole && this.featureSwitchApiURL.data !== "osm-test" &&
(location.hostname === "localhost" || location.hostname === "127.0.0.1")) { !Utils.runningFromConsole &&
(location.hostname === "localhost" || location.hostname === "127.0.0.1")
) {
testingDefaultValue = true testingDefaultValue = true
} }
this.featureSwitchIsTesting = QueryParameters.GetBooleanQueryParameter( this.featureSwitchIsTesting = QueryParameters.GetBooleanQueryParameter(
"test", "test",
testingDefaultValue, 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" "If true, shows some extra debugging help such as all the available tags on every object"
) )
this.featureSwitchFakeUser = QueryParameters.GetBooleanQueryParameter("fake-user", false, this.featureSwitchFakeUser = QueryParameters.GetBooleanQueryParameter(
"If true, 'dryrun' mode is activated and a fake user account is loaded") "fake-user",
false,
"If true, 'dryrun' mode is activated and a fake user account is loaded"
)
this.overpassUrl = QueryParameters.GetQueryParameter(
this.overpassUrl = QueryParameters.GetQueryParameter("overpassUrl", "overpassUrl",
(layoutToUse?.overpassUrl ?? Constants.defaultOverpassUrls).join(","), (layoutToUse?.overpassUrl ?? Constants.defaultOverpassUrls).join(","),
"Point mapcomplete to a different overpass-instance. Example: https://overpass-api.de/api/interpreter" "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", this.overpassTimeout = UIEventSource.asFloat(
QueryParameters.GetQueryParameter(
"overpassTimeout",
"" + layoutToUse?.overpassTimeout, "" + layoutToUse?.overpassTimeout,
"Set a different timeout (in seconds) for queries in overpass")) "Set a different timeout (in seconds) for queries in overpass"
)
)
this.overpassMaxZoom = UIEventSource.asFloat(
this.overpassMaxZoom = QueryParameters.GetQueryParameter(
UIEventSource.asFloat(QueryParameters.GetQueryParameter("overpassMaxZoom", "overpassMaxZoom",
"" + layoutToUse?.overpassMaxZoom, "" + layoutToUse?.overpassMaxZoom,
" point to switch between OSM-api and overpass")) " point to switch between OSM-api and overpass"
)
)
this.osmApiTileSize = this.osmApiTileSize = UIEventSource.asFloat(
UIEventSource.asFloat(QueryParameters.GetQueryParameter("osmApiTileSize", QueryParameters.GetQueryParameter(
"osmApiTileSize",
"" + layoutToUse?.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) { if (!userbadge) {
this.featureSwitchAddNew.setData(false) this.featureSwitchAddNew.setData(false)
} }
@ -191,9 +205,6 @@ export default class FeatureSwitchState {
"background", "background",
layoutToUse?.defaultBackgroundId ?? "osm", layoutToUse?.defaultBackgroundId ?? "osm",
"The id of the background layer to start with" "The id of the background layer to start with"
); )
} }
} }

View file

@ -1,34 +1,33 @@
import UserRelatedState from "./UserRelatedState"; import UserRelatedState from "./UserRelatedState"
import {Store, Stores, UIEventSource} from "../UIEventSource"; import { Store, Stores, UIEventSource } from "../UIEventSource"
import BaseLayer from "../../Models/BaseLayer"; import BaseLayer from "../../Models/BaseLayer"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import AvailableBaseLayers from "../Actors/AvailableBaseLayers"; import AvailableBaseLayers from "../Actors/AvailableBaseLayers"
import Attribution from "../../UI/BigComponents/Attribution"; import Attribution from "../../UI/BigComponents/Attribution"
import Minimap, {MinimapObj} from "../../UI/Base/Minimap"; import Minimap, { MinimapObj } from "../../UI/Base/Minimap"
import {Tiles} from "../../Models/TileRange"; import { Tiles } from "../../Models/TileRange"
import BaseUIElement from "../../UI/BaseUIElement"; import BaseUIElement from "../../UI/BaseUIElement"
import FilteredLayer, {FilterState} from "../../Models/FilteredLayer"; import FilteredLayer, { FilterState } from "../../Models/FilteredLayer"
import TilesourceConfig from "../../Models/ThemeConfig/TilesourceConfig"; import TilesourceConfig from "../../Models/ThemeConfig/TilesourceConfig"
import {QueryParameters} from "../Web/QueryParameters"; import { QueryParameters } from "../Web/QueryParameters"
import ShowOverlayLayer from "../../UI/ShowDataLayer/ShowOverlayLayer"; import ShowOverlayLayer from "../../UI/ShowDataLayer/ShowOverlayLayer"
import {FeatureSourceForLayer, Tiled} from "../FeatureSource/FeatureSource"; import { FeatureSourceForLayer, Tiled } from "../FeatureSource/FeatureSource"
import SimpleFeatureSource from "../FeatureSource/Sources/SimpleFeatureSource"; import SimpleFeatureSource from "../FeatureSource/Sources/SimpleFeatureSource"
import {LocalStorageSource} from "../Web/LocalStorageSource"; import { LocalStorageSource } from "../Web/LocalStorageSource"
import {GeoOperations} from "../GeoOperations"; import { GeoOperations } from "../GeoOperations"
import TitleHandler from "../Actors/TitleHandler"; import TitleHandler from "../Actors/TitleHandler"
import {BBox} from "../BBox"; import { BBox } from "../BBox"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"; import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import {TiledStaticFeatureSource} from "../FeatureSource/Sources/StaticFeatureSource"; import { TiledStaticFeatureSource } from "../FeatureSource/Sources/StaticFeatureSource"
import {Translation, TypedTranslation} from "../../UI/i18n/Translation"; import { Translation, TypedTranslation } from "../../UI/i18n/Translation"
import {Tag} from "../Tags/Tag"; import { Tag } from "../Tags/Tag"
import {OsmConnection} from "../Osm/OsmConnection"; import { OsmConnection } from "../Osm/OsmConnection"
export interface GlobalFilter { export interface GlobalFilter {
filter: FilterState, filter: FilterState
id: string, id: string
onNewPoint: { onNewPoint: {
safetyCheck: Translation, safetyCheck: Translation
confirmAddNew: TypedTranslation<{ preset: Translation }> confirmAddNew: TypedTranslation<{ preset: Translation }>
tags: Tag[] tags: Tag[]
} }
@ -38,60 +37,64 @@ export interface GlobalFilter {
* Contains all the leaflet-map related state * Contains all the leaflet-map related state
*/ */
export default class MapState extends UserRelatedState { export default class MapState extends UserRelatedState {
/** /**
The leaflet instance of the big basemap 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 * A list of currently available background layers
*/ */
public availableBackgroundLayers: Store<BaseLayer[]>; public availableBackgroundLayers: Store<BaseLayer[]>
/** /**
* The current background layer * The current background layer
*/ */
public backgroundLayer: UIEventSource<BaseLayer>; public backgroundLayer: UIEventSource<BaseLayer>
/** /**
* Last location where a click was registered * Last location where a click was registered
*/ */
public readonly LastClickLocation: UIEventSource<{ public readonly LastClickLocation: UIEventSource<{
lat: number; lat: number
lon: number; lon: number
}> = new UIEventSource<{ lat: number; lon: number }>(undefined); }> = new UIEventSource<{ lat: number; lon: number }>(undefined)
/** /**
* The bounds of the current map view * The bounds of the current map view
*/ */
public currentView: FeatureSourceForLayer & Tiled; public currentView: FeatureSourceForLayer & Tiled
/** /**
* The location as delivered by the GPS * The location as delivered by the GPS
*/ */
public currentUserLocation: SimpleFeatureSource; public currentUserLocation: SimpleFeatureSource
/** /**
* All previously visited points * All previously visited points
*/ */
public historicalUserLocations: SimpleFeatureSource; public historicalUserLocations: SimpleFeatureSource
/** /**
* The number of seconds that the GPS-locations are stored in memory. * The number of seconds that the GPS-locations are stored in memory.
* Time in seconds * Time in seconds
*/ */
public gpsLocationHistoryRetentionTime = new UIEventSource(7 * 24 * 60 * 60, "gps_location_retention") public gpsLocationHistoryRetentionTime = new UIEventSource(
public historicalUserLocationsTrack: FeatureSourceForLayer & Tiled; 7 * 24 * 60 * 60,
"gps_location_retention"
)
public historicalUserLocationsTrack: FeatureSourceForLayer & Tiled
/** /**
* A feature source containing the current home location of the user * A feature source containing the current home location of the user
*/ */
public homeLocation: FeatureSourceForLayer & Tiled 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 * 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 * Filters which apply onto all layers
@ -101,31 +104,30 @@ export default class MapState extends UserRelatedState {
/** /**
* Which overlays are shown * 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 }) { 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 let defaultLayer = AvailableBaseLayers.osmCarto
const available = this.availableBackgroundLayers.data; const available = this.availableBackgroundLayers.data
for (const layer of available) { for (const layer of available) {
if (this.backgroundLayerId.data === layer.id) { if (this.backgroundLayerId.data === layer.id) {
defaultLayer = layer; defaultLayer = layer
} }
} }
const self = this const self = this
this.backgroundLayer = new UIEventSource<BaseLayer>(defaultLayer) 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( const attr = new Attribution(
this.locationControl, this.locationControl,
this.osmConnection.userDetails, this.osmConnection.userDetails,
this.layoutToUse, this.layoutToUse,
this.currentBounds this.currentBounds
); )
// Will write into this.leafletMap // Will write into this.leafletMap
this.mainMapObject = Minimap.createMiniMap({ this.mainMapObject = Minimap.createMiniMap({
@ -134,18 +136,23 @@ export default class MapState extends UserRelatedState {
leafletMap: this.leafletMap, leafletMap: this.leafletMap,
bounds: this.currentBounds, bounds: this.currentBounds,
attribution: attr, attribution: attr,
lastClickLocation: this.LastClickLocation lastClickLocation: this.LastClickLocation,
}) })
this.overlayToggles =
this.overlayToggles = this.layoutToUse?.tileLayerSources this.layoutToUse?.tileLayerSources
?.filter(c => c.name !== undefined) ?.filter((c) => c.name !== undefined)
?.map(c => ({ ?.map((c) => ({
config: c, config: c,
isDisplayed: QueryParameters.GetBooleanQueryParameter("overlay-" + c.id, c.defaultState, "Wether or not the overlay " + c.id + " is shown") 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.filteredLayers = new UIEventSource<FilteredLayer[]>(
MapState.InitializeFilteredLayers(this.layoutToUse, this.osmConnection)
)
this.lockBounds() this.lockBounds()
this.AddAllOverlaysToMap(this.leafletMap) this.AddAllOverlaysToMap(this.leafletMap)
@ -155,7 +162,7 @@ export default class MapState extends UserRelatedState {
this.initUserLocationTrail() this.initUserLocationTrail()
this.initCurrentView() this.initCurrentView()
new TitleHandler(this); new TitleHandler(this)
} }
public AddAllOverlaysToMap(leafletMap: UIEventSource<any>) { public AddAllOverlaysToMap(leafletMap: UIEventSource<any>) {
@ -171,15 +178,14 @@ export default class MapState extends UserRelatedState {
} }
new ShowOverlayLayer(tileLayerSource, leafletMap) new ShowOverlayLayer(tileLayerSource, leafletMap)
} }
} }
private lockBounds() { private lockBounds() {
const layout = this.layoutToUse; const layout = this.layoutToUse
if (!layout?.lockLocation) { if (!layout?.lockLocation) {
return; return
} }
console.warn("Locking the bounds to ", layout.lockLocation); console.warn("Locking the bounds to ", layout.lockLocation)
this.mainMapObject.installBounds( this.mainMapObject.installBounds(
new BBox(layout.lockLocation), new BBox(layout.lockLocation),
this.featureSwitchIsTesting.data this.featureSwitchIsTesting.data
@ -187,17 +193,19 @@ export default class MapState extends UserRelatedState {
} }
private initCurrentView() { 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) { if (currentViewLayer === undefined) {
// This layer is not needed by the theme and thus unloaded // This layer is not needed by the theme and thus unloaded
return; return
} }
let i = 0 let i = 0
const self = this; const self = this
const features: Store<{ feature: any, freshness: Date }[]> = this.currentBounds.map(bounds => { const features: Store<{ feature: any; freshness: Date }[]> = this.currentBounds.map(
(bounds) => {
if (bounds === undefined) { if (bounds === undefined) {
return [] return []
} }
@ -208,48 +216,59 @@ export default class MapState extends UserRelatedState {
type: "Feature", type: "Feature",
properties: { properties: {
id: "current_view-" + i, id: "current_view-" + i,
"current_view": "yes", current_view: "yes",
"zoom": "" + self.locationControl.data.zoom zoom: "" + self.locationControl.data.zoom,
}, },
geometry: { geometry: {
type: "Polygon", type: "Polygon",
coordinates: [[ coordinates: [
[
[bounds.maxLon, bounds.maxLat], [bounds.maxLon, bounds.maxLat],
[bounds.minLon, bounds.maxLat], [bounds.minLon, bounds.maxLat],
[bounds.minLon, bounds.minLat], [bounds.minLon, bounds.minLat],
[bounds.maxLon, bounds.minLat], [bounds.maxLon, bounds.minLat],
[bounds.maxLon, bounds.maxLat], [bounds.maxLon, bounds.maxLat],
]] ],
} ],
} },
},
} }
return [feature] return [feature]
}) }
)
this.currentView = new TiledStaticFeatureSource(features, currentViewLayer); this.currentView = new TiledStaticFeatureSource(features, currentViewLayer)
} }
private initGpsLocation() { private initGpsLocation() {
// Initialize the gps layer data. This is emtpy for now, the actual writing happens in the Geolocationhandler // 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) { if (gpsLayerDef === undefined) {
return 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() { 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() const now = new Date().getTime()
features.data = features.data features.data = features.data
.map(ff => ({feature: ff.feature, freshness: new Date(ff.freshness)})) .map((ff) => ({ feature: ff.feature, freshness: new Date(ff.freshness) }))
.filter(ff => (now - ff.freshness.getTime()) < 1000 * this.gpsLocationHistoryRetentionTime.data) .filter(
(ff) =>
now - ff.freshness.getTime() < 1000 * this.gpsLocationHistoryRetentionTime.data
)
features.ping() features.ping()
const self = this; const self = this
let i = 0 let i = 0
this.currentUserLocation?.features?.addCallbackAndRunD(([location]) => { this.currentUserLocation?.features?.addCallbackAndRunD(([location]) => {
if (location === undefined) { if (location === undefined) {
return; return
} }
const previousLocation = features.data[features.data.length - 1] const previousLocation = features.data[features.data.length - 1]
@ -261,11 +280,14 @@ export default class MapState extends UserRelatedState {
let timeDiff = Number.MAX_VALUE // in seconds let timeDiff = Number.MAX_VALUE // in seconds
const olderLocation = features.data[features.data.length - 2] const olderLocation = features.data[features.data.length - 2]
if (olderLocation !== undefined) { 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) { if (d < 20 && timeDiff < 60) {
// Do not append changes less then 20m - it's probably noise anyway // Do not append changes less then 20m - it's probably noise anyway
return; return
} }
} }
@ -276,15 +298,19 @@ export default class MapState extends UserRelatedState {
features.ping() features.ping()
}) })
let gpsLayerDef: FilteredLayer = this.filteredLayers.data.filter(
let gpsLayerDef: FilteredLayer = this.filteredLayers.data.filter(l => l.layerDef.id === "gps_location_history")[0] (l) => l.layerDef.id === "gps_location_history"
)[0]
if (gpsLayerDef !== undefined) { 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) this.changes.setHistoricalUserLocations(this.historicalUserLocations)
} }
const asLine = features.map((allPoints) => {
const asLine = features.map(allPoints => {
if (allPoints === undefined || allPoints.length < 2) { if (allPoints === undefined || allPoints.length < 2) {
return [] return []
} }
@ -292,136 +318,184 @@ export default class MapState extends UserRelatedState {
const feature = { const feature = {
type: "Feature", type: "Feature",
properties: { properties: {
"id": "location_track", id: "location_track",
"_date:now": new Date().toISOString(), "_date:now": new Date().toISOString(),
}, },
geometry: { geometry: {
type: "LineString", 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) self.allElements.ContainingFeatures.set(feature.properties.id, feature)
return [{ return [
{
feature, feature,
freshness: new Date() 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) { if (gpsLineLayerDef !== undefined) {
this.historicalUserLocationsTrack = new TiledStaticFeatureSource(asLine, gpsLineLayerDef); this.historicalUserLocationsTrack = new TiledStaticFeatureSource(
asLine,
gpsLineLayerDef
)
} }
} }
private initHomeLocation() { private initHomeLocation() {
const empty = [] const empty = []
const feature = Stores.ListStabilized(this.osmConnection.userDetails.map(userDetails => { const feature = Stores.ListStabilized(
this.osmConnection.userDetails.map((userDetails) => {
if (userDetails === undefined) { if (userDetails === undefined) {
return undefined; return undefined
} }
const home = userDetails.home; const home = userDetails.home
if (home === undefined) { if (home === undefined) {
return undefined; return undefined
} }
return [home.lon, home.lat] return [home.lon, home.lat]
})).map(homeLonLat => { })
).map((homeLonLat) => {
if (homeLonLat === undefined) { if (homeLonLat === undefined) {
return empty return empty
} }
return [{ return [
{
feature: { feature: {
"type": "Feature", type: "Feature",
"properties": { properties: {
"id": "home", id: "home",
"user:home": "yes", "user:home": "yes",
"_lon": homeLonLat[0], _lon: homeLonLat[0],
"_lat": homeLonLat[1] _lat: homeLonLat[1],
}, },
"geometry": { geometry: {
"type": "Point", type: "Point",
"coordinates": homeLonLat 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) { if (flayer !== undefined) {
this.homeLocation = new TiledStaticFeatureSource(feature, flayer) this.homeLocation = new TiledStaticFeatureSource(feature, flayer)
} }
} }
private static getPref(osmConnection: OsmConnection, key: string, layer: LayerConfig): UIEventSource<boolean> { private static getPref(
return osmConnection osmConnection: OsmConnection,
.GetPreference(key, layer.shownByDefault + "") key: string,
.sync(v => { layer: LayerConfig
): UIEventSource<boolean> {
return osmConnection.GetPreference(key, layer.shownByDefault + "").sync(
(v) => {
if (v === undefined) { if (v === undefined) {
return undefined return undefined
} }
return v === "true"; return v === "true"
}, [], b => { },
[],
(b) => {
if (b === undefined) { if (b === undefined) {
return 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) { if (layoutToUse === undefined) {
return [] return []
} }
const flayers: FilteredLayer[] = []; const flayers: FilteredLayer[] = []
for (const layer of layoutToUse.layers) { for (const layer of layoutToUse.layers) {
let isDisplayed: UIEventSource<boolean> let isDisplayed: UIEventSource<boolean>
if (layer.syncSelection === "local") { 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") { } 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") { } else if (layer.syncSelection === "global") {
isDisplayed = MapState.getPref(osmConnection,"layer-" + layer.id + "-enabled", layer) isDisplayed = MapState.getPref(
osmConnection,
"layer-" + layer.id + "-enabled",
layer
)
} else { } 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 = { const flayer: FilteredLayer = {
isDisplayed, isDisplayed,
layerDef: layer, layerDef: layer,
appliedFilters: new UIEventSource<Map<string, FilterState>>(new Map<string, FilterState>()) appliedFilters: new UIEventSource<Map<string, FilterState>>(
}; new Map<string, FilterState>()
layer.filters.forEach(filterConfig => { ),
}
layer.filters.forEach((filterConfig) => {
const stateSrc = filterConfig.initState() const stateSrc = filterConfig.initState()
stateSrc.addCallbackAndRun(state => flayer.appliedFilters.data.set(filterConfig.id, state)) stateSrc.addCallbackAndRun((state) =>
flayer.appliedFilters.map(dict => dict.get(filterConfig.id)) flayer.appliedFilters.data.set(filterConfig.id, state)
.addCallback(state => stateSrc.setData(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) { for (const layer of layoutToUse.layers) {
if (layer.filterIsSameAs === undefined) { if (layer.filterIsSameAs === undefined) {
continue continue
} }
const toReuse = flayers.find(l => l.layerDef.id === layer.filterIsSameAs) const toReuse = flayers.find((l) => l.layerDef.id === layer.filterIsSameAs)
if (toReuse === undefined) { 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) console.warn(
const selfLayer = flayers.findIndex(l => l.layerDef.id === layer.id) "Linking filter and isDisplayed-states of " +
layer.id +
" and " +
layer.filterIsSameAs
)
const selfLayer = flayers.findIndex((l) => l.layerDef.id === layer.id)
flayers[selfLayer] = { flayers[selfLayer] = {
isDisplayed: toReuse.isDisplayed, isDisplayed: toReuse.isDisplayed,
layerDef: layer, 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 LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import {OsmConnection} from "../Osm/OsmConnection"; import { OsmConnection } from "../Osm/OsmConnection"
import {MangroveIdentity} from "../Web/MangroveReviews"; import { MangroveIdentity } from "../Web/MangroveReviews"
import {Store, UIEventSource} from "../UIEventSource"; import { Store, UIEventSource } from "../UIEventSource"
import {QueryParameters} from "../Web/QueryParameters"; import { QueryParameters } from "../Web/QueryParameters"
import {LocalStorageSource} from "../Web/LocalStorageSource"; import { LocalStorageSource } from "../Web/LocalStorageSource"
import {Utils} from "../../Utils"; import { Utils } from "../../Utils"
import Locale from "../../UI/i18n/Locale"; import Locale from "../../UI/i18n/Locale"
import ElementsState from "./ElementsState"; import ElementsState from "./ElementsState"
import SelectedElementTagsUpdater from "../Actors/SelectedElementTagsUpdater"; import SelectedElementTagsUpdater from "../Actors/SelectedElementTagsUpdater"
import {Changes} from "../Osm/Changes"; import { Changes } from "../Osm/Changes"
import ChangeToElementsActor from "../Actors/ChangeToElementsActor"; import ChangeToElementsActor from "../Actors/ChangeToElementsActor"
import PendingChangesUploader from "../Actors/PendingChangesUploader"; import PendingChangesUploader from "../Actors/PendingChangesUploader"
import * as translators from "../../assets/translators.json" 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, * The part of the state which keeps track of user-related stuff, e.g. the OSM-connection,
* which layers they enabled, ... * which layers they enabled, ...
*/ */
export default class UserRelatedState extends ElementsState { export default class UserRelatedState extends ElementsState {
/** /**
The user credentials The user credentials
*/ */
public osmConnection: OsmConnection; public osmConnection: OsmConnection
/** /**
THe change handler THe change handler
*/ */
public changes: Changes; public changes: Changes
/** /**
* The key for mangrove * The key for mangrove
*/ */
public mangroveIdentity: MangroveIdentity; public mangroveIdentity: MangroveIdentity
/** /**
* Maproulette connection * Maproulette connection
*/ */
public maprouletteConnection: Maproulette; public maprouletteConnection: Maproulette
public readonly isTranslator : Store<boolean>; public readonly isTranslator: Store<boolean>
public readonly installedUserThemes: Store<string[]> public readonly installedUserThemes: Store<string[]>
constructor(layoutToUse: LayoutConfig, options?: { attemptLogin: true | boolean }) { constructor(layoutToUse: LayoutConfig, options?: { attemptLogin: true | boolean }) {
super(layoutToUse); super(layoutToUse)
this.osmConnection = new OsmConnection({ this.osmConnection = new OsmConnection({
dryRun: this.featureSwitchIsTesting, dryRun: this.featureSwitchIsTesting,
@ -55,78 +52,82 @@ export default class UserRelatedState extends ElementsState {
undefined, undefined,
"Used to complete the login" "Used to complete the login"
), ),
osmConfiguration: <'osm' | 'osm-test'>this.featureSwitchApiURL.data, osmConfiguration: <"osm" | "osm-test">this.featureSwitchApiURL.data,
attemptLogin: options?.attemptLogin 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) translationMode.syncWith(Locale.showLinkToWeblate)
this.isTranslator = this.osmConnection.userDetails.map(ud => { this.isTranslator = this.osmConnection.userDetails.map((ud) => {
if (!ud.loggedIn) { if (!ud.loggedIn) {
return false; return false
} }
const name= ud.name.toLowerCase().replace(/\s+/g, '') const name = ud.name.toLowerCase().replace(/\s+/g, "")
return translators.contributors.some(c => c.contributor.toLowerCase().replace(/\s+/g, '') === name) return translators.contributors.some(
(c) => c.contributor.toLowerCase().replace(/\s+/g, "") === name
)
}) })
this.isTranslator.addCallbackAndRunD(ud => { this.isTranslator.addCallbackAndRunD((ud) => {
if (ud) { if (ud) {
Locale.showLinkToWeblate.setData(true) Locale.showLinkToWeblate.setData(true)
} }
}); })
this.changes = new Changes(this, layoutToUse?.isLeftRightSensitive() ?? false) this.changes = new Changes(this, layoutToUse?.isLeftRightSensitive() ?? false)
new ChangeToElementsActor(this.changes, this.allElements) new ChangeToElementsActor(this.changes, this.allElements)
new PendingChangesUploader(this.changes, this.selectedElement); new PendingChangesUploader(this.changes, this.selectedElement)
this.mangroveIdentity = new MangroveIdentity( this.mangroveIdentity = new MangroveIdentity(
this.osmConnection.GetLongPreference("identity", "mangrove") this.osmConnection.GetLongPreference("identity", "mangrove")
); )
this.maprouletteConnection = new Maproulette(); this.maprouletteConnection = new Maproulette()
if (layoutToUse?.hideFromOverview) { if (layoutToUse?.hideFromOverview) {
this.osmConnection.isLoggedIn.addCallbackAndRunD(loggedIn => { this.osmConnection.isLoggedIn.addCallbackAndRunD((loggedIn) => {
if (loggedIn) { if (loggedIn) {
this.osmConnection this.osmConnection
.GetPreference("hidden-theme-" + layoutToUse?.id + "-enabled") .GetPreference("hidden-theme-" + layoutToUse?.id + "-enabled")
.setData("true"); .setData("true")
return true; return true
} }
}) })
} }
if (this.layoutToUse !== undefined && !this.layoutToUse.official) { if (this.layoutToUse !== undefined && !this.layoutToUse.official) {
console.log("Marking unofficial theme as visited") console.log("Marking unofficial theme as visited")
this.osmConnection.GetLongPreference("unofficial-theme-" + this.layoutToUse.id) this.osmConnection.GetLongPreference("unofficial-theme-" + this.layoutToUse.id).setData(
.setData(JSON.stringify({ JSON.stringify({
id: this.layoutToUse.id, id: this.layoutToUse.id,
icon: this.layoutToUse.icon, icon: this.layoutToUse.icon,
title: this.layoutToUse.title.translations, title: this.layoutToUse.title.translations,
shortDescription: this.layoutToUse.shortDescription.translations, shortDescription: this.layoutToUse.shortDescription.translations,
definition: this.layoutToUse["definition"] definition: this.layoutToUse["definition"],
})) })
)
} }
this.InitializeLanguage(); this.InitializeLanguage()
new SelectedElementTagsUpdater(this) new SelectedElementTagsUpdater(this)
this.installedUserThemes = this.InitInstalledUserThemes(); this.installedUserThemes = this.InitInstalledUserThemes()
} }
private InitializeLanguage() { private InitializeLanguage() {
const layoutToUse = this.layoutToUse; const layoutToUse = this.layoutToUse
Locale.language.syncWith(this.osmConnection.GetPreference("language")); Locale.language.syncWith(this.osmConnection.GetPreference("language"))
Locale.language Locale.language.addCallback((currentLanguage) => {
.addCallback((currentLanguage) => {
if (layoutToUse === undefined) { if (layoutToUse === undefined) {
return; return
} }
if (Locale.showLinkToWeblate.data) { if (Locale.showLinkToWeblate.data) {
return true; // Disable auto switching as we are in translators mode return true // Disable auto switching as we are in translators mode
} }
if (this.layoutToUse.language.indexOf(currentLanguage) < 0) { if (this.layoutToUse.language.indexOf(currentLanguage) < 0) {
console.log( console.log(
@ -135,32 +136,34 @@ export default class UserRelatedState extends ElementsState {
"as", "as",
currentLanguage, currentLanguage,
" is unsupported" " is unsupported"
); )
// The current language is not supported -> switch to a supported one // The current language is not supported -> switch to a supported one
Locale.language.setData(layoutToUse.language[0]); Locale.language.setData(layoutToUse.language[0])
} }
}) })
Locale.language.ping(); Locale.language.ping()
} }
private InitInstalledUserThemes(): Store<string[]> { private InitInstalledUserThemes(): Store<string[]> {
const prefix = "mapcomplete-unofficial-theme-"; const prefix = "mapcomplete-unofficial-theme-"
const postfix = "-combined-length" const postfix = "-combined-length"
return this.osmConnection.preferencesHandler.preferences.map(prefs => return this.osmConnection.preferencesHandler.preferences.map((prefs) =>
Object.keys(prefs) Object.keys(prefs)
.filter(k => k.startsWith(prefix) && k.endsWith(postfix)) .filter((k) => k.startsWith(prefix) && k.endsWith(postfix))
.map(k => k.substring(prefix.length, k.length - postfix.length)) .map((k) => k.substring(prefix.length, k.length - postfix.length))
) )
} }
public GetUnofficialTheme(id: string): { public GetUnofficialTheme(id: string):
| {
id: string id: string
icon: string, icon: string
title: any, title: any
shortDescription: any, shortDescription: any
definition?: any, definition?: any
isOfficial: boolean isOfficial: boolean
} | undefined { }
| undefined {
console.log("GETTING UNOFFICIAL THEME") 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 const str = pref.data
@ -173,20 +176,23 @@ export default class UserRelatedState extends ElementsState {
try { try {
const value: { const value: {
id: string id: string
icon: string, icon: string
title: any, title: any
shortDescription: any, shortDescription: any
definition?: any, definition?: any
isOfficial: boolean isOfficial: boolean
} = JSON.parse(str) } = JSON.parse(str)
value.isOfficial = false value.isOfficial = false
return value; return value
} catch (e) { } 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) pref.setData(null)
return undefined return undefined
} }
} }
} }

View file

@ -1,15 +1,14 @@
import {TagsFilter} from "./TagsFilter"; import { TagsFilter } from "./TagsFilter"
import {Or} from "./Or"; import { Or } from "./Or"
import {TagUtils} from "./TagUtils"; import { TagUtils } from "./TagUtils"
import {Tag} from "./Tag"; import { Tag } from "./Tag"
import {RegexTag} from "./RegexTag"; import { RegexTag } from "./RegexTag"
export class And extends TagsFilter { export class And extends TagsFilter {
public and: TagsFilter[] public and: TagsFilter[]
constructor(and: TagsFilter[]) { constructor(and: TagsFilter[]) {
super(); super()
this.and = and this.and = and
} }
@ -21,11 +20,11 @@ export class And extends TagsFilter {
} }
private static combine(filter: string, choices: string[]): string[] { private static combine(filter: string, choices: string[]): string[] {
const values = []; const values = []
for (const or of choices) { for (const or of choices) {
values.push(filter + or); values.push(filter + or)
} }
return values; return values
} }
normalize() { normalize() {
@ -43,11 +42,11 @@ export class And extends TagsFilter {
matchesProperties(tags: any): boolean { matchesProperties(tags: any): boolean {
for (const tagsFilter of this.and) { for (const tagsFilter of this.and) {
if (!tagsFilter.matchesProperties(tags)) { 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\"]" ] * and.asOverpass() // => [ "[\"boundary\"=\"protected_area\"][\"protect_class\"!=\"98\"]" ]
*/ */
asOverpass(): string[] { asOverpass(): string[] {
let allChoices: string[] = null; let allChoices: string[] = null
for (const andElement of this.and) { for (const andElement of this.and) {
const andElementFilter = andElement.asOverpass(); const andElementFilter = andElement.asOverpass()
if (allChoices === null) { if (allChoices === null) {
allChoices = andElementFilter; allChoices = andElementFilter
continue; continue
} }
const newChoices: string[] = []; const newChoices: string[] = []
for (const choice of allChoices) { for (const choice of allChoices) {
newChoices.push( newChoices.push(...And.combine(choice, andElementFilter))
...And.combine(choice, andElementFilter)
)
} }
allChoices = newChoices; allChoices = newChoices
} }
return allChoices; return allChoices
} }
asHumanString(linkToWiki: boolean, shorten: boolean, properties) { 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 { isUsableAsAnswer(): boolean {
for (const t of this.and) { for (const t of this.and) {
if (!t.isUsableAsAnswer()) { if (!t.isUsableAsAnswer()) {
return false; return false
} }
} }
return true; return true
} }
/** /**
@ -107,45 +107,44 @@ export class And extends TagsFilter {
*/ */
shadows(other: TagsFilter): boolean { shadows(other: TagsFilter): boolean {
if (!(other instanceof And)) { if (!(other instanceof And)) {
return false; return false
} }
for (const selfTag of this.and) { for (const selfTag of this.and) {
let matchFound = false; let matchFound = false
for (const otherTag of other.and) { for (const otherTag of other.and) {
matchFound = selfTag.shadows(otherTag); matchFound = selfTag.shadows(otherTag)
if (matchFound) { if (matchFound) {
break; break
} }
} }
if (!matchFound) { if (!matchFound) {
return false; return false
} }
} }
for (const otherTag of other.and) { for (const otherTag of other.and) {
let matchFound = false; let matchFound = false
for (const selfTag of this.and) { for (const selfTag of this.and) {
matchFound = selfTag.shadows(otherTag); matchFound = selfTag.shadows(otherTag)
if (matchFound) { if (matchFound) {
break; break
} }
} }
if (!matchFound) { if (!matchFound) {
return false; return false
} }
} }
return true
return true;
} }
usedKeys(): string[] { usedKeys(): string[] {
return [].concat(...this.and.map(subkeys => subkeys.usedKeys())); return [].concat(...this.and.map((subkeys) => subkeys.usedKeys()))
} }
usedTags(): { key: string; value: string }[] { 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 }[] { asChange(properties: any): { k: string; v: string }[] {
@ -153,7 +152,7 @@ export class And extends TagsFilter {
for (const tagsFilter of this.and) { for (const tagsFilter of this.and) {
result.push(...tagsFilter.asChange(properties)) result.push(...tagsFilter.asChange(properties))
} }
return result; return result
} }
/** /**
@ -187,7 +186,7 @@ export class And extends TagsFilter {
continue continue
} }
if (r === false) { if (r === false) {
return false; return false
} }
newAnds.push(r) newAnds.push(r)
continue continue
@ -203,7 +202,6 @@ export class And extends TagsFilter {
continue continue
} }
if (!value && tag.shadows(knownExpression)) { if (!value && tag.shadows(knownExpression)) {
/** /**
* We know that knownExpression is unmet. * We know that knownExpression is unmet.
* if the tag shadows 'knownExpression' (which is the case when control flows gets here), * if the tag shadows 'knownExpression' (which is the case when control flows gets here),
@ -228,13 +226,14 @@ export class And extends TagsFilter {
if (this.and.length === 0) { if (this.and.length === 0) {
return true return true
} }
const optimizedRaw = this.and.map(t => t.optimize()) const optimizedRaw = this.and
.filter(t => t !== true /* true is the neutral element in an AND, we drop them*/) .map((t) => t.optimize())
if (optimizedRaw.some(t => t === false)) { .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' // 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 // Conflicting keys do return false
@ -287,7 +286,7 @@ export class And extends TagsFilter {
} }
{ {
let dirty = false; let dirty = false
do { do {
const cleanedContainedOrs: Or[] = [] const cleanedContainedOrs: Or[] = []
outer: for (let containedOr of containedOrs) { 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 // the 'or' dissolved into a normal tag -> it has to be added to the newAnds
newAnds.push(cleaned) newAnds.push(cleaned)
dirty = true; // rerun this algo later on dirty = true // rerun this algo later on
continue outer; continue outer
} }
cleanedContainedOrs.push(containedOr) cleanedContainedOrs.push(containedOr)
} }
@ -319,12 +318,11 @@ export class And extends TagsFilter {
} while (dirty) } while (dirty)
} }
containedOrs = containedOrs.filter((ca) => {
containedOrs = containedOrs.filter(ca => {
const isShadowed = TagUtils.containsEquivalents(newAnds, ca.or) 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 // 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 // XY & (XY | AB) === XY
return !isShadowed; return !isShadowed
}) })
// Extract common keys from the OR // Extract common keys from the OR
@ -333,16 +331,19 @@ export class And extends TagsFilter {
} else if (containedOrs.length > 1) { } 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++) { for (let i = 1; i < containedOrs.length && commonValues.length > 0; i++) {
const containedOr = containedOrs[i]; const containedOr = containedOrs[i]
commonValues = commonValues.filter(cv => containedOr.or.some(candidate => candidate.shadows(cv))) commonValues = commonValues.filter((cv) =>
containedOr.or.some((candidate) => candidate.shadows(cv))
)
} }
if (commonValues.length === 0) { if (commonValues.length === 0) {
newAnds.push(...containedOrs) newAnds.push(...containedOrs)
} else { } else {
const newOrs: TagsFilter[] = [] const newOrs: TagsFilter[] = []
for (const containedOr of containedOrs) { for (const containedOr of containedOrs) {
const elements = containedOr.or const elements = containedOr.or.filter(
.filter(candidate => !commonValues.some(cv => cv.shadows(candidate))) (candidate) => !commonValues.some((cv) => cv.shadows(candidate))
)
newOrs.push(Or.construct(elements)) newOrs.push(Or.construct(elements))
} }
@ -371,12 +372,11 @@ export class And extends TagsFilter {
} }
isNegative(): boolean { isNegative(): boolean {
return !this.and.some(t => !t.isNegative()); return !this.and.some((t) => !t.isNegative())
} }
visit(f: (TagsFilter: any) => void) { visit(f: (TagsFilter: any) => void) {
f(this) 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 { export default class ComparingTag implements TagsFilter {
private readonly _key: string; private readonly _key: string
private readonly _predicate: (value: string) => boolean; private readonly _predicate: (value: string) => boolean
private readonly _representation: string; private readonly _representation: string
constructor(key: string, predicate: (value: string | undefined) => boolean, representation: string = "") { constructor(
this._key = key; key: string,
this._predicate = predicate; predicate: (value: string | undefined) => boolean,
this._representation = representation; representation: string = ""
) {
this._key = key
this._predicate = predicate
this._representation = representation
} }
asChange(properties: any): { k: string; v: string }[] { asChange(properties: any): { k: string; v: string }[] {
@ -24,11 +28,11 @@ export default class ComparingTag implements TagsFilter {
} }
shadows(other: TagsFilter): boolean { shadows(other: TagsFilter): boolean {
return other === this; return other === this
} }
isUsableAsAnswer(): boolean { isUsableAsAnswer(): boolean {
return false; return false
} }
/** /**
@ -41,23 +45,23 @@ export default class ComparingTag implements TagsFilter {
* t.matchesProperties({differentKey: 42}) // => false * t.matchesProperties({differentKey: 42}) // => false
*/ */
matchesProperties(properties: any): boolean { matchesProperties(properties: any): boolean {
return this._predicate(properties[this._key]); return this._predicate(properties[this._key])
} }
usedKeys(): string[] { usedKeys(): string[] {
return [this._key]; return [this._key]
} }
usedTags(): { key: string; value: string }[] { usedTags(): { key: string; value: string }[] {
return []; return []
} }
optimize(): TagsFilter | boolean { optimize(): TagsFilter | boolean {
return this; return this
} }
isNegative(): boolean { isNegative(): boolean {
return true; return true
} }
visit(f: (TagsFilter) => void) { visit(f: (TagsFilter) => void) {

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