forked from MapComplete/MapComplete
WIP: more features in inspector
This commit is contained in:
parent
c34300fae1
commit
552ea22275
19 changed files with 526 additions and 65 deletions
67
assets/layers/usertouched/usertouched.json
Normal file
67
assets/layers/usertouched/usertouched.json
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
{
|
||||||
|
"id": "usertouched",
|
||||||
|
"description": {
|
||||||
|
"en": "Special layer showing all items which were changed by a certain user"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"en": "Changed by user"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"render": {
|
||||||
|
"en": "Changed by user"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"source": "special",
|
||||||
|
"tagRenderings": [
|
||||||
|
{
|
||||||
|
"id": "test",
|
||||||
|
"render": {
|
||||||
|
"en": "Changed by user"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"all_tags"
|
||||||
|
],
|
||||||
|
"pointRendering": [
|
||||||
|
{
|
||||||
|
"location": [
|
||||||
|
"point",
|
||||||
|
"centroid"
|
||||||
|
],
|
||||||
|
"iconSize": "15,15",
|
||||||
|
"marker": [
|
||||||
|
{
|
||||||
|
"icon": "circle",
|
||||||
|
"color": "#aaa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon": "ring",
|
||||||
|
"color": "#000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lineRendering": [
|
||||||
|
{
|
||||||
|
"color": "black",
|
||||||
|
"width": 3,
|
||||||
|
"fillColor": "#00000000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "#cccccccc",
|
||||||
|
"width": {
|
||||||
|
"render": 0,
|
||||||
|
"mappings": [
|
||||||
|
{
|
||||||
|
"if": {"or":
|
||||||
|
["_geometry:type=Polygon","_geometry:type=MultiPolygon"]
|
||||||
|
},
|
||||||
|
"then": 20
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"offset": 15,
|
||||||
|
"fillColor": "#00000000"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"allowMove": false
|
||||||
|
}
|
13
assets/themes/inspector/inspector.json
Normal file
13
assets/themes/inspector/inspector.json
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"id": "inspector",
|
||||||
|
"title": {
|
||||||
|
"en": "Inspect changes from a single user"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "A theme to inspect what a single user did in the past"
|
||||||
|
},
|
||||||
|
"icon": "./assets/svg/add.svg",
|
||||||
|
"layers": [
|
||||||
|
"usertouched"
|
||||||
|
]
|
||||||
|
}
|
|
@ -351,6 +351,10 @@
|
||||||
"if": "theme=indoors",
|
"if": "theme=indoors",
|
||||||
"then": "./assets/layers/entrance/entrance.svg"
|
"then": "./assets/layers/entrance/entrance.svg"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"if": "theme=inspector",
|
||||||
|
"then": "./assets/svg/add.svg"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"if": "theme=items_with_image",
|
"if": "theme=items_with_image",
|
||||||
"then": "./assets/layers/item_with_image/camera.svg"
|
"then": "./assets/layers/item_with_image/camera.svg"
|
||||||
|
|
|
@ -643,11 +643,15 @@ class LayerOverviewUtils extends Script {
|
||||||
LayerOverviewUtils.layerPath +
|
LayerOverviewUtils.layerPath +
|
||||||
sharedLayerPath.substring(sharedLayerPath.lastIndexOf("/"))
|
sharedLayerPath.substring(sharedLayerPath.lastIndexOf("/"))
|
||||||
if (!forceReload && !this.shouldBeUpdated(sharedLayerPath, targetPath)) {
|
if (!forceReload && !this.shouldBeUpdated(sharedLayerPath, targetPath)) {
|
||||||
const sharedLayer = JSON.parse(readFileSync(targetPath, "utf8"))
|
try{
|
||||||
sharedLayers.set(sharedLayer.id, sharedLayer)
|
const sharedLayer = JSON.parse(readFileSync(targetPath, "utf8"))
|
||||||
skippedLayers.push(sharedLayer.id)
|
sharedLayers.set(sharedLayer.id, sharedLayer)
|
||||||
ScriptUtils.erasableLog("Loaded " + sharedLayer.id)
|
skippedLayers.push(sharedLayer.id)
|
||||||
continue
|
ScriptUtils.erasableLog("Loaded " + sharedLayer.id)
|
||||||
|
continue
|
||||||
|
}catch (e) {
|
||||||
|
throw "Could not parse "+targetPath+" : "+e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ export default class OsmObjectDownloader {
|
||||||
readonly isUploading: Store<boolean>
|
readonly isUploading: Store<boolean>
|
||||||
}
|
}
|
||||||
private readonly backend: string
|
private readonly backend: string
|
||||||
private historyCache = new Map<string, UIEventSource<OsmObject[]>>()
|
private historyCache = new Map<string, Promise<OsmObject[]>>()
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
backend: string = "https://api.openstreetmap.org",
|
backend: string = "https://api.openstreetmap.org",
|
||||||
|
@ -75,49 +75,51 @@ export default class OsmObjectDownloader {
|
||||||
return await this.applyPendingChanges(obj)
|
return await this.applyPendingChanges(obj)
|
||||||
}
|
}
|
||||||
|
|
||||||
public DownloadHistory(id: NodeId): UIEventSource<OsmNode[]>
|
private async _downloadHistoryUncached(id: string): Promise<OsmObject[]> {
|
||||||
|
|
||||||
public DownloadHistory(id: WayId): UIEventSource<OsmWay[]>
|
|
||||||
|
|
||||||
public DownloadHistory(id: RelationId): UIEventSource<OsmRelation[]>
|
|
||||||
|
|
||||||
public DownloadHistory(id: OsmId): UIEventSource<OsmObject[]>
|
|
||||||
|
|
||||||
public DownloadHistory(id: string): UIEventSource<OsmObject[]> {
|
|
||||||
if (this.historyCache.has(id)) {
|
|
||||||
return this.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 data = await Utils.downloadJsonCached(
|
||||||
this.historyCache.set(id, src)
|
|
||||||
Utils.downloadJsonCached(
|
|
||||||
`${this.backend}api/0.6/${type}/${idN}/history`,
|
`${this.backend}api/0.6/${type}/${idN}/history`,
|
||||||
10 * 60 * 1000
|
10 * 60 * 1000
|
||||||
).then((data) => {
|
)
|
||||||
const elements: any[] = data.elements
|
const elements: [] = 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
|
||||||
element.nodes = []
|
element["nodes"] = []
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "node":
|
case "node":
|
||||||
osmObject = new OsmNode(idN, element)
|
osmObject = new OsmNode(idN, element)
|
||||||
break
|
break
|
||||||
case "way":
|
case "way":
|
||||||
osmObject = new OsmWay(idN, element)
|
osmObject = new OsmWay(idN, element)
|
||||||
break
|
break
|
||||||
case "relation":
|
case "relation":
|
||||||
osmObject = new OsmRelation(idN, element)
|
osmObject = new OsmRelation(idN, element)
|
||||||
break
|
break
|
||||||
}
|
|
||||||
osmObject?.SaveExtraData(element, [])
|
|
||||||
osmObjects.push(osmObject)
|
|
||||||
}
|
}
|
||||||
src.setData(osmObjects)
|
osmObject?.SaveExtraData(element, [])
|
||||||
})
|
osmObjects.push(osmObject)
|
||||||
return src
|
}
|
||||||
|
return osmObjects
|
||||||
|
}
|
||||||
|
|
||||||
|
public downloadHistory(id: NodeId): Promise<OsmNode[]>
|
||||||
|
|
||||||
|
public downloadHistory(id: WayId): Promise<OsmWay[]>
|
||||||
|
|
||||||
|
public downloadHistory(id: RelationId): Promise<OsmRelation[]>
|
||||||
|
|
||||||
|
public downloadHistory(id: OsmId): Promise<OsmObject[]>
|
||||||
|
|
||||||
|
public async downloadHistory(id: string): Promise<OsmObject[]> {
|
||||||
|
if (this.historyCache.has(id)) {
|
||||||
|
return this.historyCache.get(id)
|
||||||
|
}
|
||||||
|
const promise = this._downloadHistoryUncached(id)
|
||||||
|
this.historyCache.set(id, promise)
|
||||||
|
return promise
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -26,7 +26,10 @@ export class Overpass {
|
||||||
) {
|
) {
|
||||||
this._timeout = timeout ?? new ImmutableStore<number>(90)
|
this._timeout = timeout ?? new ImmutableStore<number>(90)
|
||||||
this._interpreterUrl = interpreterUrl
|
this._interpreterUrl = interpreterUrl
|
||||||
const optimized = filter.optimize()
|
if (filter === undefined && !extraScripts) {
|
||||||
|
throw "Filter is undefined. This is probably a bug. Alternatively, pass an 'extraScript'"
|
||||||
|
}
|
||||||
|
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"
|
||||||
}
|
}
|
||||||
|
@ -85,7 +88,7 @@ export class Overpass {
|
||||||
* new Overpass(new Tag("key","value"), [], "").buildScript("{{bbox}}") // => `[out:json][timeout:90]{{bbox}};(nwr["key"="value"];);out body;out meta;>;out skel qt;`
|
* new Overpass(new Tag("key","value"), [], "").buildScript("{{bbox}}") // => `[out:json][timeout:90]{{bbox}};(nwr["key"="value"];);out body;out meta;>;out skel qt;`
|
||||||
*/
|
*/
|
||||||
public buildScript(bbox: string, postCall: string = "", pretty = false): string {
|
public buildScript(bbox: string, postCall: string = "", 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) {
|
||||||
|
@ -97,12 +100,13 @@ export class Overpass {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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;${
|
return `[out:json][timeout:${this._timeout.data}]${bbox};(${filter});out body;${
|
||||||
this._includeMeta ? "out meta;" : ""
|
this._includeMeta ? "out meta;" : ""
|
||||||
}>;out skel qt;`
|
}>;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'
|
||||||
|
|
|
@ -727,6 +727,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Parse the number and round to the nearest int
|
||||||
*
|
*
|
||||||
* @param source
|
* @param source
|
||||||
* UIEventSource.asInt(new UIEventSource("123")).data // => 123
|
* UIEventSource.asInt(new UIEventSource("123")).data // => 123
|
||||||
|
|
|
@ -41,6 +41,7 @@ export default class Constants {
|
||||||
"usersettings",
|
"usersettings",
|
||||||
"icons",
|
"icons",
|
||||||
"filters",
|
"filters",
|
||||||
|
"usertouched"
|
||||||
] as const
|
] as const
|
||||||
/**
|
/**
|
||||||
* Layer IDs of layers which have special properties through built-in hooks
|
* Layer IDs of layers which have special properties through built-in hooks
|
||||||
|
|
|
@ -306,7 +306,7 @@ export default class ThemeConfig implements ThemeInformation {
|
||||||
return { untranslated, total }
|
return { untranslated, total }
|
||||||
}
|
}
|
||||||
|
|
||||||
public getMatchingLayer(tags: Record<string, string>): LayerConfig | undefined {
|
public getMatchingLayer(tags: Record<string, string>, blacklistLayers?: Set<string>): LayerConfig | undefined {
|
||||||
if (tags === undefined) {
|
if (tags === undefined) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
@ -314,6 +314,9 @@ export default class ThemeConfig implements ThemeInformation {
|
||||||
return this.getLayer("current_view")
|
return this.getLayer("current_view")
|
||||||
}
|
}
|
||||||
for (const layer of this.layers) {
|
for (const layer of this.layers) {
|
||||||
|
if(blacklistLayers?.has(layer.id)){
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (!layer.source) {
|
if (!layer.source) {
|
||||||
if (layer.isShown?.matchesProperties(tags)) {
|
if (layer.isShown?.matchesProperties(tags)) {
|
||||||
return layer
|
return layer
|
||||||
|
|
|
@ -1040,7 +1040,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
||||||
/**
|
/**
|
||||||
* Searches the appropriate layer - will first try if a special layer matches; if not, a normal layer will be used by delegating to the theme
|
* Searches the appropriate layer - will first try if a special layer matches; if not, a normal layer will be used by delegating to the theme
|
||||||
*/
|
*/
|
||||||
public getMatchingLayer(properties: Record<string, string>) {
|
public getMatchingLayer(properties: Record<string, string>): LayerConfig | undefined {
|
||||||
const id = properties.id
|
const id = properties.id
|
||||||
|
|
||||||
if (id.startsWith("summary_")) {
|
if (id.startsWith("summary_")) {
|
||||||
|
|
|
@ -3,24 +3,15 @@
|
||||||
import type { Feature } from "geojson"
|
import type { Feature } from "geojson"
|
||||||
import SelectedElementView from "../BigComponents/SelectedElementView.svelte"
|
import SelectedElementView from "../BigComponents/SelectedElementView.svelte"
|
||||||
import SelectedElementTitle from "../BigComponents/SelectedElementTitle.svelte"
|
import SelectedElementTitle from "../BigComponents/SelectedElementTitle.svelte"
|
||||||
import UserRelatedState from "../../Logic/State/UserRelatedState"
|
|
||||||
import { LastClickFeatureSource } from "../../Logic/FeatureSource/Sources/LastClickFeatureSource"
|
|
||||||
import Loading from "./Loading.svelte"
|
import Loading from "./Loading.svelte"
|
||||||
import { onDestroy } from "svelte"
|
import { onDestroy } from "svelte"
|
||||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
|
|
||||||
import { GeocodingUtils } from "../../Logic/Search/GeocodingProvider"
|
|
||||||
import ThemeViewState from "../../Models/ThemeViewState"
|
|
||||||
|
|
||||||
export let state: SpecialVisualizationState
|
export let state: SpecialVisualizationState
|
||||||
export let selected: Feature
|
export let selected: Feature
|
||||||
let tags = state.featureProperties.getStore(selected.properties.id)
|
let tags = state.featureProperties.getStore(selected.properties.id)
|
||||||
|
|
||||||
export let absolute = true
|
export let absolute = true
|
||||||
function getLayer(properties: Record<string, string>): LayerConfig {
|
let layer = state.getMatchingLayer(selected.properties)
|
||||||
return state.getMatchingLayer(properties)
|
|
||||||
}
|
|
||||||
|
|
||||||
let layer = getLayer(selected.properties)
|
|
||||||
|
|
||||||
let stillMatches = tags.map(
|
let stillMatches = tags.map(
|
||||||
(tags) => !layer?.source?.osmTags || layer?.source?.osmTags?.matchesProperties(tags)
|
(tags) => !layer?.source?.osmTags || layer?.source?.osmTags?.matchesProperties(tags)
|
||||||
|
|
|
@ -1,23 +1,21 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Feature } from "geojson"
|
import type { Feature } from "geojson"
|
||||||
import { Store, UIEventSource } from "../../Logic/UIEventSource"
|
import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
|
||||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
|
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
|
||||||
import type { SpecialVisualizationState } from "../SpecialVisualization"
|
import type { SpecialVisualizationState } from "../SpecialVisualization"
|
||||||
import TagRenderingAnswer from "../Popup/TagRendering/TagRenderingAnswer.svelte"
|
import TagRenderingAnswer from "../Popup/TagRendering/TagRenderingAnswer.svelte"
|
||||||
import Translations from "../i18n/Translations"
|
import Translations from "../i18n/Translations"
|
||||||
import Tr from "../Base/Tr.svelte"
|
import Tr from "../Base/Tr.svelte"
|
||||||
import { XCircleIcon } from "@rgossiaux/svelte-heroicons/solid"
|
|
||||||
import { ariaLabel } from "../../Utils/ariaLabel"
|
|
||||||
import { CloseButton } from "flowbite-svelte"
|
import { CloseButton } from "flowbite-svelte"
|
||||||
|
|
||||||
export let state: SpecialVisualizationState
|
export let state: SpecialVisualizationState
|
||||||
export let layer: LayerConfig
|
export let layer: LayerConfig
|
||||||
export let selectedElement: Feature
|
export let selectedElement: Feature
|
||||||
let tags: UIEventSource<Record<string, string>> = state.featureProperties.getStore(
|
let tags: UIEventSource<Record<string, string>> = state?.featureProperties?.getStore(
|
||||||
selectedElement.properties.id
|
selectedElement.properties.id
|
||||||
)
|
)
|
||||||
$: {
|
$: {
|
||||||
tags = state.featureProperties.getStore(selectedElement.properties.id)
|
tags = state?.featureProperties?.getStore(selectedElement.properties.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
let isTesting = state.featureSwitchIsTesting
|
let isTesting = state.featureSwitchIsTesting
|
||||||
|
|
73
src/UI/History/AggregateView.svelte
Normal file
73
src/UI/History/AggregateView.svelte
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Feature } from "geojson"
|
||||||
|
import { Store, UIEventSource } from "../../Logic/UIEventSource"
|
||||||
|
import OsmObjectDownloader from "../../Logic/Osm/OsmObjectDownloader"
|
||||||
|
import { OsmObject } from "../../Logic/Osm/OsmObject"
|
||||||
|
import Loading from "../Base/Loading.svelte"
|
||||||
|
import { HistoryUtils } from "./HistoryUtils"
|
||||||
|
|
||||||
|
export let onlyShowUsername: string
|
||||||
|
export let features: Feature[]
|
||||||
|
|
||||||
|
const downloader = new OsmObjectDownloader()
|
||||||
|
let allHistories: UIEventSource<OsmObject[][]> = UIEventSource.FromPromise(
|
||||||
|
Promise.all(features.map(f => downloader.downloadHistory(f.properties.id)))
|
||||||
|
)
|
||||||
|
let allDiffs: Store<{ key: string; value?: string; oldValue?: string }[]> = allHistories.mapD(histories => {
|
||||||
|
const allDiffs = [].concat(...histories.map(
|
||||||
|
history => {
|
||||||
|
const filtered = history.filter(step => !onlyShowUsername || step.tags["_last_edit:contributor"] === onlyShowUsername)
|
||||||
|
const diffs: {
|
||||||
|
key: string;
|
||||||
|
value?: string;
|
||||||
|
oldValue?: string
|
||||||
|
}[][] = filtered.map(step => HistoryUtils.tagHistoryDiff(step, history))
|
||||||
|
return [].concat(...diffs)
|
||||||
|
}
|
||||||
|
))
|
||||||
|
return allDiffs
|
||||||
|
})
|
||||||
|
|
||||||
|
const mergedCount = allDiffs.mapD(allDiffs => {
|
||||||
|
const keyCounts = new Map<string, Map<string, number>>()
|
||||||
|
for (const diff of allDiffs) {
|
||||||
|
const k = diff.key
|
||||||
|
if(!keyCounts.has(k)){
|
||||||
|
keyCounts.set(k, new Map<string, number>())
|
||||||
|
}
|
||||||
|
const valueCounts = keyCounts.get(k)
|
||||||
|
const v = diff.value ?? ""
|
||||||
|
valueCounts.set(v, 1 + (valueCounts.get(v) ?? 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
const perKey: {key: string, count: number, values:
|
||||||
|
{value: string, count: number}[]
|
||||||
|
}[] = []
|
||||||
|
keyCounts.forEach((values, key) => {
|
||||||
|
const keyTotal : {value: string, count: number}[] = []
|
||||||
|
values.forEach((count, value) => {
|
||||||
|
keyTotal.push({value, count})
|
||||||
|
})
|
||||||
|
let countForKey = 0
|
||||||
|
for (const {count} of keyTotal) {
|
||||||
|
countForKey += count
|
||||||
|
}
|
||||||
|
keyTotal.sort((a, b) => b.count - a.count)
|
||||||
|
perKey.push({count: countForKey, key, values: keyTotal})
|
||||||
|
})
|
||||||
|
perKey.sort((a, b) => b.count - a.count)
|
||||||
|
|
||||||
|
return perKey
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if allHistories === undefined}
|
||||||
|
<Loading />
|
||||||
|
{:else if $allDiffs !== undefined}
|
||||||
|
{#each $mergedCount as diff}
|
||||||
|
<div class="m-1 border-black border p-1">
|
||||||
|
{JSON.stringify(diff)}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
95
src/UI/History/History.svelte
Normal file
95
src/UI/History/History.svelte
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Shows a history of the object which focuses on changes made by a certain username
|
||||||
|
*/
|
||||||
|
import type { OsmId } from "../../Models/OsmFeature"
|
||||||
|
import OsmObjectDownloader from "../../Logic/Osm/OsmObjectDownloader"
|
||||||
|
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||||
|
import Loading from "../Base/Loading.svelte"
|
||||||
|
import { HistoryUtils } from "./HistoryUtils"
|
||||||
|
import ToSvelte from "../Base/ToSvelte.svelte"
|
||||||
|
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
|
||||||
|
import Tr from "../Base/Tr.svelte"
|
||||||
|
|
||||||
|
export let onlyShowChangesBy: string
|
||||||
|
export let id: OsmId
|
||||||
|
|
||||||
|
let fullHistory = UIEventSource.FromPromise(new OsmObjectDownloader().downloadHistory(id))
|
||||||
|
|
||||||
|
let partOfLayer = fullHistory.mapD(history => history.map(step => ({
|
||||||
|
step,
|
||||||
|
layer: HistoryUtils.determineLayer(step.tags)
|
||||||
|
})))
|
||||||
|
let filteredHistory = partOfLayer.mapD(history =>
|
||||||
|
history.filter(({ step }) => {
|
||||||
|
if (!onlyShowChangesBy) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
console.log("Comparing ", step.tags["_last_edit:contributor"], onlyShowChangesBy, step.tags["_last_edit:contributor"] === onlyShowChangesBy)
|
||||||
|
return step.tags["_last_edit:contributor"] === onlyShowChangesBy
|
||||||
|
|
||||||
|
}))
|
||||||
|
|
||||||
|
let lastStep = filteredHistory.mapD(history => history.at(-1))
|
||||||
|
let l : LayerConfig
|
||||||
|
// l.title.GetRenderValue({}).Subs({})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $lastStep?.layer}
|
||||||
|
<a href={"https://openstreetmap.org/" + $lastStep.step.tags.id} target="_blank">
|
||||||
|
<h3 class="flex items-center gap-x-2">
|
||||||
|
<div class="w-8 h-8 shrink-0 inline-block">
|
||||||
|
<ToSvelte construct={$lastStep.layer?.defaultIcon($lastStep.step.tags)} />
|
||||||
|
</div>
|
||||||
|
<Tr t={$lastStep.layer?.title?.GetRenderValue($lastStep.step.tags)?.Subs($lastStep.step.tags)}/>
|
||||||
|
</h3>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !$filteredHistory}
|
||||||
|
<Loading>Loading history...</Loading>
|
||||||
|
{:else if $filteredHistory.length === 0}
|
||||||
|
Only geometry changes found
|
||||||
|
{:else}
|
||||||
|
<table class="w-full m-1">
|
||||||
|
{#each $filteredHistory as { step, layer }}
|
||||||
|
|
||||||
|
{#if step.version === 1}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3">
|
||||||
|
<h3>
|
||||||
|
Created by {step.tags["_last_edit:contributor"]}
|
||||||
|
</h3>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{#if HistoryUtils.tagHistoryDiff(step, $fullHistory).length === 0}
|
||||||
|
<tr>
|
||||||
|
<td class="font-bold justify-center flex w-full" colspan="3">
|
||||||
|
Only changes in geometry
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
{#each HistoryUtils.tagHistoryDiff(step, $fullHistory) as diff}
|
||||||
|
<tr>
|
||||||
|
<td><a href={"https://osm.org/changeset/"+step.tags["_last_edit:changeset"]}
|
||||||
|
target="_blank">{step.version}</a></td>
|
||||||
|
<td>{layer?.id ?? "Unknown layer"}</td>
|
||||||
|
{#if diff.oldValue === undefined}
|
||||||
|
<td>{diff.key}</td>
|
||||||
|
<td>{diff.value}</td>
|
||||||
|
{:else if diff.value === undefined }
|
||||||
|
<td>{diff.key}</td>
|
||||||
|
<td class="line-through"> {diff.value}</td>
|
||||||
|
{:else}
|
||||||
|
<td>{diff.key}</td>
|
||||||
|
<td><span class="line-through"> {diff.oldValue}</span> → {diff.value}</td>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</table>
|
||||||
|
{/if}
|
35
src/UI/History/HistoryUtils.ts
Normal file
35
src/UI/History/HistoryUtils.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import * as all_layers from "../../assets/generated/themes/personal.json"
|
||||||
|
import ThemeConfig from "../../Models/ThemeConfig/ThemeConfig"
|
||||||
|
import { OsmObject } from "../../Logic/Osm/OsmObject"
|
||||||
|
|
||||||
|
export class HistoryUtils {
|
||||||
|
|
||||||
|
private static personalTheme = new ThemeConfig(<any> all_layers, true)
|
||||||
|
private static ignoredLayers = new Set<string>(["fixme"])
|
||||||
|
public static determineLayer(properties: Record<string, string>){
|
||||||
|
return this.personalTheme.getMatchingLayer(properties, this.ignoredLayers)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static tagHistoryDiff(step: OsmObject, history: OsmObject[]): {
|
||||||
|
key: string,
|
||||||
|
value?: string,
|
||||||
|
oldValue?: string
|
||||||
|
}[] {
|
||||||
|
const previous = history[step.version - 2]
|
||||||
|
if (!previous) {
|
||||||
|
return Object.keys(step.tags).filter(key => !key.startsWith("_") && key !== "id").map(key => ({
|
||||||
|
key, value: step.tags[key]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
const previousTags = previous.tags
|
||||||
|
return Object.keys(step.tags).filter(key => !key.startsWith("_") )
|
||||||
|
.map(key => {
|
||||||
|
const value = step.tags[key]
|
||||||
|
const oldValue = previousTags[key]
|
||||||
|
return {
|
||||||
|
key, value, oldValue
|
||||||
|
}
|
||||||
|
}).filter(ch => ch.oldValue !== ch.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
165
src/UI/InspectorGUI.svelte
Normal file
165
src/UI/InspectorGUI.svelte
Normal file
|
@ -0,0 +1,165 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { UIEventSource } from "../Logic/UIEventSource"
|
||||||
|
import { QueryParameters } from "../Logic/Web/QueryParameters"
|
||||||
|
import ValidatedInput from "./InputElement/ValidatedInput.svelte"
|
||||||
|
import { Overpass } from "../Logic/Osm/Overpass"
|
||||||
|
import Constants from "../Models/Constants"
|
||||||
|
import MaplibreMap from "./Map/MaplibreMap.svelte"
|
||||||
|
import { MapLibreAdaptor } from "./Map/MapLibreAdaptor"
|
||||||
|
import { Map as MlMap } from "maplibre-gl"
|
||||||
|
import ShowDataLayer from "./Map/ShowDataLayer"
|
||||||
|
import * as inspector_theme from "../assets/generated/themes/inspector.json"
|
||||||
|
|
||||||
|
import StaticFeatureSource from "../Logic/FeatureSource/Sources/StaticFeatureSource"
|
||||||
|
import type { Feature } from "geojson"
|
||||||
|
import Loading from "./Base/Loading.svelte"
|
||||||
|
import { linear } from "svelte/easing"
|
||||||
|
import { Drawer } from "flowbite-svelte"
|
||||||
|
import ThemeConfig from "../Models/ThemeConfig/ThemeConfig"
|
||||||
|
import History from "./History/History.svelte"
|
||||||
|
import TitledPanel from "./Base/TitledPanel.svelte"
|
||||||
|
import { XCircleIcon } from "@babeard/svelte-heroicons/solid"
|
||||||
|
import { Utils } from "../Utils"
|
||||||
|
import AggregateView from "./History/AggregateView.svelte"
|
||||||
|
|
||||||
|
let username = QueryParameters.GetQueryParameter("user", undefined, "Inspect this user")
|
||||||
|
let step = new UIEventSource<"waiting" | "loading" | "done">("waiting")
|
||||||
|
let map = new UIEventSource<MlMap>(undefined)
|
||||||
|
let zoom = UIEventSource.asFloat(QueryParameters.GetQueryParameter("z", "0"))
|
||||||
|
let lat = UIEventSource.asFloat(QueryParameters.GetQueryParameter("lat", "0"))
|
||||||
|
let lon = UIEventSource.asFloat(QueryParameters.GetQueryParameter("lon", "0"))
|
||||||
|
let theme = new ThemeConfig(<any>inspector_theme, true)
|
||||||
|
let layer = theme.layers.find(l => l.id === "usertouched")
|
||||||
|
theme.getMatchingLayer = () => {
|
||||||
|
// Is this a dirty hack? Yes it is!
|
||||||
|
return layer
|
||||||
|
}
|
||||||
|
let loadingData = false
|
||||||
|
let selectedElement = new UIEventSource<Feature>(undefined)
|
||||||
|
|
||||||
|
let maplibremap: MapLibreAdaptor = new MapLibreAdaptor(map, {
|
||||||
|
zoom,
|
||||||
|
location: new UIEventSource<{ lon: number; lat: number }>({ lat: lat.data, lon: lon.data })
|
||||||
|
})
|
||||||
|
maplibremap.location.stabilized(500).addCallbackAndRunD(l => {
|
||||||
|
lat.set(l.lat)
|
||||||
|
lon.set(l.lon)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
let featuresStore = new UIEventSource<Feature[]>([])
|
||||||
|
let features = new StaticFeatureSource(featuresStore)
|
||||||
|
new ShowDataLayer(map,
|
||||||
|
{
|
||||||
|
layer,
|
||||||
|
zoomToFeatures: true,
|
||||||
|
features,
|
||||||
|
onClick: (f: Feature) => {
|
||||||
|
selectedElement.set(undefined)
|
||||||
|
Utils.waitFor(200).then(() => {
|
||||||
|
selectedElement.set(f)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
|
||||||
|
step.setData("loading")
|
||||||
|
const overpass = new Overpass(undefined, ["nw(user_touched:\"" + username.data + "\");"], Constants.defaultOverpassUrls[0])
|
||||||
|
if (!maplibremap.bounds.data) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadingData = true
|
||||||
|
const [data, date] = await overpass.queryGeoJson(maplibremap.bounds.data)
|
||||||
|
console.log("Overpass result:", data)
|
||||||
|
loadingData = false
|
||||||
|
console.log(data, date)
|
||||||
|
featuresStore.set(data.features)
|
||||||
|
console.log("Loaded", data.features.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
map.addCallbackAndRunD(() => {
|
||||||
|
// when the map is loaded: attempt to load the user given via Queryparams
|
||||||
|
if (username.data) {
|
||||||
|
console.log("Current username is", username.data)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
let mode: "map" | "table" | "aggregate" = "map"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col w-full h-full">
|
||||||
|
|
||||||
|
<div class="flex gap-x-2 items-center low-interaction p-2">
|
||||||
|
<h1 class="flex-shrink-0">Inspect contributor</h1>
|
||||||
|
<ValidatedInput type="string" value={username} on:submit={() => load()} />
|
||||||
|
{#if loadingData}
|
||||||
|
<Loading />
|
||||||
|
{:else}
|
||||||
|
<button class="primary" on:click={() => load()}>Load</button>
|
||||||
|
{/if}
|
||||||
|
<a href="./index.html" class="button">Back to index</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<button class:primary={mode === "map"} on:click={() => mode = "map"}>
|
||||||
|
Map view
|
||||||
|
</button>
|
||||||
|
<button class:primary={mode === "table"} on:click={() => mode = "table"}>
|
||||||
|
Table view
|
||||||
|
</button>
|
||||||
|
<button class:primary={mode === "aggregate"} on:click={() => mode = "aggregate"}>
|
||||||
|
Aggregate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if mode === "map"}
|
||||||
|
{#if $selectedElement !== undefined}
|
||||||
|
<!-- right modal with the selected element view -->
|
||||||
|
<Drawer
|
||||||
|
placement="right"
|
||||||
|
transitionType="fly"
|
||||||
|
activateClickOutside={false}
|
||||||
|
backdrop={false}
|
||||||
|
id="drawer-right"
|
||||||
|
width="w-full md:w-6/12 lg:w-5/12 xl:w-4/12"
|
||||||
|
rightOffset="inset-y-0 right-0"
|
||||||
|
transitionParams={{
|
||||||
|
x: 640,
|
||||||
|
duration: 0,
|
||||||
|
easing: linear,
|
||||||
|
}}
|
||||||
|
divClass="overflow-y-auto z-50 bg-white"
|
||||||
|
hidden={$selectedElement === undefined}
|
||||||
|
on:close={() => {
|
||||||
|
selectedElement.setData(undefined)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
<TitledPanel>
|
||||||
|
<div slot="title" class="flex justify-between">
|
||||||
|
|
||||||
|
<a target="_blank" rel="noopener"
|
||||||
|
href={"https://osm.org/"+$selectedElement.properties.id}>{$selectedElement.properties.id}</a>
|
||||||
|
<XCircleIcon class="w-6 h-6" on:click={() => selectedElement.set(undefined)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<History onlyShowChangesBy={$username} id={$selectedElement.properties.id}></History>
|
||||||
|
</TitledPanel>
|
||||||
|
</Drawer>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex-grow overflow-hidden m-1 rounded-xl">
|
||||||
|
<MaplibreMap map={map} mapProperties={maplibremap} autorecovery={true} />
|
||||||
|
</div>
|
||||||
|
{:else if mode === "table"}
|
||||||
|
{#each $featuresStore as f}
|
||||||
|
<h3><a href={"https://osm.org/"+f.properties.id} target="_blank">{f.properties.id}</a></h3>
|
||||||
|
<History onlyShowChangesBy={$username} id={f.properties.id} />
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<AggregateView features={$featuresStore} onlyShowUsername={$username}></AggregateView>
|
||||||
|
{/if}
|
||||||
|
</div>
|
5
src/UI/InspectorGUI.ts
Normal file
5
src/UI/InspectorGUI.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import InspectorGUI from "./InspectorGUI.svelte"
|
||||||
|
|
||||||
|
new InspectorGUI({
|
||||||
|
target: document.getElementById("main"),
|
||||||
|
})
|
|
@ -97,7 +97,7 @@ export class DeleteFlowState {
|
||||||
if (allByMyself.data === null && useTheInternet) {
|
if (allByMyself.data === null && useTheInternet) {
|
||||||
// We kickoff the download here as it hasn't yet been downloaded. Note that this is mapped onto 'all by myself' above
|
// We kickoff the download here as it hasn't yet been downloaded. Note that this is mapped onto 'all by myself' above
|
||||||
const hist = this.objectDownloader
|
const hist = this.objectDownloader
|
||||||
.DownloadHistory(id)
|
.downloadHistory(id)
|
||||||
.map((versions) =>
|
.map((versions) =>
|
||||||
versions.map((version) =>
|
versions.map((version) =>
|
||||||
Number(version.tags["_last_edit:contributor:uid"])
|
Number(version.tags["_last_edit:contributor:uid"])
|
||||||
|
|
|
@ -87,7 +87,7 @@ export interface SpecialVisualizationState {
|
||||||
readonly geocodedImages: UIEventSource<Feature[]>
|
readonly geocodedImages: UIEventSource<Feature[]>
|
||||||
readonly searchState: SearchState
|
readonly searchState: SearchState
|
||||||
|
|
||||||
getMatchingLayer(properties: Record<string, string>)
|
getMatchingLayer(properties: Record<string, string>): LayerConfig | undefined
|
||||||
|
|
||||||
showCurrentLocationOn(map: Store<MlMap>): ShowDataLayer
|
showCurrentLocationOn(map: Store<MlMap>): ShowDataLayer
|
||||||
reportError(message: string | Error | XMLHttpRequest, extramessage?: string): Promise<void>
|
reportError(message: string | Error | XMLHttpRequest, extramessage?: string): Promise<void>
|
||||||
|
|
Loading…
Reference in a new issue