forked from MapComplete/MapComplete
Merge latest develop
This commit is contained in:
commit
17450deb82
386 changed files with 12073 additions and 25528 deletions
|
@ -13,60 +13,74 @@ export default class FilteringFeatureSource implements FeatureSource {
|
|||
private readonly _is_dirty = new UIEventSource(false)
|
||||
private readonly _layer: FilteredLayer
|
||||
private previousFeatureSet: Set<any> = undefined
|
||||
private readonly _zoomlevel: Store<number>
|
||||
private readonly _selectedElement: Store<Feature>
|
||||
|
||||
constructor(
|
||||
layer: FilteredLayer,
|
||||
upstream: FeatureSource,
|
||||
fetchStore?: (id: string) => Store<Record<string, string>>,
|
||||
globalFilters?: Store<GlobalFilter[]>,
|
||||
metataggingUpdated?: Store<any>
|
||||
metataggingUpdated?: Store<any>,
|
||||
zoomlevel?: Store<number>,
|
||||
selectedElement?: Store<Feature>
|
||||
) {
|
||||
this.upstream = upstream
|
||||
this._fetchStore = fetchStore
|
||||
this._layer = layer
|
||||
this._globalFilters = globalFilters
|
||||
this._zoomlevel = zoomlevel
|
||||
this._selectedElement = selectedElement
|
||||
|
||||
const self = this
|
||||
upstream.features.addCallback(() => {
|
||||
self.update()
|
||||
this.update()
|
||||
})
|
||||
layer.isDisplayed.addCallback(() => {
|
||||
self.update()
|
||||
this.update()
|
||||
})
|
||||
|
||||
layer.appliedFilters.forEach((value) =>
|
||||
value.addCallback((_) => {
|
||||
self.update()
|
||||
this.update()
|
||||
})
|
||||
)
|
||||
|
||||
this._is_dirty.stabilized(1000).addCallbackAndRunD((dirty) => {
|
||||
if (dirty) {
|
||||
self.update()
|
||||
this.update()
|
||||
}
|
||||
})
|
||||
|
||||
metataggingUpdated?.addCallback((_) => {
|
||||
self._is_dirty.setData(true)
|
||||
metataggingUpdated?.addCallback(() => {
|
||||
this._is_dirty.setData(true)
|
||||
})
|
||||
|
||||
globalFilters?.addCallback((_) => {
|
||||
self.update()
|
||||
globalFilters?.addCallback(() => {
|
||||
this.update()
|
||||
})
|
||||
|
||||
selectedElement?.addCallback(() => this.update())
|
||||
|
||||
zoomlevel?.mapD((z) => Math.floor(z)).addCallback(() => this.update())
|
||||
|
||||
this.update()
|
||||
}
|
||||
|
||||
private update() {
|
||||
const self = this
|
||||
const layer = this._layer
|
||||
const features: Feature[] = this.upstream.features.data ?? []
|
||||
const includedFeatureIds = new Set<string>()
|
||||
const globalFilters = self._globalFilters?.data?.map((f) => f)
|
||||
const globalFilters = this._globalFilters?.data?.map((f) => f)
|
||||
const zoomlevel = this._zoomlevel?.data
|
||||
const selectedElement = this._selectedElement?.data?.properties?.id
|
||||
const newFeatures = (features ?? []).filter((f) => {
|
||||
self.registerCallback(f.properties.id)
|
||||
this.registerCallback(f.properties.id)
|
||||
|
||||
if (!layer.isShown(f.properties, globalFilters)) {
|
||||
if (selectedElement === f.properties.id) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!layer.isShown(f.properties, globalFilters, zoomlevel)) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
|
@ -50,13 +50,16 @@ export default class OverpassFeatureSource implements UpdatableFeatureSource {
|
|||
options?: {
|
||||
padToTiles?: Store<number>
|
||||
isActive?: Store<boolean>
|
||||
ignoreZoom?: boolean
|
||||
}
|
||||
) {
|
||||
this.state = state
|
||||
this._isActive = options?.isActive ?? new ImmutableStore(true)
|
||||
this.padToZoomLevel = options?.padToTiles
|
||||
const self = this
|
||||
this._layersToDownload = state.zoom.map((zoom) => this.layersToDownload(zoom))
|
||||
this._layersToDownload = options?.ignoreZoom
|
||||
? new ImmutableStore(state.layers)
|
||||
: state.zoom.map((zoom) => this.layersToDownload(zoom))
|
||||
|
||||
state.bounds.mapD(
|
||||
(_) => {
|
||||
|
@ -103,7 +106,7 @@ export default class OverpassFeatureSource implements UpdatableFeatureSource {
|
|||
* Download the relevant data from overpass. Attempt to use a different server if one fails; only downloads the relevant layers
|
||||
* @private
|
||||
*/
|
||||
public async updateAsync(): Promise<void> {
|
||||
public async updateAsync(overrideBounds?: BBox): Promise<void> {
|
||||
let data: any = undefined
|
||||
let lastUsed = 0
|
||||
const start = new Date()
|
||||
|
@ -122,9 +125,11 @@ export default class OverpassFeatureSource implements UpdatableFeatureSource {
|
|||
let bounds: BBox
|
||||
do {
|
||||
try {
|
||||
bounds = this.state.bounds.data
|
||||
?.pad(this.state.widenFactor)
|
||||
?.expandToTileBounds(this.padToZoomLevel?.data)
|
||||
bounds =
|
||||
overrideBounds ??
|
||||
this.state.bounds.data
|
||||
?.pad(this.state.widenFactor)
|
||||
?.expandToTileBounds(this.padToZoomLevel?.data)
|
||||
if (!bounds) {
|
||||
return
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { FeatureSource, UpdatableFeatureSource } from "../FeatureSource"
|
|||
import { Or } from "../../Tags/Or"
|
||||
import FeatureSwitchState from "../../State/FeatureSwitchState"
|
||||
import OverpassFeatureSource from "./OverpassFeatureSource"
|
||||
import { Store, UIEventSource } from "../../UIEventSource"
|
||||
import { ImmutableStore, Store, UIEventSource } from "../../UIEventSource"
|
||||
import OsmFeatureSource from "./OsmFeatureSource"
|
||||
import DynamicGeoJsonTileSource from "../TiledFeatureSource/DynamicGeoJsonTileSource"
|
||||
import { BBox } from "../../BBox"
|
||||
|
@ -28,6 +28,13 @@ export default class ThemeSource extends FeatureSourceMerger {
|
|||
|
||||
public static readonly fromCacheZoomLevel = 15
|
||||
|
||||
/**
|
||||
* This source is _only_ triggered when the data is downloaded for CSV export
|
||||
* @private
|
||||
*/
|
||||
private readonly _downloadAll: OverpassFeatureSource
|
||||
private readonly _mapBounds: Store<BBox>
|
||||
|
||||
constructor(
|
||||
layers: LayerConfig[],
|
||||
featureSwitches: FeatureSwitchState,
|
||||
|
@ -103,11 +110,37 @@ export default class ThemeSource extends FeatureSourceMerger {
|
|||
ThemeSource.setupGeojsonSource(l, mapProperties, isDisplayed(l.id))
|
||||
)
|
||||
|
||||
super(...geojsonSources, ...Array.from(fromCache.values()), ...mvtSources, ...nonMvtSources)
|
||||
const downloadAllBounds: UIEventSource<BBox> = new UIEventSource<BBox>(undefined)
|
||||
const downloadAll = new OverpassFeatureSource(
|
||||
{
|
||||
layers: layers.filter((l) => l.isNormal()),
|
||||
bounds: mapProperties.bounds,
|
||||
zoom: mapProperties.zoom,
|
||||
overpassUrl: featureSwitches.overpassUrl,
|
||||
overpassTimeout: featureSwitches.overpassTimeout,
|
||||
overpassMaxZoom: new ImmutableStore(99),
|
||||
widenFactor: 0,
|
||||
},
|
||||
{
|
||||
ignoreZoom: true,
|
||||
}
|
||||
)
|
||||
|
||||
super(
|
||||
...geojsonSources,
|
||||
...Array.from(fromCache.values()),
|
||||
...mvtSources,
|
||||
...nonMvtSources,
|
||||
downloadAll
|
||||
)
|
||||
|
||||
this.isLoading = isLoading
|
||||
supportsForceDownload.push(...geojsonSources)
|
||||
supportsForceDownload.push(...mvtSources) // Non-mvt sources are handled by overpass
|
||||
|
||||
this._mapBounds = mapProperties.bounds
|
||||
this._downloadAll = downloadAll
|
||||
|
||||
this.supportsForceDownload = supportsForceDownload
|
||||
}
|
||||
|
||||
|
@ -211,8 +244,9 @@ export default class ThemeSource extends FeatureSourceMerger {
|
|||
}
|
||||
|
||||
public async downloadAll() {
|
||||
console.log("Downloading all data")
|
||||
await Promise.all(this.supportsForceDownload.map((i) => i.updateAsync()))
|
||||
console.log("Downloading all data:")
|
||||
await this._downloadAll.updateAsync(this._mapBounds.data)
|
||||
// await Promise.all(this.supportsForceDownload.map((i) => i.updateAsync()))
|
||||
console.log("Done")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -829,7 +829,7 @@ export class GeoOperations {
|
|||
}
|
||||
return undefined
|
||||
default:
|
||||
throw "Unkown location type: " + location + " for feature " + feature.properties.id
|
||||
throw "Unknown location type: " + location + " for feature " + feature.properties.id
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -68,7 +68,7 @@ export default class AllImageProviders {
|
|||
|
||||
private static readonly _cachedImageStores: Record<string, Store<ProvidedImage[]>> = {}
|
||||
/**
|
||||
* Tries to extract all image data for this image. Cachedon tags?.data?.id
|
||||
* Tries to extract all image data for this image. Cached on tags?.data?.id
|
||||
*/
|
||||
public static LoadImagesFor(
|
||||
tags: Store<Record<string, string>>,
|
||||
|
@ -78,8 +78,9 @@ export default class AllImageProviders {
|
|||
return undefined
|
||||
}
|
||||
const id = tags?.data?.id
|
||||
if(this._cachedImageStores[id]){
|
||||
return this._cachedImageStores[id]
|
||||
const cachekey = id + (tagKey?.join(";") ?? "")
|
||||
if (this._cachedImageStores[cachekey]) {
|
||||
return this._cachedImageStores[cachekey]
|
||||
}
|
||||
|
||||
const source = new UIEventSource([])
|
||||
|
@ -90,6 +91,7 @@ export default class AllImageProviders {
|
|||
However, we override them if a custom image tag is set, e.g. 'image:menu'
|
||||
*/
|
||||
const prefixes = tagKey ?? imageProvider.defaultKeyPrefixes
|
||||
console.log("Prefixes are", tagKey, prefixes)
|
||||
const singleSource = tags.bindD((tags) => imageProvider.getRelevantUrls(tags, prefixes))
|
||||
allSources.push(singleSource)
|
||||
singleSource.addCallbackAndRunD((_) => {
|
||||
|
@ -98,21 +100,19 @@ export default class AllImageProviders {
|
|||
source.set(dedup)
|
||||
})
|
||||
}
|
||||
this._cachedImageStores[id] = source
|
||||
this._cachedImageStores[cachekey] = source
|
||||
return source
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of URLs, tries to detect the images. Used in e.g. the comments
|
||||
* @param url
|
||||
*/
|
||||
public static loadImagesFrom(urls: string[]): Store<ProvidedImage[]> {
|
||||
const tags = {
|
||||
id: "na",
|
||||
id: urls.join(";"),
|
||||
}
|
||||
for (let i = 0; i < urls.length; i++) {
|
||||
const url = urls[i]
|
||||
tags["image:" + i] = url
|
||||
tags["image:" + i] = urls[i]
|
||||
}
|
||||
return this.LoadImagesFor(new ImmutableStore(tags))
|
||||
}
|
||||
|
|
|
@ -89,7 +89,7 @@ export default abstract class ImageProvider {
|
|||
|
||||
public abstract apiUrls(): string[]
|
||||
|
||||
public static async offerImageAsDownload(image: ProvidedImage){
|
||||
public static async offerImageAsDownload(image: ProvidedImage) {
|
||||
const response = await fetch(image.url_hd ?? image.url)
|
||||
const blob = await response.blob()
|
||||
Utils.offerContentsAsDownloadableFile(blob, new URL(image.url).pathname.split("/").at(-1), {
|
||||
|
|
|
@ -157,14 +157,15 @@ export class ImageUploadManager {
|
|||
blob: File,
|
||||
targetKey: string | undefined,
|
||||
noblur: boolean,
|
||||
feature?: Feature
|
||||
feature?: Feature,
|
||||
ignoreGps: boolean = false
|
||||
): Promise<UploadResult> {
|
||||
this.increaseCountFor(this._uploadStarted, featureId)
|
||||
let key: string
|
||||
let value: string
|
||||
let absoluteUrl: string
|
||||
let location: [number, number] = undefined
|
||||
if (this._gps.data) {
|
||||
if (this._gps.data && !ignoreGps) {
|
||||
location = [this._gps.data.longitude, this._gps.data.latitude]
|
||||
}
|
||||
if (location === undefined || location?.some((l) => l === undefined)) {
|
||||
|
|
|
@ -11,6 +11,7 @@ export class Imgur extends ImageProvider {
|
|||
public readonly defaultKeyPrefixes: string[] = ["image"]
|
||||
public static readonly apiUrl = "https://api.imgur.com/3/image"
|
||||
public static readonly supportingUrls = ["https://i.imgur.com"]
|
||||
|
||||
private constructor() {
|
||||
super()
|
||||
}
|
||||
|
@ -37,6 +38,37 @@ export class Imgur extends ImageProvider {
|
|||
return undefined
|
||||
}
|
||||
|
||||
public static parseLicense(descr: string) {
|
||||
const data: Record<string, string> = {}
|
||||
|
||||
if (!descr) {
|
||||
return undefined
|
||||
}
|
||||
if (descr.toLowerCase() === "cc0") {
|
||||
data.author = "Unknown"
|
||||
data.license = "CC0"
|
||||
} else {
|
||||
for (const tag of descr.split("\n")) {
|
||||
const kv = tag.split(":")
|
||||
if (kv.length < 2) {
|
||||
continue
|
||||
}
|
||||
const k = kv[0]
|
||||
data[k] = kv[1]?.replace(/\r/g, "")
|
||||
}
|
||||
}
|
||||
if (Object.keys(data).length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const licenseInfo = new LicenseInfo()
|
||||
|
||||
licenseInfo.licenseShortName = data.license
|
||||
licenseInfo.artist = data.author
|
||||
|
||||
return licenseInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the attribution and license info for the picture at the given URL
|
||||
*
|
||||
|
@ -56,9 +88,14 @@ export class Imgur extends ImageProvider {
|
|||
*
|
||||
*
|
||||
*/
|
||||
public async DownloadAttribution(providedImage: { url: string }): Promise<LicenseInfo> {
|
||||
public async DownloadAttribution(
|
||||
providedImage: {
|
||||
url: string
|
||||
},
|
||||
withResponse?: (obj) => void
|
||||
): Promise<LicenseInfo> {
|
||||
const url = providedImage.url
|
||||
const hash = url.substr("https://i.imgur.com/".length).split(/\.jpe?g/i)[0]
|
||||
const hash = url.substr("https://i.imgur.com/".length).split(/(\.jpe?g)|(\.png)/i)[0]
|
||||
|
||||
const apiUrl = "https://api.imgur.com/3/image/" + hash
|
||||
const response = await Utils.downloadJsonCached<{
|
||||
|
@ -66,24 +103,17 @@ export class Imgur extends ImageProvider {
|
|||
}>(apiUrl, 365 * 24 * 60 * 60, {
|
||||
Authorization: "Client-ID " + Constants.ImgurApiKey,
|
||||
})
|
||||
|
||||
const descr = response.data.description ?? ""
|
||||
const data: any = {}
|
||||
const imgurData = response.data
|
||||
|
||||
for (const tag of descr.split("\n")) {
|
||||
const kv = tag.split(":")
|
||||
const k = kv[0]
|
||||
data[k] = kv[1]?.replace(/\r/g, "")
|
||||
if (withResponse) {
|
||||
withResponse(response)
|
||||
}
|
||||
|
||||
const licenseInfo = new LicenseInfo()
|
||||
const imgurData = response.data
|
||||
const license = Imgur.parseLicense(imgurData.description ?? "")
|
||||
if (license) {
|
||||
license.views = imgurData.views
|
||||
license.date = new Date(Number(imgurData.datetime) * 1000)
|
||||
}
|
||||
|
||||
licenseInfo.licenseShortName = data.license
|
||||
licenseInfo.artist = data.author
|
||||
licenseInfo.date = new Date(Number(imgurData.datetime) * 1000)
|
||||
licenseInfo.views = imgurData.views
|
||||
|
||||
return licenseInfo
|
||||
return license
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ import Panoramax_bw from "../../assets/svg/Panoramax_bw.svelte"
|
|||
import Link from "../../UI/Base/Link"
|
||||
|
||||
export default class PanoramaxImageProvider extends ImageProvider {
|
||||
public static readonly singleton = new PanoramaxImageProvider()
|
||||
public static readonly singleton: PanoramaxImageProvider = new PanoramaxImageProvider()
|
||||
private static readonly xyz = new PanoramaxXYZ()
|
||||
private static defaultPanoramax = new AuthorizedPanoramax(
|
||||
Constants.panoramax.url,
|
||||
|
@ -126,7 +126,11 @@ export default class PanoramaxImageProvider extends ImageProvider {
|
|||
if (!Panoramax.isId(value)) {
|
||||
return undefined
|
||||
}
|
||||
return [await this.getInfoFor(value).then((r) => this.featureToImage(<any>r))]
|
||||
return [await this.getInfo(value)]
|
||||
}
|
||||
|
||||
public async getInfo(hash: string): Promise<ProvidedImage> {
|
||||
return await this.getInfoFor(hash).then((r) => this.featureToImage(<any>r))
|
||||
}
|
||||
|
||||
getRelevantUrls(tags: Record<string, string>, prefixes: string[]): Store<ProvidedImage[]> {
|
||||
|
@ -138,12 +142,14 @@ export default class PanoramaxImageProvider extends ImageProvider {
|
|||
}
|
||||
return data?.some(
|
||||
(img) =>
|
||||
img?.status !== undefined && img?.status !== "ready" && img?.status !== "broken" && img?.status !== "hidden"
|
||||
img?.status !== undefined &&
|
||||
img?.status !== "ready" &&
|
||||
img?.status !== "broken" &&
|
||||
img?.status !== "hidden"
|
||||
)
|
||||
}
|
||||
|
||||
Stores.Chronic(1500, () => hasLoading(source.data)).addCallback((_) => {
|
||||
console.log("Testing panoramax URLS again as some were loading", source.data, hasLoading(source.data))
|
||||
Stores.Chronic(1500, () => hasLoading(source.data)).addCallback(() => {
|
||||
super.getRelevantUrlsFor(tags, prefixes).then((data) => {
|
||||
source.set(data)
|
||||
return !hasLoading(data)
|
||||
|
@ -170,12 +176,12 @@ export default class PanoramaxImageProvider extends ImageProvider {
|
|||
return ["https://panoramax.mapcomplete.org", "https://panoramax.xyz"]
|
||||
}
|
||||
|
||||
public static getPanoramaxInstance (host: string){
|
||||
public static getPanoramaxInstance(host: string) {
|
||||
host = new URL(host).host
|
||||
if(new URL(this.defaultPanoramax.host).host === host){
|
||||
if (new URL(this.defaultPanoramax.host).host === host) {
|
||||
return this.defaultPanoramax
|
||||
}
|
||||
if(new URL(this.xyz.host).host === host){
|
||||
if (new URL(this.xyz.host).host === host) {
|
||||
return this.xyz
|
||||
}
|
||||
return new Panoramax(host)
|
||||
|
@ -185,9 +191,9 @@ export default class PanoramaxImageProvider extends ImageProvider {
|
|||
export class PanoramaxUploader implements ImageUploader {
|
||||
public readonly panoramax: AuthorizedPanoramax
|
||||
maxFileSizeInMegabytes = 100 * 1000 * 1000 // 100MB
|
||||
private readonly _targetSequence: Store<string>
|
||||
private readonly _targetSequence?: Store<string>
|
||||
|
||||
constructor(url: string, token: string, targetSequence: Store<string>) {
|
||||
constructor(url: string, token: string, targetSequence?: Store<string>) {
|
||||
this._targetSequence = targetSequence
|
||||
this.panoramax = new AuthorizedPanoramax(url, token)
|
||||
}
|
||||
|
@ -197,7 +203,8 @@ export class PanoramaxUploader implements ImageUploader {
|
|||
currentGps: [number, number],
|
||||
author: string,
|
||||
noblur: boolean = false,
|
||||
sequenceId?: string
|
||||
sequenceId?: string,
|
||||
datetime?: string
|
||||
): Promise<{
|
||||
key: string
|
||||
value: string
|
||||
|
@ -205,22 +212,42 @@ export class PanoramaxUploader implements ImageUploader {
|
|||
}> {
|
||||
// https://panoramax.openstreetmap.fr/api/docs/swagger#/
|
||||
|
||||
let [lon, lat] = currentGps
|
||||
let datetime = new Date().toISOString()
|
||||
let [lon, lat] = currentGps ?? [undefined, undefined]
|
||||
datetime ??= new Date().toISOString()
|
||||
try {
|
||||
const tags = await ExifReader.load(blob)
|
||||
const [[latD], [latM], [latS, latSDenom]] = <
|
||||
[[number, number], [number, number], [number, number]]
|
||||
>tags?.GPSLatitude.value
|
||||
>tags?.GPSLatitude?.value
|
||||
const [[lonD], [lonM], [lonS, lonSDenom]] = <
|
||||
[[number, number], [number, number], [number, number]]
|
||||
>tags?.GPSLongitude.value
|
||||
lat = latD + latM / 60 + latS / (3600 * latSDenom)
|
||||
lon = lonD + lonM / 60 + lonS / (3600 * lonSDenom)
|
||||
|
||||
const [date, time] = tags.DateTime.value[0].split(" ")
|
||||
datetime = new Date(date.replaceAll(":", "-") + "T" + time).toISOString()
|
||||
>tags?.GPSLongitude?.value
|
||||
|
||||
const exifLat = latD + latM / 60 + latS / (3600 * latSDenom)
|
||||
const exifLon = lonD + lonM / 60 + lonS / (3600 * lonSDenom)
|
||||
if (
|
||||
typeof exifLat === "number" &&
|
||||
!isNaN(exifLat) &&
|
||||
typeof exifLon === "number" &&
|
||||
!isNaN(exifLon) &&
|
||||
!(exifLat === 0 && exifLon === 0)
|
||||
) {
|
||||
lat = exifLat
|
||||
lon = exifLon
|
||||
}
|
||||
const [date, time] =( tags.DateTime.value[0] ?? tags.DateTimeOriginal.value[0] ?? tags.GPSDateStamp ?? tags["Date Created"]).split(" ")
|
||||
const exifDatetime = new Date(date.replaceAll(":", "-") + "T" + time)
|
||||
if (exifDatetime.getFullYear() === 1970) {
|
||||
// The data probably got reset to the epoch
|
||||
// we don't use the value
|
||||
console.log(
|
||||
"Datetime from picture is probably invalid:",
|
||||
exifDatetime,
|
||||
"using 'now' instead"
|
||||
)
|
||||
} else {
|
||||
datetime = exifDatetime.toISOString()
|
||||
}
|
||||
console.log("Tags are", tags)
|
||||
} catch (e) {
|
||||
console.error("Could not read EXIF-tags")
|
||||
|
|
|
@ -532,7 +532,7 @@ export class OsmConnection {
|
|||
this.auth = new osmAuth({
|
||||
client_id: this._oauth_config.oauth_client_id,
|
||||
url: this._oauth_config.url,
|
||||
scope: "read_prefs write_prefs write_api write_gpx write_notes openid",
|
||||
scope: "read_prefs write_prefs write_api write_gpx write_notes",
|
||||
redirect_uri: Utils.runningFromConsole
|
||||
? "https://mapcomplete.org/land.html"
|
||||
: window.location.protocol + "//" + window.location.host + "/land.html",
|
||||
|
|
|
@ -14,7 +14,7 @@ export default class OsmObjectDownloader {
|
|||
readonly isUploading: Store<boolean>
|
||||
}
|
||||
private readonly backend: string
|
||||
private historyCache = new Map<string, UIEventSource<OsmObject[]>>()
|
||||
private historyCache = new Map<string, Promise<OsmObject[]>>()
|
||||
|
||||
constructor(
|
||||
backend: string = "https://api.openstreetmap.org",
|
||||
|
@ -75,49 +75,51 @@ export default class OsmObjectDownloader {
|
|||
return await this.applyPendingChanges(obj)
|
||||
}
|
||||
|
||||
public DownloadHistory(id: NodeId): UIEventSource<OsmNode[]>
|
||||
|
||||
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)
|
||||
}
|
||||
private async _downloadHistoryUncached(id: string): Promise<OsmObject[]> {
|
||||
const splitted = id.split("/")
|
||||
const type = splitted[0]
|
||||
const idN = Number(splitted[1])
|
||||
const src = new UIEventSource<OsmObject[]>([])
|
||||
this.historyCache.set(id, src)
|
||||
Utils.downloadJsonCached(
|
||||
const data = await Utils.downloadJsonCached(
|
||||
`${this.backend}api/0.6/${type}/${idN}/history`,
|
||||
10 * 60 * 1000
|
||||
).then((data) => {
|
||||
const elements: any[] = data.elements
|
||||
const osmObjects: OsmObject[] = []
|
||||
for (const element of elements) {
|
||||
let osmObject: OsmObject = null
|
||||
element.nodes = []
|
||||
switch (type) {
|
||||
case "node":
|
||||
osmObject = new OsmNode(idN, element)
|
||||
break
|
||||
case "way":
|
||||
osmObject = new OsmWay(idN, element)
|
||||
break
|
||||
case "relation":
|
||||
osmObject = new OsmRelation(idN, element)
|
||||
break
|
||||
}
|
||||
osmObject?.SaveExtraData(element, [])
|
||||
osmObjects.push(osmObject)
|
||||
)
|
||||
const elements: [] = data["elements"]
|
||||
const osmObjects: OsmObject[] = []
|
||||
for (const element of elements) {
|
||||
let osmObject: OsmObject = null
|
||||
element["nodes"] = []
|
||||
switch (type) {
|
||||
case "node":
|
||||
osmObject = new OsmNode(idN, element)
|
||||
break
|
||||
case "way":
|
||||
osmObject = new OsmWay(idN, element)
|
||||
break
|
||||
case "relation":
|
||||
osmObject = new OsmRelation(idN, element)
|
||||
break
|
||||
}
|
||||
src.setData(osmObjects)
|
||||
})
|
||||
return src
|
||||
osmObject?.SaveExtraData(element, [])
|
||||
osmObjects.push(osmObject)
|
||||
}
|
||||
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._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) {
|
||||
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;`
|
||||
*/
|
||||
public buildScript(bbox: string, postCall: string = "", pretty = false): string {
|
||||
const filters = this._filter.asOverpass()
|
||||
const filters = this._filter?.asOverpass() ?? []
|
||||
let filter = ""
|
||||
for (const filterOr of filters) {
|
||||
if (pretty) {
|
||||
|
@ -97,12 +100,13 @@ export class Overpass {
|
|||
}
|
||||
}
|
||||
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;`
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the actual script to execute on Overpass with geocoding
|
||||
* 'PostCall' can be used to set an extra range, see 'AsOverpassTurboLink'
|
||||
|
|
|
@ -1,10 +1,5 @@
|
|||
import { Store, Stores, UIEventSource } from "../UIEventSource"
|
||||
import GeocodingProvider, {
|
||||
GeocodeResult,
|
||||
GeocodingOptions,
|
||||
ReverseGeocodingProvider,
|
||||
ReverseGeocodingResult,
|
||||
} from "./GeocodingProvider"
|
||||
import { Store, Stores } from "../UIEventSource"
|
||||
import GeocodingProvider, { GeocodeResult, GeocodingOptions } from "./GeocodingProvider"
|
||||
import { decode as pluscode_decode } from "pluscodes"
|
||||
|
||||
export default class OpenLocationCodeSearch implements GeocodingProvider {
|
||||
|
|
|
@ -562,4 +562,15 @@ export default class UserRelatedState {
|
|||
|
||||
return amendedPrefs
|
||||
}
|
||||
|
||||
/**
|
||||
* The disabled questions for this theme and layer
|
||||
*/
|
||||
public getThemeDisabled(themeId: string, layerId: string): UIEventSource<string[]> {
|
||||
const flatSource = this.osmConnection.getPreference(
|
||||
"disabled-questions-" + themeId + "-" + layerId,
|
||||
"[]"
|
||||
)
|
||||
return UIEventSource.asObject<string[]>(flatSource, [])
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,10 @@ export class And extends TagsFilter {
|
|||
console.error("Assertion failed: invalid subtags:", and)
|
||||
throw "Assertion failed: invalid subtags found"
|
||||
}
|
||||
if (and.some((p) => p === undefined)) {
|
||||
console.error("Assertion failed: invalid subtags:", and)
|
||||
throw "Assertion failed: invalid subtags found (undefined)"
|
||||
}
|
||||
}
|
||||
|
||||
public static construct(and: ReadonlyArray<TagsFilter>): TagsFilter
|
||||
|
|
|
@ -127,10 +127,9 @@ export class RegexTag extends TagsFilter {
|
|||
return `${this.key}${invert}~${v}`
|
||||
}
|
||||
return `${this.key}${invert}~i~${v}`
|
||||
|
||||
}
|
||||
|
||||
const key :string = RegexTag.source(this.key, false)
|
||||
const key: string = RegexTag.source(this.key, false)
|
||||
return `${key}${invert}~${caseInvariant ? "i~" : ""}~${v}`
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
|
|||
import key_counts from "../../assets/key_totals.json"
|
||||
|
||||
import { ConversionContext } from "../../Models/ThemeConfig/Conversion/ConversionContext"
|
||||
import { TagsFilterClosed, UploadableTag } from "./TagTypes"
|
||||
import { FlatTag, TagsFilterClosed, UploadableTag } from "./TagTypes"
|
||||
|
||||
type Tags = Record<string, string>
|
||||
|
||||
|
@ -102,12 +102,12 @@ export class TagUtils {
|
|||
"~i~~": {
|
||||
name: "Key and value should match a given regex; value is case-invariant",
|
||||
overpassSupport: true,
|
||||
docs: "Similar to ~~, except that the value is case-invariant"
|
||||
docs: "Similar to ~~, except that the value is case-invariant",
|
||||
},
|
||||
"!~i~~": {
|
||||
name: "Key and value should match a given regex; value is case-invariant",
|
||||
overpassSupport: true,
|
||||
docs: "Similar to !~~, except that the value is case-invariant"
|
||||
docs: "Similar to !~~, except that the value is case-invariant",
|
||||
},
|
||||
":=": {
|
||||
name: "Substitute `... {some_key} ...` and match `key`",
|
||||
|
@ -504,6 +504,8 @@ export class TagUtils {
|
|||
* regex.matchesProperties({maxspeed: "50 mph"}) // => true
|
||||
*/
|
||||
|
||||
public static Tag(json: string, context?: string | ConversionContext): FlatTag
|
||||
public static Tag(json: TagConfigJson, context?: string | ConversionContext): TagsFilterClosed
|
||||
public static Tag(
|
||||
json: TagConfigJson,
|
||||
context: string | ConversionContext = ""
|
||||
|
@ -802,7 +804,7 @@ export class TagUtils {
|
|||
|
||||
if (tag.indexOf("~~") >= 0 || tag.indexOf("~i~~") >= 0) {
|
||||
const caseInvariant = tag.indexOf("~i~~") >= 0
|
||||
const split = Utils.SplitFirst(tag, caseInvariant ? "~i~~" : "~~")
|
||||
const split = Utils.SplitFirst(tag, caseInvariant ? "~i~~" : "~~")
|
||||
let keyRegex: RegExp
|
||||
if (split[0] === "*") {
|
||||
keyRegex = new RegExp(".+", "i")
|
||||
|
@ -813,7 +815,7 @@ export class TagUtils {
|
|||
if (split[1] === "*") {
|
||||
valueRegex = new RegExp(".+", "s")
|
||||
} else {
|
||||
valueRegex = new RegExp("^(" + split[1] + ")$",caseInvariant ? "si": "s" )
|
||||
valueRegex = new RegExp("^(" + split[1] + ")$", caseInvariant ? "si" : "s")
|
||||
}
|
||||
return new RegexTag(keyRegex, valueRegex)
|
||||
}
|
||||
|
@ -866,7 +868,7 @@ export class TagUtils {
|
|||
tag +
|
||||
". To indicate a missing tag, use '" +
|
||||
split[0] +
|
||||
"!=' instead"
|
||||
"=' instead"
|
||||
)
|
||||
}
|
||||
if (split[1] === "") {
|
||||
|
|
|
@ -9,7 +9,7 @@ export class Stores {
|
|||
const source = new UIEventSource<Date>(undefined)
|
||||
|
||||
function run() {
|
||||
if(asLong !== undefined && !asLong()){
|
||||
if (asLong !== undefined && !asLong()) {
|
||||
return
|
||||
}
|
||||
source.setData(new Date())
|
||||
|
@ -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
|
||||
* UIEventSource.asInt(new UIEventSource("123")).data // => 123
|
||||
|
|
|
@ -371,6 +371,10 @@ export default class LinkedDataLoader {
|
|||
const match = maxstay.match(/P([0-9]+)D/)
|
||||
if (match) {
|
||||
const days = Number(match[1])
|
||||
if (days === 30) {
|
||||
// 30 is the default which is set if velopark didn't know the actual value
|
||||
return undefined
|
||||
}
|
||||
if (days === 1) {
|
||||
return "1 day"
|
||||
}
|
||||
|
|
|
@ -42,7 +42,7 @@ export class LocalStorageSource {
|
|||
}
|
||||
const source = new UIEventSource<string>(saved ?? defaultValue, "localstorage:" + key)
|
||||
|
||||
if(!Utils.runningFromConsole){
|
||||
if (!Utils.runningFromConsole) {
|
||||
source.addCallback((data) => {
|
||||
if (data === undefined || data === "" || data === null) {
|
||||
localStorage.removeItem(key)
|
||||
|
|
|
@ -80,7 +80,10 @@ export default class VeloparkLoader {
|
|||
1,
|
||||
g.maximumParkingDuration.length - 1
|
||||
)
|
||||
properties.maxstay = duration + " days"
|
||||
if (duration !== "30") {
|
||||
// We don't set maxstay if it is 30, they are the default value that velopark chose for "unknown"
|
||||
properties.maxstay = duration + " days"
|
||||
}
|
||||
}
|
||||
properties.access = g.publicAccess ?? "yes" ? "yes" : "no"
|
||||
const prefix = "http://schema.org/"
|
||||
|
|
|
@ -40,7 +40,7 @@ export default class Constants {
|
|||
"import_candidate",
|
||||
"usersettings",
|
||||
"icons",
|
||||
"filters",
|
||||
"filters"
|
||||
] as const
|
||||
/**
|
||||
* Layer IDs of layers which have special properties through built-in hooks
|
||||
|
@ -68,7 +68,6 @@ export default class Constants {
|
|||
|
||||
mapCompleteHelpUnlock: 50,
|
||||
themeGeneratorReadOnlyUnlock: 50,
|
||||
themeGeneratorFullUnlock: 500,
|
||||
addNewPointWithUnreadMessagesUnlock: 500,
|
||||
|
||||
importHelperUnlock: 5000,
|
||||
|
@ -138,6 +137,7 @@ export default class Constants {
|
|||
public static osmAuthConfig: AuthConfig = Constants.config.oauth_credentials
|
||||
public static nominatimEndpoint: string = Constants.config.nominatimEndpoint
|
||||
public static photonEndpoint: string = Constants.config.photonEndpoint
|
||||
public static weblate: string = "https://translate.mapcomplete.org/"
|
||||
|
||||
public static linkedDataProxy: string = Constants.config["jsonld-proxy"]
|
||||
/**
|
||||
|
|
|
@ -36,7 +36,7 @@ export default class FilteredLayer {
|
|||
constructor(
|
||||
layer: LayerConfig,
|
||||
appliedFilters?: ReadonlyMap<string, UIEventSource<undefined | number | string>>,
|
||||
isDisplayed?: UIEventSource<boolean>,
|
||||
isDisplayed?: UIEventSource<boolean>
|
||||
) {
|
||||
this.layerDef = layer
|
||||
this.isDisplayed = isDisplayed ?? new UIEventSource(true)
|
||||
|
@ -82,25 +82,25 @@ export default class FilteredLayer {
|
|||
layer: LayerConfig,
|
||||
context: string,
|
||||
osmConnection: OsmConnection,
|
||||
enabledByDefault?: Store<boolean>,
|
||||
enabledByDefault?: Store<boolean>
|
||||
) {
|
||||
let isDisplayed: UIEventSource<boolean>
|
||||
if (layer.syncSelection === "local") {
|
||||
isDisplayed = LocalStorageSource.getParsed(
|
||||
context + "-layer-" + layer.id + "-enabled",
|
||||
layer.shownByDefault,
|
||||
layer.shownByDefault
|
||||
)
|
||||
} else if (layer.syncSelection === "theme-only") {
|
||||
isDisplayed = FilteredLayer.getPref(
|
||||
osmConnection,
|
||||
context + "-layer-" + layer.id + "-enabled",
|
||||
layer,
|
||||
layer
|
||||
)
|
||||
} else if (layer.syncSelection === "global") {
|
||||
isDisplayed = FilteredLayer.getPref(
|
||||
osmConnection,
|
||||
"layer-" + layer.id + "-enabled",
|
||||
layer,
|
||||
layer
|
||||
)
|
||||
} else {
|
||||
let isShown = layer.shownByDefault
|
||||
|
@ -110,7 +110,7 @@ export default class FilteredLayer {
|
|||
isDisplayed = QueryParameters.GetBooleanQueryParameter(
|
||||
FilteredLayer.queryParameterKey(layer),
|
||||
isShown,
|
||||
"Whether or not layer " + layer.id + " is shown",
|
||||
"Whether or not layer " + layer.id + " is shown"
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -145,7 +145,7 @@ export default class FilteredLayer {
|
|||
*/
|
||||
private static fieldsToTags(
|
||||
option: FilterConfigOption,
|
||||
fieldstate: string | Record<string, string>,
|
||||
fieldstate: string | Record<string, string>
|
||||
): TagsFilter | undefined {
|
||||
let properties: Record<string, string>
|
||||
if (typeof fieldstate === "string") {
|
||||
|
@ -181,7 +181,7 @@ export default class FilteredLayer {
|
|||
private static getPref(
|
||||
osmConnection: OsmConnection,
|
||||
key: string,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): UIEventSource<boolean> {
|
||||
return osmConnection.GetPreference(key, layer.shownByDefault + "").sync(
|
||||
(v) => {
|
||||
|
@ -196,7 +196,7 @@ export default class FilteredLayer {
|
|||
return undefined
|
||||
}
|
||||
return "" + b
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -210,7 +210,11 @@ export default class FilteredLayer {
|
|||
* - the specified 'global filters'
|
||||
* - the 'isShown'-filter set by the layer
|
||||
*/
|
||||
public isShown(properties: Record<string, string>, globalFilters?: GlobalFilter[]): boolean {
|
||||
public isShown(
|
||||
properties: Record<string, string>,
|
||||
globalFilters?: GlobalFilter[],
|
||||
zoomlevel?: number
|
||||
): boolean {
|
||||
if (properties._deleted === "yes") {
|
||||
return false
|
||||
}
|
||||
|
@ -219,13 +223,20 @@ export default class FilteredLayer {
|
|||
if (neededTags !== undefined) {
|
||||
const doesMatch = neededTags.matchesProperties(properties)
|
||||
if (globalFilter.forceShowOnMatch) {
|
||||
return doesMatch || this.isDisplayed.data
|
||||
}
|
||||
if (!doesMatch) {
|
||||
if (doesMatch) {
|
||||
return true
|
||||
}
|
||||
} else if (!doesMatch) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
if(!this.isDisplayed.data){
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const isShown: TagsFilter = this.layerDef.isShown
|
||||
if (isShown !== undefined && !isShown.matchesProperties(properties)) {
|
||||
|
@ -240,6 +251,13 @@ export default class FilteredLayer {
|
|||
}
|
||||
}
|
||||
|
||||
if (
|
||||
zoomlevel !== undefined &&
|
||||
(this.layerDef.minzoom > zoomlevel || this.layerDef.minzoomVisible < zoomlevel)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ export interface GlobalFilter {
|
|||
/**
|
||||
* If set, this object will be shown instead of hidden, even if the layer is not displayed
|
||||
*/
|
||||
forceShowOnMatch?: boolean,
|
||||
forceShowOnMatch?: boolean
|
||||
state: number | string | undefined
|
||||
id: string
|
||||
onNewPoint: {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { Store, UIEventSource } from "../Logic/UIEventSource"
|
||||
import { BBox } from "../Logic/BBox"
|
||||
import { RasterLayerPolygon } from "./RasterLayers"
|
||||
import { Feature } from "geojson"
|
||||
|
||||
export interface KeyNavigationEvent {
|
||||
date: Date
|
||||
|
@ -19,7 +20,14 @@ export interface MapProperties {
|
|||
readonly allowRotating: UIEventSource<true | boolean>
|
||||
readonly rotation: UIEventSource<number>
|
||||
readonly pitch: UIEventSource<number>
|
||||
readonly lastClickLocation: Store<{ lon: number; lat: number }>
|
||||
readonly lastClickLocation: Store<{
|
||||
lon: number
|
||||
lat: number
|
||||
/**
|
||||
* The nearest feature from a MapComplete layer
|
||||
*/
|
||||
nearestFeature?: Feature
|
||||
}>
|
||||
readonly allowZooming: UIEventSource<true | boolean>
|
||||
readonly useTerrain: Store<boolean>
|
||||
readonly showScale: UIEventSource<boolean>
|
||||
|
|
|
@ -141,7 +141,7 @@ class ExpandFilter extends DesugaringStep<LayerConfigJson> {
|
|||
"Found a matching tagRendering to base a filter on, but this tagRendering does not contain any mappings"
|
||||
)
|
||||
}
|
||||
const qtr = (<QuestionableTagRenderingConfigJson>tr)
|
||||
const qtr = <QuestionableTagRenderingConfigJson>tr
|
||||
const options = qtr.mappings.map((mapping) => {
|
||||
let icon: string = mapping.icon?.["path"] ?? mapping.icon
|
||||
let emoji: string = undefined
|
||||
|
@ -149,12 +149,15 @@ class ExpandFilter extends DesugaringStep<LayerConfigJson> {
|
|||
emoji = icon
|
||||
icon = undefined
|
||||
}
|
||||
let osmTags = TagUtils.Tag( mapping.if)
|
||||
if(qtr.multiAnswer && osmTags instanceof Tag){
|
||||
osmTags = new RegexTag(osmTags.key, new RegExp("^(.+;)?"+osmTags.value+"(;.+)$","is"))
|
||||
let osmTags = TagUtils.Tag(mapping.if)
|
||||
if (qtr.multiAnswer && osmTags instanceof Tag) {
|
||||
osmTags = new RegexTag(
|
||||
osmTags.key,
|
||||
new RegExp("^(.+;)?" + osmTags.value + "(;.+)$", "is")
|
||||
)
|
||||
}
|
||||
if(mapping.alsoShowIf){
|
||||
osmTags= new Or([osmTags, TagUtils.Tag(mapping.alsoShowIf)])
|
||||
if (mapping.alsoShowIf) {
|
||||
osmTags = new Or([osmTags, TagUtils.Tag(mapping.alsoShowIf)])
|
||||
}
|
||||
|
||||
return <FilterConfigOptionJson>{
|
||||
|
|
|
@ -58,7 +58,7 @@ export default class DeleteConfig {
|
|||
} else if (json.omitDefaultDeleteReasons) {
|
||||
const forbidden = <string[]>json.omitDefaultDeleteReasons
|
||||
deleteReasons = deleteReasons.filter(
|
||||
(dl) => forbidden.indexOf(dl.changesetMessage) < 0,
|
||||
(dl) => forbidden.indexOf(dl.changesetMessage) < 0
|
||||
)
|
||||
}
|
||||
for (const defaultDeleteReason of deleteReasons) {
|
||||
|
@ -90,7 +90,7 @@ export default class DeleteConfig {
|
|||
if (json.softDeletionTags !== undefined) {
|
||||
this.softDeletionTags = TagUtils.Tag(
|
||||
json.softDeletionTags,
|
||||
`${context}.softDeletionTags`,
|
||||
`${context}.softDeletionTags`
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -319,7 +319,7 @@ export interface LayerConfigJson {
|
|||
*
|
||||
* If no presets are defined, the button which invites to add a new preset will not be shown.
|
||||
*</div>
|
||||
* <video controls autoplay muted src='./Docs/Screenshots/AddNewItemScreencast.webm' class='w-64'/>
|
||||
* <video controls autoplay muted src='https://github.com/pietervdvn/MapComplete/raw/refs/heads/master/Docs/Screenshots/AddNewItemScreencast.webm' class='w-64'/>
|
||||
*</div>
|
||||
*
|
||||
* group: presets
|
||||
|
|
|
@ -29,7 +29,7 @@ export default interface PointRenderingConfigJson {
|
|||
/**
|
||||
* question: At what location should this icon be shown?
|
||||
* multianswer: true
|
||||
* suggestions: return [{if: "value=point",then: "Show an icon for point (node) objects"},{if: "value=centroid",then: "Show an icon for line or polygon (way) objects at their centroid location"}, {if: "value=start",then: "Show an icon for line (way) objects at the start"},{if: "value=end",then: "Show an icon for line (way) object at the end"},{if: "value=projected_centerpoint",then: "Show an icon for line (way) object near the centroid location, but moved onto the line. Does not show an item on polygons"}, {if: "value=polygon_centroid",then: "Show an icon at a polygon centroid (but not if it is a way)"}]
|
||||
* suggestions: return [{if: "value=point",then: "Show an icon for point (node) objects"},{if: "value=centroid",then: "Show an icon for line or polygon (way) objects at their centroid location"}, {if: "value=start",then: "Show an icon for line (way) objects at the start"},{if: "value=end",then: "Show an icon for line (way) object at the end"},{if: "value=projected_centerpoint",then: "Show an icon for line (way) object near the centroid location, but moved onto the line. Does not show an item on polygons"}, {if: "value=polygon_centroid",then: "Show an icon at a polygon centroid (but not if it is a way)"}, {if: "value=waypoints", then: "Show an icon on every intermediate point of a way"}]
|
||||
*/
|
||||
location: (
|
||||
| "point"
|
||||
|
@ -38,6 +38,7 @@ export default interface PointRenderingConfigJson {
|
|||
| "end"
|
||||
| "projected_centerpoint"
|
||||
| "polygon_centroid"
|
||||
| "waypoints"
|
||||
| string
|
||||
)[]
|
||||
|
||||
|
|
|
@ -321,7 +321,7 @@ export interface QuestionableTagRenderingConfigJson extends TagRenderingConfigJs
|
|||
editButtonAriaLabel?: Translatable
|
||||
|
||||
/**
|
||||
* What labels should be applied on this tagRendering?
|
||||
* question: What labels should be applied on this tagRendering?
|
||||
*
|
||||
* A list of labels. These are strings that are used for various purposes, e.g. to only include a subset of the tagRenderings when reusing a layer
|
||||
*
|
||||
|
@ -330,4 +330,9 @@ export interface QuestionableTagRenderingConfigJson extends TagRenderingConfigJs
|
|||
* - "description": this label is a description used in the search
|
||||
*/
|
||||
labels?: string[]
|
||||
|
||||
/**
|
||||
* question: What tags should be applied when the object is soft-deleted?
|
||||
*/
|
||||
onSoftDelete?: string[]
|
||||
}
|
||||
|
|
|
@ -159,7 +159,7 @@ export default class LayerConfig extends WithContextLoader {
|
|||
if (json["minZoom"] !== undefined) {
|
||||
throw "At " + context + ": minzoom is written all lowercase"
|
||||
}
|
||||
this.minzoomVisible = json.minzoomVisible ?? this.minzoom
|
||||
this.minzoomVisible = json.minzoomVisible ?? 100
|
||||
this.shownByDefault = json.shownByDefault ?? true
|
||||
this.doCount = json.isCounted ?? this.shownByDefault ?? true
|
||||
this.forceLoad = json.forceLoad ?? false
|
||||
|
|
|
@ -41,6 +41,7 @@ export default class PointRenderingConfig extends WithContextLoader {
|
|||
"end",
|
||||
"projected_centerpoint",
|
||||
"polygon_centroid",
|
||||
"waypoints",
|
||||
])
|
||||
public readonly location: Set<
|
||||
| "point"
|
||||
|
@ -49,6 +50,7 @@ export default class PointRenderingConfig extends WithContextLoader {
|
|||
| "end"
|
||||
| "projected_centerpoint"
|
||||
| "polygon_centroid"
|
||||
| "waypoints"
|
||||
| string
|
||||
>
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ import { Feature } from "geojson"
|
|||
import MarkdownUtils from "../../Utils/MarkdownUtils"
|
||||
import { UploadableTag } from "../../Logic/Tags/TagTypes"
|
||||
import LayerConfig from "./LayerConfig"
|
||||
import ComparingTag from "../../Logic/Tags/ComparingTag"
|
||||
|
||||
export interface Mapping {
|
||||
readonly if: UploadableTag
|
||||
|
@ -80,6 +81,8 @@ export default class TagRenderingConfig {
|
|||
public readonly labels: string[]
|
||||
public readonly classes: string[] | undefined
|
||||
|
||||
public readonly onSoftDelete?: ReadonlyArray<UploadableTag>
|
||||
|
||||
constructor(
|
||||
config:
|
||||
| string
|
||||
|
@ -142,6 +145,19 @@ export default class TagRenderingConfig {
|
|||
this.questionhint = Translations.T(json.questionHint, translationKey + ".questionHint")
|
||||
this.questionHintIsMd = json["questionHintIsMd"] ?? false
|
||||
this.description = Translations.T(json.description, translationKey + ".description")
|
||||
if (json.onSoftDelete && !Array.isArray(json.onSoftDelete)) {
|
||||
throw context + ".onSoftDelete Not an array: " + typeof json.onSoftDelete
|
||||
}
|
||||
this.onSoftDelete = json.onSoftDelete?.map((t) => {
|
||||
const tag = TagUtils.Tag(t, context)
|
||||
if (tag instanceof RegexTag) {
|
||||
throw context + ".onSoftDelete Invalid onSoftDelete: cannot upload tag " + t
|
||||
}
|
||||
if (tag instanceof ComparingTag) {
|
||||
throw context + ".onSoftDelete Invalid onSoftDelete: cannot upload tag " + t
|
||||
}
|
||||
return tag
|
||||
})
|
||||
this.editButtonAriaLabel = Translations.T(
|
||||
json.editButtonAriaLabel,
|
||||
translationKey + ".editButtonAriaLabel"
|
||||
|
@ -371,8 +387,14 @@ export default class TagRenderingConfig {
|
|||
throw `${ctx}.addExtraTags: expected a list, but got a ${typeof mapping.addExtraTags}`
|
||||
}
|
||||
if (mapping.addExtraTags !== undefined && multiAnswer) {
|
||||
const usedKeys = mapping.addExtraTags?.flatMap((et) => TagUtils.Tag(et).usedKeys())
|
||||
if (usedKeys.some((key) => TagUtils.Tag(mapping.if).usedKeys().indexOf(key) > 0)) {
|
||||
const usedKeys = mapping.addExtraTags?.flatMap((et) =>
|
||||
TagUtils.Tag(et, context).usedKeys()
|
||||
)
|
||||
if (
|
||||
usedKeys.some(
|
||||
(key) => TagUtils.Tag(mapping.if, context).usedKeys().indexOf(key) > 0
|
||||
)
|
||||
) {
|
||||
throw `${ctx}: Invalid mapping: got a multi-Answer with addExtraTags which also modifies one of the keys; this is not allowed`
|
||||
}
|
||||
}
|
||||
|
|
|
@ -306,7 +306,7 @@ export default class ThemeConfig implements ThemeInformation {
|
|||
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) {
|
||||
return undefined
|
||||
}
|
||||
|
@ -314,6 +314,9 @@ export default class ThemeConfig implements ThemeInformation {
|
|||
return this.getLayer("current_view")
|
||||
}
|
||||
for (const layer of this.layers) {
|
||||
if(blacklistLayers?.has(layer.id)){
|
||||
continue
|
||||
}
|
||||
if (!layer.source) {
|
||||
if (layer.isShown?.matchesProperties(tags)) {
|
||||
return layer
|
||||
|
|
|
@ -178,7 +178,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
this.map = new UIEventSource<MlMap>(undefined)
|
||||
const geolocationState = new GeoLocationState()
|
||||
const initial = new InitialMapPositioning(layout, geolocationState)
|
||||
this.mapProperties = new MapLibreAdaptor(this.map, initial)
|
||||
this.mapProperties = new MapLibreAdaptor(this.map, initial, { correctClick: 20 })
|
||||
|
||||
this.featureSwitchIsTesting = this.featureSwitches.featureSwitchIsTesting
|
||||
this.featureSwitchUserbadge = this.featureSwitches.featureSwitchEnableLogin
|
||||
|
@ -445,10 +445,13 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
this.perLayer.forEach((fs, layerName) => {
|
||||
const doShowLayer = this.mapProperties.zoom.map(
|
||||
(z) => {
|
||||
if ((fs.layer.isDisplayed?.data ?? true) && z >= (fs.layer.layerDef?.minzoom ?? 0)){
|
||||
if (
|
||||
(fs.layer.isDisplayed?.data ?? true) &&
|
||||
z >= (fs.layer.layerDef?.minzoom ?? 0)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if(this.layerState.globalFilters.data.some(f => f.forceShowOnMatch)){
|
||||
if (this.layerState.globalFilters.data.some((f) => f.forceShowOnMatch)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
@ -470,7 +473,10 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
fs.layer,
|
||||
fs,
|
||||
(id) => this.featureProperties.getStore(id),
|
||||
this.layerState.globalFilters
|
||||
this.layerState.globalFilters,
|
||||
undefined,
|
||||
this.mapProperties.zoom,
|
||||
this.selectedElement
|
||||
)
|
||||
filteringFeatureSource.set(layerName, filtered)
|
||||
|
||||
|
@ -551,6 +557,18 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
})
|
||||
}
|
||||
|
||||
private setSelectedElement(feature: Feature) {
|
||||
const current = this.selectedElement.data
|
||||
if (
|
||||
current?.properties?.id !== undefined &&
|
||||
current.properties.id === feature.properties.id
|
||||
) {
|
||||
console.log("Not setting selected, same id", current, feature)
|
||||
return // already set
|
||||
}
|
||||
this.selectedElement.setData(feature)
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the feature that is 'i' closest to the map center
|
||||
*/
|
||||
|
@ -567,13 +585,11 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
if (!toSelect) {
|
||||
return
|
||||
}
|
||||
this.selectedElement.setData(undefined)
|
||||
this.selectedElement.setData(toSelect)
|
||||
this.setSelectedElement(toSelect)
|
||||
})
|
||||
return
|
||||
}
|
||||
this.selectedElement.setData(undefined)
|
||||
this.selectedElement.setData(toSelect)
|
||||
this.setSelectedElement(toSelect)
|
||||
}
|
||||
|
||||
private initHotkeys() {
|
||||
|
@ -989,26 +1005,38 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
this.userRelatedState.recentlyVisitedSearch.add(r)
|
||||
})
|
||||
|
||||
this.mapProperties.lastClickLocation.addCallbackD((lastClick) => {
|
||||
if (lastClick.mode !== "left" || !lastClick.nearestFeature) {
|
||||
return
|
||||
}
|
||||
const f = lastClick.nearestFeature
|
||||
this.setSelectedElement(f)
|
||||
})
|
||||
|
||||
this.userRelatedState.showScale.addCallbackAndRun((showScale) => {
|
||||
this.mapProperties.showScale.set(showScale)
|
||||
})
|
||||
|
||||
|
||||
this.layerState.filteredLayers.get("favourite")?.isDisplayed?.addCallbackAndRunD(favouritesShown => {
|
||||
const oldGlobal = this.layerState.globalFilters.data
|
||||
const key = "show-favourite"
|
||||
if(favouritesShown){
|
||||
this.layerState.globalFilters.set([...oldGlobal, {
|
||||
forceShowOnMatch: true,
|
||||
id:key,
|
||||
osmTags: new Tag("_favourite","yes"),
|
||||
state: 0,
|
||||
onNewPoint: undefined
|
||||
}])
|
||||
}else{
|
||||
this.layerState.globalFilters.set(oldGlobal.filter(gl => gl.id !== key))
|
||||
}
|
||||
})
|
||||
this.layerState.filteredLayers
|
||||
.get("favourite")
|
||||
?.isDisplayed?.addCallbackAndRunD((favouritesShown) => {
|
||||
const oldGlobal = this.layerState.globalFilters.data
|
||||
const key = "show-favourite"
|
||||
if (favouritesShown) {
|
||||
this.layerState.globalFilters.set([
|
||||
...oldGlobal,
|
||||
{
|
||||
forceShowOnMatch: true,
|
||||
id: key,
|
||||
osmTags: new Tag("_favourite", "yes"),
|
||||
state: 0,
|
||||
onNewPoint: undefined,
|
||||
},
|
||||
])
|
||||
} else {
|
||||
this.layerState.globalFilters.set(oldGlobal.filter((gl) => gl.id !== key))
|
||||
}
|
||||
})
|
||||
|
||||
new ThemeViewStateHashActor(this)
|
||||
new MetaTagging(this)
|
||||
|
@ -1033,7 +1061,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
|
||||
*/
|
||||
public getMatchingLayer(properties: Record<string, string>) {
|
||||
public getMatchingLayer(properties: Record<string, string>): LayerConfig | undefined {
|
||||
const id = properties.id
|
||||
|
||||
if (id.startsWith("summary_")) {
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
export let open = new UIEventSource(false)
|
||||
export let dotsSize = `w-6 h-6`
|
||||
export let dotsPosition = `top-0 right-0`
|
||||
export let hideBackground = false
|
||||
export let hideBackground: boolean = false
|
||||
let menuPosition = ``
|
||||
if (dotsPosition.indexOf("left-0") >= 0) {
|
||||
menuPosition = "left-0"
|
||||
|
@ -50,7 +50,7 @@
|
|||
}
|
||||
|
||||
:global(.dots-menu > path) {
|
||||
fill: var(--interactive-background);
|
||||
fill: var(--button-background-hover);
|
||||
transition: fill 350ms linear;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
<script lang="ts">
|
||||
import { Drawer } from "flowbite-svelte"
|
||||
import { sineIn } from "svelte/easing"
|
||||
import { cubicInOut } from "svelte/easing"
|
||||
import { UIEventSource } from "../../Logic/UIEventSource.js"
|
||||
|
||||
export let shown: UIEventSource<boolean>
|
||||
let transitionParams = {
|
||||
x: -320,
|
||||
duration: 200,
|
||||
easing: sineIn,
|
||||
easing: cubicInOut,
|
||||
}
|
||||
let hidden = !shown.data
|
||||
$: {
|
||||
|
|
|
@ -2,7 +2,8 @@
|
|||
import { createEventDispatcher, onDestroy } from "svelte"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export let accept: string
|
||||
export let accept: string | undefined
|
||||
export let capture: string | undefined = undefined
|
||||
export let multiple: boolean = true
|
||||
|
||||
const dispatcher = createEventDispatcher<{ submit: FileList }>()
|
||||
|
@ -98,5 +99,6 @@
|
|||
{multiple}
|
||||
name="file-input"
|
||||
type="file"
|
||||
{capture}
|
||||
/>
|
||||
</form>
|
||||
|
|
|
@ -3,6 +3,7 @@ import Locale from "../i18n/Locale"
|
|||
import Link from "./Link"
|
||||
import SvelteUIElement from "./SvelteUIElement"
|
||||
import Translate from "../../assets/svg/Translate.svelte"
|
||||
import Constants from "../../Models/Constants"
|
||||
|
||||
/**
|
||||
* The little 'translate'-icon next to every icon + some static helper functions
|
||||
|
@ -48,7 +49,7 @@ export default class LinkToWeblate extends VariableUiElement {
|
|||
/**
|
||||
* Creates the url to Hosted weblate
|
||||
*
|
||||
* LinkToWeblate.hrefToWeblate("nl", "category:some.context") // => "https://hosted.weblate.org/translate/mapcomplete/category/nl/?offset=1&q=context%3A%3D%22some.context%22"
|
||||
* LinkToWeblate.hrefToWeblate("nl", "category:some.context") // => "https://translate.mapcomplete.org/translate/mapcomplete/category/nl/?offset=1&q=context%3A%3D%22some.context%22"
|
||||
*/
|
||||
public static hrefToWeblate(language: string, contextKey: string): string {
|
||||
if (contextKey === undefined || contextKey.indexOf(":") < 0) {
|
||||
|
@ -57,7 +58,7 @@ export default class LinkToWeblate extends VariableUiElement {
|
|||
const [category, ...rest] = contextKey.split(":")
|
||||
const key = rest.join(":")
|
||||
|
||||
const baseUrl = "https://hosted.weblate.org/translate/mapcomplete/"
|
||||
const baseUrl = Constants.weblate + "translate/mapcomplete/"
|
||||
return baseUrl + category + "/" + language + "/?offset=1&q=context%3A%3D%22" + key + "%22"
|
||||
}
|
||||
|
||||
|
@ -66,7 +67,7 @@ export default class LinkToWeblate extends VariableUiElement {
|
|||
category: "core" | "themes" | "layers" | "shared-questions" | "glossary" | string,
|
||||
searchKey: string
|
||||
): string {
|
||||
const baseUrl = "https://hosted.weblate.org/zen/mapcomplete/"
|
||||
const baseUrl = Constants.weblate + "zen/mapcomplete/"
|
||||
// ?offset=1&q=+state%3A%3Ctranslated+context%3Acampersite&sort_by=-priority%2Cposition&checksum=
|
||||
return (
|
||||
baseUrl +
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
/**
|
||||
* Default: 50
|
||||
*/
|
||||
export let zIndex : string = "z-50"
|
||||
export let zIndex: string = "z-50"
|
||||
|
||||
const shared =
|
||||
"in-page normal-background dark:bg-gray-800 rounded-lg border-gray-200 dark:border-gray-700 border-gray-200 dark:border-gray-700 divide-gray-200 dark:divide-gray-700 shadow-md"
|
||||
|
@ -21,7 +21,7 @@
|
|||
if (fullscreen) {
|
||||
defaultClass = shared
|
||||
}
|
||||
let dialogClass = "fixed top-0 start-0 end-0 h-modal inset-0 w-full p-4 flex "+zIndex
|
||||
let dialogClass = "fixed top-0 start-0 end-0 h-modal inset-0 w-full p-4 flex " + zIndex
|
||||
if (fullscreen) {
|
||||
dialogClass += " h-full-child"
|
||||
}
|
||||
|
|
|
@ -65,7 +65,10 @@
|
|||
|
||||
{#if $value.length > 0}
|
||||
<Backspace
|
||||
on:click={() => value.set("")}
|
||||
on:click={(e) => {
|
||||
value.set("")
|
||||
e.preventDefault()
|
||||
}}
|
||||
color="var(--button-background)"
|
||||
class="mr-3 h-6 w-6 cursor-pointer"
|
||||
/>
|
||||
|
|
|
@ -3,24 +3,15 @@
|
|||
import type { Feature } from "geojson"
|
||||
import SelectedElementView from "../BigComponents/SelectedElementView.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 { 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 selected: Feature
|
||||
let tags = state.featureProperties.getStore(selected.properties.id)
|
||||
|
||||
export let absolute = true
|
||||
function getLayer(properties: Record<string, string>): LayerConfig {
|
||||
return state.getMatchingLayer(properties)
|
||||
}
|
||||
|
||||
let layer = getLayer(selected.properties)
|
||||
let layer = state.getMatchingLayer(selected.properties)
|
||||
|
||||
let stillMatches = tags.map(
|
||||
(tags) => !layer?.source?.osmTags || layer?.source?.osmTags?.matchesProperties(tags)
|
||||
|
|
|
@ -47,7 +47,7 @@
|
|||
{resource.resolved?.description}
|
||||
{#if resource.languageCodes?.indexOf($language) >= 0}
|
||||
<div class="thanks w-fit">
|
||||
<Tr t={availableTranslation}/>
|
||||
<Tr t={availableTranslation} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
@ -19,11 +19,19 @@
|
|||
export let filteredLayer: FilteredLayer
|
||||
export let highlightedLayer: Store<string | undefined> = new ImmutableStore(undefined)
|
||||
export let zoomlevel: Store<number> = new ImmutableStore(22)
|
||||
export let showLayerTitle = true
|
||||
let layer: LayerConfig = filteredLayer.layerDef
|
||||
let isDisplayed: UIEventSource<boolean> = filteredLayer.isDisplayed
|
||||
|
||||
let isDebugging = state?.featureSwitches?.featureSwitchIsDebugging ?? new ImmutableStore(false)
|
||||
let showTags = state?.userRelatedState?.showTags?.map(s => (s === "yes" && state?.userRelatedState?.osmConnection?.userDetails?.data?.csCount >= Constants.userJourney.tagsVisibleAt) || s === "always" || s === "full")
|
||||
let showTags = state?.userRelatedState?.showTags?.map(
|
||||
(s) =>
|
||||
(s === "yes" &&
|
||||
state?.userRelatedState?.osmConnection?.userDetails?.data?.csCount >=
|
||||
Constants.userJourney.tagsVisibleAt) ||
|
||||
s === "always" ||
|
||||
s === "full",
|
||||
)
|
||||
|
||||
/**
|
||||
* Gets a UIEventSource as boolean for the given option, to be used with a checkbox
|
||||
|
@ -33,7 +41,7 @@
|
|||
return state.sync(
|
||||
(f) => f === 0,
|
||||
[],
|
||||
(b) => (b ? 0 : undefined),
|
||||
(b) => (b ? 0 : undefined)
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -47,19 +55,21 @@
|
|||
|
||||
{#if filteredLayer.layerDef.name}
|
||||
<div class:focus={$highlightedLayer === filteredLayer.layerDef.id} class="mb-1.5">
|
||||
<Checkbox selected={isDisplayed}>
|
||||
<div class="no-image-background block h-6 w-6" class:opacity-50={!$isDisplayed}>
|
||||
<ToSvelte construct={() => layer.defaultIcon()} />
|
||||
</div>
|
||||
{#if showLayerTitle}
|
||||
<Checkbox selected={isDisplayed}>
|
||||
<div class="no-image-background block h-6 w-6" class:opacity-50={!$isDisplayed}>
|
||||
<ToSvelte construct={() => layer.defaultIcon()} />
|
||||
</div>
|
||||
|
||||
<Tr t={filteredLayer.layerDef.name} />
|
||||
<Tr t={filteredLayer.layerDef.name} />
|
||||
|
||||
{#if $zoomlevel < layer.minzoom}
|
||||
<span class="alert">
|
||||
<Tr t={Translations.t.general.layerSelection.zoomInToSeeThisLayer} />
|
||||
</span>
|
||||
{/if}
|
||||
</Checkbox>
|
||||
{#if $zoomlevel < layer.minzoom}
|
||||
<span class="alert">
|
||||
<Tr t={Translations.t.general.layerSelection.zoomInToSeeThisLayer} />
|
||||
</span>
|
||||
{/if}
|
||||
</Checkbox>
|
||||
{/if}
|
||||
|
||||
{#if $isDisplayed && filteredLayer.layerDef.filters?.length > 0}
|
||||
<div id="subfilters" class="ml-4 flex flex-col gap-y-1">
|
||||
|
@ -69,10 +79,11 @@
|
|||
{#if filter.options.length === 1 && filter.options[0].fields.length === 0}
|
||||
<Checkbox selected={getBooleanStateFor(filter)}>
|
||||
<Tr t={filter.options[0].question} />
|
||||
{#if $showTags && filter.options[0].osmTags !== undefined}
|
||||
<span class="subtle">
|
||||
{filter.options[0].osmTags.asHumanString()}
|
||||
</span>
|
||||
|
||||
{/if}
|
||||
</Checkbox>
|
||||
{/if}
|
||||
|
||||
|
@ -89,7 +100,7 @@
|
|||
{/if}
|
||||
<Tr t={option.question} />
|
||||
{#if $showTags && option.osmTags !== undefined}
|
||||
({option.osmTags.asHumanString()})
|
||||
({option.osmTags.asHumanString()})
|
||||
{/if}
|
||||
</option>
|
||||
{/each}
|
||||
|
|
|
@ -50,6 +50,8 @@
|
|||
import Squares2x2 from "@babeard/svelte-heroicons/mini/Squares2x2"
|
||||
import EnvelopeOpen from "@babeard/svelte-heroicons/mini/EnvelopeOpen"
|
||||
import PanoramaxLink from "./PanoramaxLink.svelte"
|
||||
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||
import MagnifyingGlassCircle from "@babeard/svelte-heroicons/mini/MagnifyingGlassCircle"
|
||||
|
||||
export let state: ThemeViewState
|
||||
let userdetails = state.osmConnection.userDetails
|
||||
|
@ -63,9 +65,25 @@
|
|||
let location = state.mapProperties.location
|
||||
export let onlyLink: boolean
|
||||
const t = Translations.t.general.menu
|
||||
let shown = new UIEventSource(state.guistate.pageStates.menu.data || !onlyLink)
|
||||
state.guistate.pageStates.menu.addCallback((isShown) => {
|
||||
if (!onlyLink) {
|
||||
return true
|
||||
}
|
||||
if (isShown) {
|
||||
shown.setData(true)
|
||||
} else {
|
||||
Utils.waitFor(250).then(() => {
|
||||
shown.setData(state.guistate.pageStates.menu.data)
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="low-interaction flex h-screen flex-col gap-y-2 overflow-y-auto p-2 sm:gap-y-3 sm:p-3">
|
||||
<div
|
||||
class="low-interaction flex h-screen flex-col gap-y-2 overflow-y-auto p-2 sm:gap-y-3 sm:p-3"
|
||||
class:hidden={!$shown}
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<h2>
|
||||
<Tr t={t.title} />
|
||||
|
@ -262,6 +280,14 @@
|
|||
</Page>
|
||||
</div>
|
||||
|
||||
<a
|
||||
class="flex"
|
||||
href={window.location.protocol + "//" + window.location.host + "/inspector.html"}
|
||||
>
|
||||
<MagnifyingGlassCircle class="mr-2 h-6 w-6" />
|
||||
<Tr t={Translations.t.inspector.menu} />
|
||||
</a>
|
||||
|
||||
<a class="flex" href="https://github.com/pietervdvn/MapComplete/" target="_blank">
|
||||
<Github class="h-6 w-6" />
|
||||
<Tr t={Translations.t.general.attribution.gotoSourceCode} />
|
||||
|
@ -276,7 +302,7 @@
|
|||
<EnvelopeOpen class="h-6 w-6" />
|
||||
<Tr t={Translations.t.general.attribution.emailCreators} />
|
||||
</a>
|
||||
<a class="flex" href="https://hosted.weblate.org/projects/mapcomplete/" target="_blank">
|
||||
<a class="flex" href={`${Constants.weblate}projects/mapcomplete/`} target="_blank">
|
||||
<TranslateIcon class="h-6 w-6" />
|
||||
<Tr t={Translations.t.translations.activateButton} />
|
||||
</a>
|
||||
|
|
|
@ -1,23 +1,21 @@
|
|||
<script lang="ts">
|
||||
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 type { SpecialVisualizationState } from "../SpecialVisualization"
|
||||
import TagRenderingAnswer from "../Popup/TagRendering/TagRenderingAnswer.svelte"
|
||||
import Translations from "../i18n/Translations"
|
||||
import Tr from "../Base/Tr.svelte"
|
||||
import { XCircleIcon } from "@rgossiaux/svelte-heroicons/solid"
|
||||
import { ariaLabel } from "../../Utils/ariaLabel"
|
||||
import { CloseButton } from "flowbite-svelte"
|
||||
|
||||
export let state: SpecialVisualizationState
|
||||
export let layer: LayerConfig
|
||||
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
|
||||
)
|
||||
$: {
|
||||
tags = state.featureProperties.getStore(selectedElement.properties.id)
|
||||
tags = state?.featureProperties?.getStore(selectedElement.properties.id)
|
||||
}
|
||||
|
||||
let isTesting = state.featureSwitchIsTesting
|
||||
|
|
|
@ -21,7 +21,7 @@ export class StackedRenderingChart extends ChartJs {
|
|||
period: "day" | "month"
|
||||
groupToOtherCutoff?: 3 | number
|
||||
// If given, take the sum of these fields to get the feature weight
|
||||
sumFields?: string[]
|
||||
sumFields?: ReadonlyArray<string>
|
||||
hideUnknown?: boolean
|
||||
hideNotApplicable?: boolean
|
||||
}
|
||||
|
@ -57,9 +57,9 @@ export class StackedRenderingChart extends ChartJs {
|
|||
backgroundColor: string
|
||||
}[] = []
|
||||
const allDays = StackedRenderingChart.getAllDays(features)
|
||||
let trimmedDays = allDays.map((d) => d.substr(0, 10))
|
||||
let trimmedDays = allDays.map((d) => d.substring(0, 10))
|
||||
if (options?.period === "month") {
|
||||
trimmedDays = trimmedDays.map((d) => d.substr(0, 7))
|
||||
trimmedDays = trimmedDays.map((d) => d.substring(0, 7))
|
||||
}
|
||||
trimmedDays = Utils.Dedup(trimmedDays)
|
||||
|
||||
|
@ -156,7 +156,7 @@ export class StackedRenderingChart extends ChartJs {
|
|||
): string[] {
|
||||
let earliest: Date = undefined
|
||||
let latest: Date = undefined
|
||||
let allDates = new Set<string>()
|
||||
const allDates = new Set<string>()
|
||||
features.forEach((value) => {
|
||||
const d = new Date(value.properties.date)
|
||||
Utils.SetMidnight(d)
|
||||
|
@ -290,10 +290,10 @@ export default class TagRenderingChart extends Combine {
|
|||
const mappings = tagRendering.mappings ?? []
|
||||
|
||||
options = options ?? {}
|
||||
let unknownCount: T[] = []
|
||||
const categoryCounts: T[][] = mappings.map((_) => [])
|
||||
const unknownCount: T[] = []
|
||||
const categoryCounts: T[][] = mappings.map(() => [])
|
||||
const otherCounts: Record<string, T[]> = {}
|
||||
let notApplicable: T[] = []
|
||||
const notApplicable: T[] = []
|
||||
for (const feature of features) {
|
||||
const props = feature.properties
|
||||
if (
|
||||
|
@ -346,7 +346,7 @@ export default class TagRenderingChart extends Combine {
|
|||
return { labels: undefined, data: undefined }
|
||||
}
|
||||
|
||||
let otherGrouped: T[] = []
|
||||
const otherGrouped: T[] = []
|
||||
const otherLabels: string[] = []
|
||||
const otherData: T[][] = []
|
||||
const sortedOtherCounts: [string, T[]][] = []
|
||||
|
|
|
@ -53,10 +53,15 @@
|
|||
*/
|
||||
export let mapProperties: undefined | Partial<MapProperties> = undefined
|
||||
|
||||
/**
|
||||
* Reuse a point if the clicked location is within this amount of meter
|
||||
*/
|
||||
export let snapTolerance: number = 5
|
||||
|
||||
let map: UIEventSource<MlMap> = new UIEventSource<MlMap>(undefined)
|
||||
let adaptor = new MapLibreAdaptor(map, mapProperties)
|
||||
|
||||
const wayGeojson: Feature<LineString> = GeoOperations.forceLineString(osmWay.asGeoJson())
|
||||
let wayGeojson: Feature<LineString> = GeoOperations.forceLineString(osmWay.asGeoJson())
|
||||
adaptor.location.setData(GeoOperations.centerpointCoordinatesObj(wayGeojson))
|
||||
adaptor.bounds.setData(BBox.get(wayGeojson).pad(2))
|
||||
adaptor.maxbounds.setData(BBox.get(wayGeojson).pad(2))
|
||||
|
@ -64,7 +69,7 @@
|
|||
state?.showCurrentLocationOn(map)
|
||||
new ShowDataLayer(map, {
|
||||
features: new StaticFeatureSource([wayGeojson]),
|
||||
drawMarkers: false,
|
||||
drawMarkers: true,
|
||||
layer: layer,
|
||||
})
|
||||
|
||||
|
@ -85,8 +90,8 @@
|
|||
layer: splitpoint_style,
|
||||
features: splitPointsFS,
|
||||
onClick: (clickedFeature: Feature) => {
|
||||
console.log("Clicked feature is", clickedFeature, splitPoints.data)
|
||||
const i = splitPoints.data.findIndex((f) => f === clickedFeature)
|
||||
// A 'splitpoint' was clicked, so we remove it again
|
||||
const i = splitPoints.data.findIndex((f) => f.properties.id === clickedFeature.properties.id)
|
||||
if (i < 0) {
|
||||
return
|
||||
}
|
||||
|
@ -96,7 +101,48 @@
|
|||
})
|
||||
let id = 0
|
||||
adaptor.lastClickLocation.addCallbackD(({ lon, lat }) => {
|
||||
const projected = GeoOperations.nearestPoint(wayGeojson, [lon, lat])
|
||||
let projected: Feature<Point, { index: number; id?: number; reuse?: string }> =
|
||||
GeoOperations.nearestPoint(wayGeojson, [lon, lat])
|
||||
|
||||
console.log("Added splitpoint", projected, id)
|
||||
|
||||
// We check the next and the previous point. If those are closer then the tolerance, we reuse those instead
|
||||
|
||||
const i = projected.properties.index
|
||||
const p = projected.geometry.coordinates
|
||||
const way = wayGeojson.geometry.coordinates
|
||||
const nextPoint = <[number, number]>way[i + 1]
|
||||
const nextDistance = GeoOperations.distanceBetween(nextPoint, p)
|
||||
const previousPoint = <[number, number]>way[i]
|
||||
const previousDistance = GeoOperations.distanceBetween(previousPoint, p)
|
||||
|
||||
console.log("ND", nextDistance, "PD", previousDistance)
|
||||
if (nextDistance <= snapTolerance && previousDistance >= nextDistance) {
|
||||
projected = {
|
||||
type: "Feature",
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: nextPoint,
|
||||
},
|
||||
properties: {
|
||||
index: i + 1,
|
||||
reuse: "yes",
|
||||
},
|
||||
}
|
||||
}
|
||||
if (previousDistance <= snapTolerance && previousDistance < nextDistance) {
|
||||
projected = {
|
||||
type: "Feature",
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: previousPoint,
|
||||
},
|
||||
properties: {
|
||||
index: i,
|
||||
reuse: "yes",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
projected.properties["id"] = id
|
||||
id++
|
||||
|
|
46
src/UI/History/AggregateImages.svelte
Normal file
46
src/UI/History/AggregateImages.svelte
Normal file
|
@ -0,0 +1,46 @@
|
|||
<script lang="ts">
|
||||
import { Store, UIEventSource } from "../../Logic/UIEventSource"
|
||||
import { HistoryUtils } from "./HistoryUtils"
|
||||
import type { Feature } from "geojson"
|
||||
import OsmObjectDownloader from "../../Logic/Osm/OsmObjectDownloader"
|
||||
import { OsmObject } from "../../Logic/Osm/OsmObject"
|
||||
import Loading from "../Base/Loading.svelte"
|
||||
import AttributedPanoramaxImage from "./AttributedPanoramaxImage.svelte"
|
||||
|
||||
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 imageKeys = new Set(...["panoramax", "image:streetsign", "image:menu"].map(k => {
|
||||
const result: string[] = [k]
|
||||
for (let i = 0; i < 10; i++) {
|
||||
result.push(k + ":" + i)
|
||||
}
|
||||
return result
|
||||
}))
|
||||
let usernamesSet = new Set(onlyShowUsername)
|
||||
let allDiffs: Store<{
|
||||
key: string;
|
||||
value?: string;
|
||||
oldValue?: string
|
||||
}[]> = allHistories.mapD(histories => HistoryUtils.fullHistoryDiff(histories, usernamesSet))
|
||||
|
||||
let addedImages = allDiffs.mapD(diffs => [].concat(...diffs.filter(({ key }) => imageKeys.has(key))))
|
||||
|
||||
</script>
|
||||
{#if $allDiffs === undefined}
|
||||
<Loading />
|
||||
{:else if $addedImages.length === 0}
|
||||
No images added by this contributor
|
||||
{:else}
|
||||
<div class="flex">
|
||||
{#each $addedImages as imgDiff}
|
||||
<div class="w-48 h-48">
|
||||
<AttributedPanoramaxImage hash={imgDiff.value} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
105
src/UI/History/AggregateView.svelte
Normal file
105
src/UI/History/AggregateView.svelte
Normal file
|
@ -0,0 +1,105 @@
|
|||
<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"
|
||||
import * as shared_questions from "../../assets/generated/layers/questions.json"
|
||||
import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig"
|
||||
import Tr from "../Base/Tr.svelte"
|
||||
import AccordionSingle from "../Flowbite/AccordionSingle.svelte"
|
||||
import Translations from "../i18n/Translations"
|
||||
|
||||
export let onlyShowUsername: string[]
|
||||
export let features: Feature[]
|
||||
|
||||
let usernames = new Set(onlyShowUsername)
|
||||
|
||||
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 => HistoryUtils.fullHistoryDiff(histories, usernames))
|
||||
|
||||
const trs = shared_questions.tagRenderings.map(tr => new TagRenderingConfig(tr))
|
||||
|
||||
function detectQuestion(key: string): TagRenderingConfig {
|
||||
return trs.find(tr => tr.freeform?.key === key)
|
||||
}
|
||||
|
||||
const mergedCount: Store<{
|
||||
key: string;
|
||||
tr: TagRenderingConfig;
|
||||
count: number;
|
||||
values: { value: string; count: number }[]
|
||||
}[]> = 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, tr: TagRenderingConfig, 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)
|
||||
const tr = detectQuestion(key)
|
||||
perKey.push({ count: countForKey, tr, key, values: keyTotal })
|
||||
})
|
||||
perKey.sort((a, b) => b.count - a.count)
|
||||
|
||||
return perKey
|
||||
})
|
||||
|
||||
const t = Translations.t.inspector
|
||||
|
||||
</script>
|
||||
|
||||
{#if allHistories === undefined}
|
||||
<Loading />
|
||||
{:else if $allDiffs !== undefined}
|
||||
{#each $mergedCount as diff}
|
||||
<h3>
|
||||
{#if diff.tr}
|
||||
<Tr t={diff.tr.question} />
|
||||
{:else}
|
||||
{diff.key}
|
||||
{/if}
|
||||
</h3>
|
||||
<AccordionSingle>
|
||||
<span slot="header">
|
||||
<Tr t={t.answeredCountTimes.Subs(diff)} />
|
||||
</span>
|
||||
<ul>
|
||||
{#each diff.values as value}
|
||||
<li>
|
||||
<b>{value.value}</b>
|
||||
{#if value.count > 1}
|
||||
- {value.count}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</AccordionSingle>
|
||||
{/each}
|
||||
{/if}
|
13
src/UI/History/AttributedPanoramaxImage.svelte
Normal file
13
src/UI/History/AttributedPanoramaxImage.svelte
Normal file
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts">
|
||||
import AttributedImage from "../Image/AttributedImage.svelte"
|
||||
import PanoramaxImageProvider from "../../Logic/ImageProviders/Panoramax"
|
||||
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||
import type { ProvidedImage } from "../../Logic/ImageProviders/ImageProvider"
|
||||
|
||||
export let hash: string
|
||||
let image: UIEventSource<ProvidedImage> = UIEventSource.FromPromise(PanoramaxImageProvider.singleton.getInfo(hash))
|
||||
</script>
|
||||
|
||||
{#if $image !== undefined}
|
||||
<AttributedImage image={$image}></AttributedImage>
|
||||
{/if}
|
106
src/UI/History/History.svelte
Normal file
106
src/UI/History/History.svelte
Normal file
|
@ -0,0 +1,106 @@
|
|||
<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 Tr from "../Base/Tr.svelte"
|
||||
import Translations from "../i18n/Translations"
|
||||
|
||||
export let onlyShowChangesBy: string[]
|
||||
export let id: OsmId
|
||||
|
||||
let usernames = new Set(onlyShowChangesBy)
|
||||
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 (usernames.size == 0) {
|
||||
return true
|
||||
}
|
||||
console.log("Checking if ", step.tags["_last_edit:contributor"],"is contained in", onlyShowChangesBy)
|
||||
return usernames.has(step.tags["_last_edit:contributor"])
|
||||
|
||||
}).map(({ step, layer }) => {
|
||||
const diff = HistoryUtils.tagHistoryDiff(step, fullHistory.data)
|
||||
return { step, layer, diff }
|
||||
}))
|
||||
|
||||
let lastStep = filteredHistory.mapD(history => history.at(-1))
|
||||
let allGeometry = filteredHistory.mapD(all => !all.some(x => x.diff.length > 0))
|
||||
/**
|
||||
* These layers are only shown if there are tag changes as well
|
||||
*/
|
||||
const ignoreLayersIfNoChanges: ReadonlySet<string> = new Set(["walls_and_buildings"])
|
||||
const t = Translations.t.inspector.previousContributors
|
||||
|
||||
</script>
|
||||
|
||||
{#if !$allGeometry || !ignoreLayersIfNoChanges.has($lastStep?.layer?.id)}
|
||||
{#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}
|
||||
<Tr t={t.onlyGeometry} />
|
||||
{:else}
|
||||
<table class="w-full m-1">
|
||||
{#each $filteredHistory as { step, layer }}
|
||||
|
||||
{#if step.version === 1}
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<h3>
|
||||
<Tr t={t.createdBy.Subs({contributor: 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">
|
||||
<Tr t={t.onlyGeometry} />
|
||||
</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}
|
||||
{/if}
|
51
src/UI/History/HistoryUtils.ts
Normal file
51
src/UI/History/HistoryUtils.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
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 {
|
||||
|
||||
public static readonly 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,
|
||||
step: OsmObject
|
||||
}[] {
|
||||
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], step
|
||||
}))
|
||||
}
|
||||
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, step
|
||||
}
|
||||
}).filter(ch => ch.oldValue !== ch.value)
|
||||
}
|
||||
|
||||
public static fullHistoryDiff(histories: OsmObject[][], onlyShowUsername?: Set<string>){
|
||||
const allDiffs: {key: string, oldValue?: string, value?: string}[] = [].concat(...histories.map(
|
||||
history => {
|
||||
const filtered = history.filter(step => !onlyShowUsername || onlyShowUsername?.has(step.tags["_last_edit:contributor"] ))
|
||||
const diffs: {
|
||||
key: string;
|
||||
value?: string;
|
||||
oldValue?: string
|
||||
}[][] = filtered.map(step => HistoryUtils.tagHistoryDiff(step, history))
|
||||
return [].concat(...diffs)
|
||||
}
|
||||
))
|
||||
return allDiffs
|
||||
}
|
||||
|
||||
}
|
107
src/UI/History/PreviouslySpiedUsers.svelte
Normal file
107
src/UI/History/PreviouslySpiedUsers.svelte
Normal file
|
@ -0,0 +1,107 @@
|
|||
<script lang="ts">
|
||||
|
||||
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
|
||||
import LoginToggle from "../Base/LoginToggle.svelte"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { XCircleIcon } from "@babeard/svelte-heroicons/solid"
|
||||
import AccordionSingle from "../Flowbite/AccordionSingle.svelte"
|
||||
import Dropdown from "../Base/Dropdown.svelte"
|
||||
|
||||
export let osmConnection: OsmConnection
|
||||
export let inspectedContributors: UIEventSource<{
|
||||
name: string,
|
||||
visitedTime: string,
|
||||
label: string
|
||||
}[]>
|
||||
let dispatch = createEventDispatcher<{ selectUser: string }>()
|
||||
|
||||
let labels = UIEventSource.asObject<string[]>(osmConnection.getPreference("previously-spied-labels"), [])
|
||||
let labelField = ""
|
||||
|
||||
function remove(user: string) {
|
||||
inspectedContributors.set(inspectedContributors.data.filter(entry => entry.name !== user))
|
||||
}
|
||||
|
||||
function addLabel() {
|
||||
if (labels.data.indexOf(labelField) >= 0) {
|
||||
return
|
||||
}
|
||||
labels.data.push(labelField)
|
||||
labels.ping()
|
||||
labelField = ""
|
||||
}
|
||||
|
||||
function sort(key: string) {
|
||||
console.log("Sorting on", key)
|
||||
inspectedContributors.data.sort((a, b) => (a[key] ?? "").localeCompare(b[key] ?? ""))
|
||||
inspectedContributors.ping()
|
||||
}
|
||||
</script>
|
||||
|
||||
<LoginToggle ignoreLoading state={{osmConnection}}>
|
||||
<table class="w-full">
|
||||
<tr>
|
||||
<td>
|
||||
<button class="as-link cursor-pointer" on:click={() => sort("name")}>
|
||||
Contributor
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
<button class="as-link cursor-pointer" on:click={() => sort("visitedTime")}>
|
||||
Visited time
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button class="as-link cursor-pointer" on:click={() => sort("label")}>Label</button>
|
||||
</td>
|
||||
<td>Remove</td>
|
||||
</tr>
|
||||
{#each $inspectedContributors as c}
|
||||
<tr>
|
||||
<td>
|
||||
<button class="as-link" on:click={() => dispatch("selectUser", c.name)}>{c.name}</button>
|
||||
</td>
|
||||
<td>
|
||||
{c.visitedTime}
|
||||
</td>
|
||||
<td>
|
||||
<select bind:value={c.label} on:change={() => inspectedContributors.ping()}>
|
||||
<option value={undefined}><i>No label</i></option>
|
||||
{#each $labels as l}
|
||||
<option value={l}>{l}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<XCircleIcon class="w-6 h-6" on:click={() => remove(c.name)} />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</table>
|
||||
|
||||
<AccordionSingle>
|
||||
|
||||
<div slot="header">Labels</div>
|
||||
{#if $labels.length === 0}
|
||||
No labels
|
||||
{:else}
|
||||
{#each $labels as label}
|
||||
<div class="mx-2">{label}
|
||||
<button class:disabled={!$inspectedContributors.some(c => c.label === label)} on:click={() => {dispatch("selectUser",
|
||||
inspectedContributors.data.filter(c =>c.label === label).map(c => c .name).join(";")
|
||||
)}}>See all changes for these users
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="interactive flex m-2 items-center gap-x-2 rounded-lg p-2">
|
||||
<div class="shrink-0">Create a new label</div>
|
||||
<input bind:value={labelField} type="text" />
|
||||
<button on:click={() => addLabel()} class:disabled={!(labelField?.length > 0) } class="disabled shrink-0">Add
|
||||
label
|
||||
</button>
|
||||
</div>
|
||||
</AccordionSingle>
|
||||
</LoginToggle>
|
|
@ -28,22 +28,24 @@
|
|||
export let imgClass: string = undefined
|
||||
export let state: SpecialVisualizationState = undefined
|
||||
export let attributionFormat: "minimal" | "medium" | "large" = "medium"
|
||||
export let previewedImage: UIEventSource<ProvidedImage>
|
||||
export let previewedImage: UIEventSource<ProvidedImage> = undefined
|
||||
export let canZoom = previewedImage !== undefined
|
||||
let loaded = false
|
||||
let showBigPreview = new UIEventSource(false)
|
||||
onDestroy(
|
||||
showBigPreview.addCallbackAndRun((shown) => {
|
||||
if (!shown) {
|
||||
previewedImage.set(undefined)
|
||||
previewedImage?.set(undefined)
|
||||
}
|
||||
}),
|
||||
})
|
||||
)
|
||||
if(previewedImage){
|
||||
onDestroy(
|
||||
previewedImage.addCallbackAndRun((previewedImage) => {
|
||||
showBigPreview.set(previewedImage?.id === image.id)
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function highlight(entered: boolean = true) {
|
||||
if (!entered) {
|
||||
|
@ -82,7 +84,7 @@
|
|||
class="normal-background"
|
||||
on:click={() => {
|
||||
console.log("Closing")
|
||||
previewedImage.set(undefined)
|
||||
previewedImage?.set(undefined)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
@ -124,13 +126,11 @@
|
|||
{#if canZoom && loaded}
|
||||
<div
|
||||
class="bg-black-transparent absolute right-0 top-0 rounded-bl-full"
|
||||
on:click={() => previewedImage.set(image)}
|
||||
on:click={() => previewedImage?.set(image)}
|
||||
>
|
||||
<MagnifyingGlassPlusIcon class="h-8 w-8 cursor-zoom-in pl-3 pb-3" color="white" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0">
|
||||
<ImageAttribution {image} {attributionFormat} />
|
||||
|
|
|
@ -23,11 +23,13 @@
|
|||
export let state: SpecialVisualizationState
|
||||
export let tags: UIEventSource<Record<string, string>>
|
||||
let showDeleteDialog = new UIEventSource(false)
|
||||
onDestroy(showDeleteDialog.addCallbackAndRunD(shown => {
|
||||
if (shown) {
|
||||
state.previewedImage.set(undefined)
|
||||
}
|
||||
}))
|
||||
onDestroy(
|
||||
showDeleteDialog.addCallbackAndRunD((shown) => {
|
||||
if (shown) {
|
||||
state.previewedImage.set(undefined)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
let reportReason = new UIEventSource<ReportReason>(REPORT_REASONS[0])
|
||||
let reportFreeText = new UIEventSource<string>(undefined)
|
||||
|
@ -58,12 +60,10 @@
|
|||
|
||||
async function unlink() {
|
||||
await state?.changes?.applyAction(
|
||||
new ChangeTagAction(tags.data.id,
|
||||
new Tag(image.key, ""),
|
||||
tags.data, {
|
||||
changeType: "delete-image",
|
||||
theme: state.theme.id,
|
||||
}),
|
||||
new ChangeTagAction(tags.data.id, new Tag(image.key, ""), tags.data, {
|
||||
changeType: "delete-image",
|
||||
theme: state.theme.id,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -72,23 +72,21 @@
|
|||
const placeholder = t.placeholder.current
|
||||
</script>
|
||||
|
||||
|
||||
<Popup shown={showDeleteDialog}>
|
||||
<Tr slot="header" t={tu.title} />
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-x-4">
|
||||
<div class="flex flex-col gap-x-4 sm:flex-row">
|
||||
<img class="w-32 sm:w-64" src={image.url} />
|
||||
<div>
|
||||
<div class="flex flex-col justify-between h-full">
|
||||
<div class="flex h-full flex-col justify-between">
|
||||
<Tr t={tu.explanation} />
|
||||
{#if $reported}
|
||||
<Tr cls="thanks p-2" t={t.deletionRequested} />
|
||||
{:else if image.provider.name === "panoramax"}
|
||||
<div class="my-4">
|
||||
<AccordionSingle noBorder>
|
||||
<div slot="header" class="text-sm flex">Report inappropriate picture</div>
|
||||
<div class="interactive p-2 flex flex-col">
|
||||
|
||||
<div slot="header" class="flex text-sm">Report inappropriate picture</div>
|
||||
<div class="interactive flex flex-col p-2">
|
||||
<h3>
|
||||
<Tr t={t.title} />
|
||||
</h3>
|
||||
|
@ -118,71 +116,57 @@
|
|||
placeholder={$placeholder}
|
||||
/>
|
||||
|
||||
<button class="primary self-end" class:disabled={$reportReason === "other" && !$reportFreeText}
|
||||
on:click={() => requestDeletion()}>
|
||||
<button
|
||||
class="primary self-end"
|
||||
class:disabled={$reportReason === "other" && !$reportFreeText}
|
||||
on:click={() => requestDeletion()}
|
||||
>
|
||||
<Tr t={t.requestDeletion} />
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</AccordionSingle>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div slot="footer" class="flex justify-end flex-wrap">
|
||||
<div slot="footer" class="flex flex-wrap justify-end">
|
||||
<button on:click={() => showDeleteDialog.set(false)}>
|
||||
<Tr t={Translations.t.general.cancel} />
|
||||
</button>
|
||||
|
||||
<NextButton clss={"primary "+($reported ? "disabled" : "") } on:click={() => unlink()}>
|
||||
<TrashIcon class="w-6 h-6 mr-2" />
|
||||
<NextButton clss={"primary " + ($reported ? "disabled" : "")} on:click={() => unlink()}>
|
||||
<TrashIcon class="mr-2 h-6 w-6" />
|
||||
<Tr t={tu.button} />
|
||||
</NextButton>
|
||||
</div>
|
||||
|
||||
</Popup>
|
||||
|
||||
<div
|
||||
class="w-fit shrink-0 relative"
|
||||
style="scroll-snap-align: start"
|
||||
>
|
||||
<div class="relative bg-gray-200 max-w-max flex items-center">
|
||||
|
||||
<div class="relative w-fit shrink-0" style="scroll-snap-align: start">
|
||||
<div class="relative flex max-w-max items-center bg-gray-200">
|
||||
<AttributedImage
|
||||
imgClass="carousel-max-height"
|
||||
{image}
|
||||
{state}
|
||||
previewedImage={state?.previewedImage}
|
||||
>
|
||||
|
||||
<svelte:fragment slot="dot-menu-actions">
|
||||
|
||||
<button on:click={() => ImageProvider.offerImageAsDownload(image)}>
|
||||
<DownloadIcon />
|
||||
<Tr t={Translations.t.general.download.downloadImage} />
|
||||
</button>
|
||||
<button
|
||||
on:click={() => showDeleteDialog.set(true)}
|
||||
class="flex items-center"
|
||||
>
|
||||
<button on:click={() => showDeleteDialog.set(true)} class="flex items-center">
|
||||
<TrashIcon />
|
||||
<Tr t={tu.button} />
|
||||
</button>
|
||||
</svelte:fragment>
|
||||
|
||||
|
||||
</AttributedImage>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.carousel-max-height) {
|
||||
max-height: var(--image-carousel-height);
|
||||
}
|
||||
:global(.carousel-max-height) {
|
||||
max-height: var(--image-carousel-height);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
|
@ -7,14 +7,10 @@
|
|||
export let images: Store<ProvidedImage[]>
|
||||
export let state: SpecialVisualizationState
|
||||
export let tags: Store<Record<string, string>>
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<div class="flex w-full space-x-2 overflow-x-auto" style="scroll-snap-type: x proximity">
|
||||
{#each $images as image (image.url)}
|
||||
<DeletableImage {image} {state} {tags}/>
|
||||
<DeletableImage {image} {state} {tags} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
||||
|
|
|
@ -19,7 +19,6 @@
|
|||
export let clss: string = undefined
|
||||
|
||||
let isLoaded = new UIEventSource(false)
|
||||
|
||||
</script>
|
||||
|
||||
<div class={twMerge("relative h-full w-full", clss)}>
|
||||
|
@ -36,12 +35,11 @@
|
|||
<slot name="dot-menu-actions">
|
||||
<button
|
||||
class="no-image-background pointer-events-auto flex items-center"
|
||||
on:click={() => ImageProvider.offerImageAsDownload(image)}
|
||||
on:click={() => ImageProvider.offerImageAsDownload(image)}
|
||||
>
|
||||
<DownloadIcon class="h-6 w-6 px-2 opacity-100" />
|
||||
<Tr t={Translations.t.general.download.downloadImage} />
|
||||
</button>
|
||||
|
||||
</slot>
|
||||
</DotMenu>
|
||||
<div
|
||||
|
|
|
@ -38,7 +38,7 @@
|
|||
|
||||
let errors = new UIEventSource<Translation[]>([])
|
||||
|
||||
async function handleFiles(files: FileList) {
|
||||
async function handleFiles(files: FileList, ignoreGps: boolean = false) {
|
||||
const errs = []
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files.item(i)
|
||||
|
@ -57,7 +57,8 @@
|
|||
file,
|
||||
"image",
|
||||
noBlur,
|
||||
feature
|
||||
feature,
|
||||
ignoreGps
|
||||
)
|
||||
if (!uploadResult) {
|
||||
return
|
||||
|
@ -78,47 +79,72 @@
|
|||
}
|
||||
errors.setData(errs)
|
||||
}
|
||||
|
||||
let maintenanceBusy = false
|
||||
</script>
|
||||
|
||||
<LoginToggle {state}>
|
||||
<LoginButton clss="small w-full" osmConnection={state.osmConnection} slot="not-logged-in">
|
||||
<Tr t={Translations.t.image.pleaseLogin} />
|
||||
</LoginButton>
|
||||
<div class="my-4 flex flex-col">
|
||||
<UploadingImageCounter {state} {tags} />
|
||||
{#each $errors as error}
|
||||
<Tr t={error} cls="alert" />
|
||||
{/each}
|
||||
<FileSelector
|
||||
accept="image/*"
|
||||
cls="button border-2 flex flex-col"
|
||||
multiple={true}
|
||||
on:submit={(e) => handleFiles(e.detail)}
|
||||
>
|
||||
<div class="flex w-full items-center justify-center text-2xl">
|
||||
{#if image !== undefined}
|
||||
<img src={image} aria-hidden="true" />
|
||||
{:else}
|
||||
<Camera class="h-12 w-12 p-1" aria-hidden="true" />
|
||||
{/if}
|
||||
{#if labelText}
|
||||
{labelText}
|
||||
{:else}
|
||||
<div class="flex flex-col">
|
||||
<Tr t={t.addPicture} />
|
||||
{#if noBlur}
|
||||
<span class="subtle text-sm">
|
||||
<Tr t={t.upload.noBlur} />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</FileSelector>
|
||||
<div class="subtle text-xs italic">
|
||||
<Tr t={Translations.t.general.attribution.panoramaxLicenseCCBYSA} />
|
||||
<span class="mx-1">—</span>
|
||||
<Tr t={t.respectPrivacy} />
|
||||
{#if maintenanceBusy}
|
||||
<div class="alert">
|
||||
Due to maintenance, uploading images is currently not possible. Sorry about this!
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="my-4 flex flex-col">
|
||||
<UploadingImageCounter {state} {tags} />
|
||||
{#each $errors as error}
|
||||
<Tr t={error} cls="alert" />
|
||||
{/each}
|
||||
<FileSelector
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
cls="button border-2 flex flex-col"
|
||||
multiple={true}
|
||||
on:submit={(e) => {
|
||||
handleFiles(e.detail)
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<div class="flex w-full items-center justify-center text-2xl">
|
||||
{#if image !== undefined}
|
||||
<img src={image} aria-hidden="true" />
|
||||
{:else}
|
||||
<Camera class="h-12 w-12 p-1" aria-hidden="true" />
|
||||
{/if}
|
||||
{#if labelText}
|
||||
{labelText}
|
||||
{:else}
|
||||
<div class="flex flex-col">
|
||||
<Tr t={t.addPicture} />
|
||||
{#if noBlur}
|
||||
<span class="subtle text-sm">
|
||||
<Tr t={t.upload.noBlur} />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</FileSelector>
|
||||
<FileSelector
|
||||
accept=".jpg, .jpeg"
|
||||
cls="flex justify-center md:hidden button"
|
||||
multiple={true}
|
||||
on:submit={(e) => {
|
||||
return handleFiles(e.detail, true)
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<Tr t={t.selectFile} />
|
||||
</FileSelector>
|
||||
<div class="subtle text-xs italic">
|
||||
<Tr t={Translations.t.general.attribution.panoramaxLicenseCCBYSA} />
|
||||
<span class="mx-1">—</span>
|
||||
<Tr t={t.respectPrivacy} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</LoginToggle>
|
||||
|
|
|
@ -18,19 +18,3 @@ export default class Toggle extends VariableUiElement {
|
|||
this.isEnabled = isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as `Toggle`, but will swap on click
|
||||
*/
|
||||
export class ClickableToggle extends Toggle {
|
||||
public declare readonly isEnabled: UIEventSource<boolean>
|
||||
|
||||
constructor(
|
||||
showEnabled: string | BaseUIElement,
|
||||
showDisabled: string | BaseUIElement,
|
||||
isEnabled: UIEventSource<boolean> = new UIEventSource<boolean>(false)
|
||||
) {
|
||||
super(showEnabled, showDisabled, isEnabled)
|
||||
this.isEnabled = isEnabled
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,11 +18,11 @@ export default class FediverseValidator extends Validator {
|
|||
*/
|
||||
public static extractServer(handle: string): { server: string; username: string } {
|
||||
const match = handle?.match(this.usernameAtServer)
|
||||
if(!match){
|
||||
if (!match) {
|
||||
return undefined
|
||||
}
|
||||
const [_, username, server] = match
|
||||
return {username, server}
|
||||
return { username, server }
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -36,7 +36,7 @@ export default class FediverseValidator extends Validator {
|
|||
const url = new URL(s)
|
||||
const path = url.pathname
|
||||
if (path.match(/^\/@?\w+$/)) {
|
||||
return `${path.substring(1)}@${url.hostname}`;
|
||||
return `${path.substring(1)}@${url.hostname}`
|
||||
}
|
||||
} catch (e) {
|
||||
// Nothing to do here
|
||||
|
|
219
src/UI/InspectorGUI.svelte
Normal file
219
src/UI/InspectorGUI.svelte
Normal file
|
@ -0,0 +1,219 @@
|
|||
<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 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 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"
|
||||
import { HistoryUtils } from "./History/HistoryUtils"
|
||||
import AggregateImages from "./History/AggregateImages.svelte"
|
||||
import Page from "./Base/Page.svelte"
|
||||
import PreviouslySpiedUsers from "./History/PreviouslySpiedUsers.svelte"
|
||||
import { OsmConnection } from "../Logic/Osm/OsmConnection"
|
||||
import MagnifyingGlassCircle from "@babeard/svelte-heroicons/outline/MagnifyingGlassCircle"
|
||||
import Translations from "./i18n/Translations"
|
||||
import Tr from "./Base/Tr.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 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 allLayers = HistoryUtils.personalTheme.layers
|
||||
let layersNoFixme = allLayers.filter(l => l.id !== "fixme")
|
||||
let fixme = allLayers.find(l => l.id === "fixme")
|
||||
let featuresStore = new UIEventSource<Feature[]>([])
|
||||
let features = new StaticFeatureSource(featuresStore)
|
||||
ShowDataLayer.showMultipleLayers(map, features, [...layersNoFixme, fixme] , {
|
||||
zoomToFeatures: true,
|
||||
onClick: (f: Feature) => {
|
||||
selectedElement.set(undefined)
|
||||
Utils.waitFor(200).then(() => {
|
||||
selectedElement.set(f)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
let osmConnection = new OsmConnection()
|
||||
let inspectedContributors: UIEventSource<{
|
||||
name: string,
|
||||
visitedTime: string,
|
||||
label: string
|
||||
}[]> = UIEventSource.asObject(
|
||||
osmConnection.getPreference("spied-upon-users"), [])
|
||||
|
||||
async function load() {
|
||||
const user = username.data
|
||||
if(user.indexOf(";")<0){
|
||||
|
||||
const inspectedData = inspectedContributors.data
|
||||
const previousEntry = inspectedData.find(e => e.name === user)
|
||||
if (previousEntry) {
|
||||
previousEntry.visitedTime = new Date().toISOString()
|
||||
} else {
|
||||
inspectedData.push({
|
||||
label: undefined,
|
||||
visitedTime: new Date().toISOString(),
|
||||
name: user
|
||||
})
|
||||
}
|
||||
inspectedContributors.ping()
|
||||
}
|
||||
|
||||
step.setData("loading")
|
||||
featuresStore.set([])
|
||||
const overpass = new Overpass(undefined, user.split(";").map(user => "nw(user_touched:\"" + user + "\");"), 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" | "images" = "map"
|
||||
|
||||
let showPreviouslyVisited = new UIEventSource(true)
|
||||
const t = Translations.t.inspector
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col w-full h-full">
|
||||
|
||||
<div class="flex gap-x-2 items-center low-interaction p-2">
|
||||
<MagnifyingGlassCircle class="w-12 h-12"/>
|
||||
<h1 class="flex-shrink-0 m-0 mx-2">
|
||||
<Tr t={t.title}/>
|
||||
</h1>
|
||||
<ValidatedInput type="string" value={username} on:submit={() => load()} />
|
||||
{#if loadingData}
|
||||
<Loading />
|
||||
{:else}
|
||||
<button class="primary" on:click={() => load()}>
|
||||
<Tr t={t.load}/>
|
||||
</button>
|
||||
{/if}
|
||||
<button on:click={() => showPreviouslyVisited.setData(true)}>
|
||||
<Tr t={t.earlierInspected}/>
|
||||
</button>
|
||||
<a href="./index.html" class="button">
|
||||
<Tr t={t.backToIndex}/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<button class:primary={mode === "map"} on:click={() => mode = "map"}>
|
||||
<Tr t={t.mapView}/>
|
||||
</button>
|
||||
<button class:primary={mode === "table"} on:click={() => mode = "table"}>
|
||||
<Tr t={t.tableView}/>
|
||||
</button>
|
||||
<button class:primary={mode === "aggregate"} on:click={() => mode = "aggregate"}>
|
||||
<Tr t={t.aggregateView}/>
|
||||
</button>
|
||||
<button class:primary={mode === "images"} on:click={() => mode = "images"}>
|
||||
<Tr t={t.images}/>
|
||||
</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"}
|
||||
<div class="m-2 h-full overflow-y-auto">
|
||||
{#each $featuresStore as f}
|
||||
<History onlyShowChangesBy={$username?.split(";")} id={f.properties.id} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if mode === "aggregate"}
|
||||
<div class="m-2 h-full overflow-y-auto">
|
||||
<AggregateView features={$featuresStore} onlyShowUsername={$username?.split(";")} />
|
||||
</div>
|
||||
{:else if mode === "images"}
|
||||
<div class="m-2 h-full overflow-y-auto">
|
||||
<AggregateImages features={$featuresStore} onlyShowUsername={$username?.split(";")} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Page shown={showPreviouslyVisited}>
|
||||
<div slot="header">Earlier inspected constributors</div>
|
||||
<PreviouslySpiedUsers {osmConnection} {inspectedContributors} on:selectUser={(e) => {
|
||||
username.set(e.detail); load();showPreviouslyVisited.set(false)
|
||||
}} />
|
||||
</Page>
|
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"),
|
||||
})
|
|
@ -16,6 +16,8 @@ import * as htmltoimage from "html-to-image"
|
|||
import RasterLayerHandler from "./RasterLayerHandler"
|
||||
import Constants from "../../Models/Constants"
|
||||
import { Protocol } from "pmtiles"
|
||||
import { GeoOperations } from "../../Logic/GeoOperations"
|
||||
import { Feature, LineString } from "geojson"
|
||||
|
||||
/**
|
||||
* The 'MapLibreAdaptor' bridges 'MapLibre' with the various properties of the `MapProperties`
|
||||
|
@ -46,7 +48,16 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
readonly allowRotating: UIEventSource<true | boolean | undefined>
|
||||
readonly allowZooming: UIEventSource<true | boolean | undefined>
|
||||
readonly lastClickLocation: Store<
|
||||
undefined | { lon: number; lat: number; mode: "left" | "right" | "middle" }
|
||||
| undefined
|
||||
| {
|
||||
lon: number
|
||||
lat: number
|
||||
mode: "left" | "right" | "middle"
|
||||
/**
|
||||
* The nearest feature from a MapComplete layer
|
||||
*/
|
||||
nearestFeature?: Feature
|
||||
}
|
||||
>
|
||||
readonly minzoom: UIEventSource<number>
|
||||
readonly maxzoom: UIEventSource<number>
|
||||
|
@ -64,7 +75,13 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
|
||||
private readonly _maplibreMap: Store<MLMap>
|
||||
|
||||
constructor(maplibreMap: Store<MLMap>, state?: Partial<MapProperties>) {
|
||||
constructor(
|
||||
maplibreMap: Store<MLMap>,
|
||||
state?: Partial<MapProperties>,
|
||||
options?: {
|
||||
correctClick?: number
|
||||
}
|
||||
) {
|
||||
if (!MapLibreAdaptor.pmtilesInited) {
|
||||
maplibregl.addProtocol("pmtiles", new Protocol().tile)
|
||||
MapLibreAdaptor.pmtilesInited = true
|
||||
|
@ -105,6 +122,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
lat: number
|
||||
lon: number
|
||||
mode: "left" | "right" | "middle"
|
||||
nearestFeature?: Feature
|
||||
}>(undefined)
|
||||
this.lastClickLocation = lastClickLocation
|
||||
const self = this
|
||||
|
@ -122,8 +140,41 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
const lat = e.lngLat.lat
|
||||
const mouseEvent: MouseEvent = e.originalEvent
|
||||
mode = mode ?? clickmodes[mouseEvent.button]
|
||||
|
||||
lastClickLocation.setData({ lon, lat, mode })
|
||||
let nearestFeature: Feature = undefined
|
||||
if (options?.correctClick && maplibreMap.data) {
|
||||
const map = maplibreMap.data
|
||||
const point = e.point
|
||||
const buffer = options?.correctClick
|
||||
const features = map
|
||||
.queryRenderedFeatures([
|
||||
[point.x - buffer, point.y - buffer],
|
||||
[point.x + buffer, point.y + buffer],
|
||||
])
|
||||
.filter((f) => f.source.startsWith("mapcomplete_"))
|
||||
if (features.length === 1) {
|
||||
nearestFeature = features[0]
|
||||
} else {
|
||||
let nearestD: number = undefined
|
||||
for (const feature of features) {
|
||||
let d: number // in meter
|
||||
if (feature.geometry.type === "LineString") {
|
||||
const way = <Feature<LineString>>feature
|
||||
const lngLat: [number, number] = [e.lngLat.lng, e.lngLat.lat]
|
||||
const p = GeoOperations.nearestPoint(way, lngLat)
|
||||
console.log(">>>", p, way, lngLat)
|
||||
if (!p) {
|
||||
continue
|
||||
}
|
||||
d = p.properties.dist * 1000
|
||||
if (nearestFeature === undefined || d < nearestD) {
|
||||
nearestFeature = way
|
||||
nearestD = d
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lastClickLocation.setData({ lon, lat, mode, nearestFeature })
|
||||
}
|
||||
|
||||
maplibreMap.addCallbackAndRunD((map) => {
|
||||
|
|
|
@ -16,6 +16,7 @@ import PerLayerFeatureSourceSplitter from "../../Logic/FeatureSource/PerLayerFea
|
|||
import FilteredLayer from "../../Models/FilteredLayer"
|
||||
import SimpleFeatureSource from "../../Logic/FeatureSource/Sources/SimpleFeatureSource"
|
||||
import { TagsFilter } from "../../Logic/Tags/TagsFilter"
|
||||
import { featureEach } from "@turf/turf"
|
||||
|
||||
class PointRenderingLayer {
|
||||
private readonly _config: PointRenderingConfig
|
||||
|
@ -103,6 +104,25 @@ class PointRenderingLayer {
|
|||
const id = feature.properties.id + "-" + location
|
||||
unseenKeys.delete(id)
|
||||
|
||||
if (location === "waypoints") {
|
||||
if (feature.geometry.type === "LineString") {
|
||||
for (const loc of feature.geometry.coordinates) {
|
||||
this.addPoint(feature, <[number, number]>loc)
|
||||
}
|
||||
}
|
||||
if (
|
||||
feature.geometry.type === "MultiLineString" ||
|
||||
feature.geometry.type === "Polygon"
|
||||
) {
|
||||
for (const coors of feature.geometry.coordinates) {
|
||||
for (const loc of coors) {
|
||||
this.addPoint(feature, <[number, number]>loc)
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const loc = GeoOperations.featureToCoordinateWithRenderingType(
|
||||
<any>feature,
|
||||
location
|
||||
|
@ -397,11 +417,13 @@ class LineRenderingLayer {
|
|||
)
|
||||
}
|
||||
|
||||
map.on("click", linelayer, (e) => {
|
||||
// line-layer-listener
|
||||
e.originalEvent["consumed"] = true
|
||||
this._onClick(e.features[0])
|
||||
})
|
||||
if (this._onClick) {
|
||||
map.on("click", linelayer, (e) => {
|
||||
// line-layer-listener
|
||||
e.originalEvent["consumed"] = true
|
||||
this._onClick(e.features[0])
|
||||
})
|
||||
}
|
||||
const polylayer = this._layername + "_polygon"
|
||||
|
||||
map.addLayer({
|
||||
|
@ -568,7 +590,6 @@ export default class ShowDataLayer {
|
|||
return
|
||||
}
|
||||
const bbox = BBox.bboxAroundAll(features.map(BBox.get))
|
||||
console.debug("Zooming to features", bbox.asGeoJson())
|
||||
window.requestAnimationFrame(() => {
|
||||
map.resize()
|
||||
map.fitBounds(bbox.toLngLat(), {
|
||||
|
|
|
@ -64,7 +64,7 @@
|
|||
onDestroy(
|
||||
globalFilter.addCallbackAndRun((globalFilter) => {
|
||||
console.log("Global filters are", globalFilter)
|
||||
_globalFilter = globalFilter ?? []
|
||||
_globalFilter = globalFilter?.filter((gf) => gf.onNewPoint !== undefined) ?? []
|
||||
})
|
||||
)
|
||||
$: {
|
||||
|
|
|
@ -97,7 +97,7 @@ export class DeleteFlowState {
|
|||
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
|
||||
const hist = this.objectDownloader
|
||||
.DownloadHistory(id)
|
||||
.downloadHistory(id)
|
||||
.map((versions) =>
|
||||
versions.map((version) =>
|
||||
Number(version.tags["_last_edit:contributor:uid"])
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
import AccordionSingle from "../../Flowbite/AccordionSingle.svelte"
|
||||
import Trash from "@babeard/svelte-heroicons/mini/Trash"
|
||||
import Invalid from "../../../assets/svg/Invalid.svelte"
|
||||
import { And } from "../../../Logic/Tags/And"
|
||||
|
||||
export let state: SpecialVisualizationState
|
||||
export let deleteConfig: DeleteConfig
|
||||
|
@ -60,10 +61,18 @@
|
|||
const changedProperties = TagUtils.changeAsProperties(selectedTags.asChange(tags?.data ?? {}))
|
||||
const deleteReason = changedProperties[DeleteConfig.deleteReasonKey]
|
||||
if (deleteReason) {
|
||||
let softDeletionTags: UploadableTag
|
||||
if (hasSoftDeletion) {
|
||||
softDeletionTags = new And([
|
||||
deleteConfig.softDeletionTags,
|
||||
...layer.tagRenderings.flatMap((tr) => tr.onSoftDelete ?? []),
|
||||
])
|
||||
}
|
||||
|
||||
// This is a proper, hard deletion
|
||||
actionToTake = new DeleteAction(
|
||||
featureId,
|
||||
deleteConfig.softDeletionTags,
|
||||
softDeletionTags,
|
||||
{
|
||||
theme: state?.theme?.id ?? "unknown",
|
||||
specialMotivation: deleteReason,
|
||||
|
|
29
src/UI/Popup/DisabledQuestions.svelte
Normal file
29
src/UI/Popup/DisabledQuestions.svelte
Normal file
|
@ -0,0 +1,29 @@
|
|||
<script lang="ts">
|
||||
import DisabledQuestionsLayer from "./DisabledQuestionsLayer.svelte"
|
||||
import { Stores } from "../../Logic/UIEventSource"
|
||||
import Tr from "../Base/Tr.svelte"
|
||||
import Translations from "../i18n/Translations"
|
||||
|
||||
/**
|
||||
* Shows _all_ disabled questions
|
||||
*/
|
||||
export let state
|
||||
let layers = state.layout.layers.filter((l) => l.isNormal())
|
||||
|
||||
let allDisabled = Stores.concat<string>(
|
||||
layers.map((l) => state.userRelatedState.getThemeDisabled(state.layout.id, l.id))
|
||||
).map((l) => [].concat(...l))
|
||||
const t = Translations.t.general.questions
|
||||
</script>
|
||||
|
||||
<h3>
|
||||
<Tr t={t.disabledTitle} />
|
||||
</h3>
|
||||
{#if $allDisabled.length === 0}
|
||||
<Tr t={t.noneDisabled} />
|
||||
{:else}
|
||||
<Tr t={t.disabledIntro} />
|
||||
{#each layers as layer (layer.id)}
|
||||
<DisabledQuestionsLayer {state} {layer} />
|
||||
{/each}
|
||||
{/if}
|
45
src/UI/Popup/DisabledQuestionsLayer.svelte
Normal file
45
src/UI/Popup/DisabledQuestionsLayer.svelte
Normal file
|
@ -0,0 +1,45 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Gives an overview of questions which are disabled for the given theme
|
||||
*/
|
||||
import UserRelatedState from "../../Logic/State/UserRelatedState"
|
||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
|
||||
import ThemeViewState from "../../Models/ThemeViewState"
|
||||
import Tr from "../Base/Tr.svelte"
|
||||
import { Translation } from "../i18n/Translation"
|
||||
import { XMarkIcon } from "@babeard/svelte-heroicons/mini"
|
||||
import ToSvelte from "../Base/ToSvelte.svelte"
|
||||
|
||||
export let layer: LayerConfig
|
||||
export let state: ThemeViewState
|
||||
|
||||
let disabledQuestions = state.userRelatedState.getThemeDisabled(state.layout.id, layer.id)
|
||||
|
||||
function getQuestion(id: string): Translation {
|
||||
return layer.tagRenderings.find((q) => q.id === id).question.Subs({})
|
||||
}
|
||||
|
||||
function enable(idToEnable: string) {
|
||||
const newList = disabledQuestions.data.filter((id) => id !== idToEnable)
|
||||
disabledQuestions.set(newList)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $disabledQuestions.length > 0}
|
||||
<div class="low-interaction p-2">
|
||||
<h4 class="my-2 flex">
|
||||
<div class="no-image-background block h-6 w-6">
|
||||
<ToSvelte construct={() => layer.defaultIcon()} />
|
||||
</div>
|
||||
<Tr t={layer.name} />
|
||||
</h4>
|
||||
<div class="flex">
|
||||
{#each $disabledQuestions as id}
|
||||
<button class="badge button-unstyled" on:click={() => enable(id)}>
|
||||
<Tr cls="ml-2" t={getQuestion(id)} />
|
||||
<XMarkIcon class="mr-2 h-4 w-4" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
|
||||
import ArrowDownTray from "@babeard/svelte-heroicons/mini/ArrowDownTray"
|
||||
import Tr from "../Base/Tr.svelte"
|
||||
import Translations from "../i18n/Translations"
|
||||
|
@ -20,23 +19,21 @@
|
|||
export let extension: string
|
||||
export let maintext: Translation
|
||||
export let helpertext: Translation
|
||||
export let construct: (feature: Feature, title: string) => (Blob | string)
|
||||
export let construct: (feature: Feature, title: string) => Blob | string
|
||||
function exportGpx() {
|
||||
console.log("Exporting as GPX!")
|
||||
const tgs = tags.data
|
||||
const title = layer.title?.GetRenderValue(tgs)?.Subs(tgs)?.txt ?? "gpx_track"
|
||||
const data = construct(feature, title)
|
||||
Utils.offerContentsAsDownloadableFile(data, title + "_mapcomplete_export."+extension, {
|
||||
Utils.offerContentsAsDownloadableFile(data, title + "_mapcomplete_export." + extension, {
|
||||
mimetype,
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<button class="w-full" on:click={() => exportGpx()}>
|
||||
<ArrowDownTray class="w-11 h-11 mr-2"/>
|
||||
<div class="flex flex-col items-start w-full">
|
||||
<ArrowDownTray class="mr-2 h-11 w-11" />
|
||||
<div class="flex w-full flex-col items-start">
|
||||
<Tr t={maintext} cls="font-bold text-lg" />
|
||||
<Tr t={helpertext} cls="subtle text-start" />
|
||||
</div>
|
||||
|
|
|
@ -9,23 +9,25 @@
|
|||
export let tags: Store<Record<string, string>>
|
||||
export let state: SpecialVisualizationState
|
||||
const validator = new FediverseValidator()
|
||||
const userinfo = tags.mapD(t => t[key]).mapD(fediAccount => {
|
||||
return FediverseValidator.extractServer(validator.reformat(fediAccount))
|
||||
})
|
||||
const homeLocation: Store<string> = state.userRelatedState?.preferencesAsTags.mapD(prefs => prefs["_mastodon_link"])
|
||||
.mapD(userhandle => FediverseValidator.extractServer(validator.reformat(userhandle))?.server)
|
||||
const userinfo = tags
|
||||
.mapD((t) => t[key])
|
||||
.mapD((fediAccount) => {
|
||||
return FediverseValidator.extractServer(validator.reformat(fediAccount))
|
||||
})
|
||||
const homeLocation: Store<string> = state.userRelatedState?.preferencesAsTags
|
||||
.mapD((prefs) => prefs["_mastodon_link"])
|
||||
.mapD((userhandle) => FediverseValidator.extractServer(validator.reformat(userhandle))?.server)
|
||||
</script>
|
||||
<div class="flex flex-col w-full">
|
||||
|
||||
<a href={ "https://" + $userinfo.server + "/@" + $userinfo.username} target="_blank">@{$userinfo.username}
|
||||
@{$userinfo.server} </a>
|
||||
|
||||
{#if $homeLocation !== undefined}
|
||||
|
||||
<a target="_blank" href={"https://"+$homeLocation+"/"}>
|
||||
<Tr t={ Translations.t.validation.fediverse.onYourServer} />
|
||||
<div class="flex w-full flex-col">
|
||||
<a href={"https://" + $userinfo.server + "/@" + $userinfo.username} target="_blank">
|
||||
@{$userinfo.username}
|
||||
@{$userinfo.server}
|
||||
</a>
|
||||
|
||||
{/if}
|
||||
|
||||
{#if $homeLocation !== undefined}
|
||||
<a target="_blank" href={"https://" + $homeLocation + "/"}>
|
||||
<Tr t={Translations.t.validation.fediverse.onYourServer} />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
@ -105,7 +105,7 @@
|
|||
snapToLayers={$reason.snapTo}
|
||||
targetLayer={layer}
|
||||
dontShow={[id]}
|
||||
maxDistanceInMeters={200}
|
||||
maxDistanceInMeters={$reason.maxSnapDistance ?? Infinity}
|
||||
/>
|
||||
<div class="absolute bottom-0 left-0">
|
||||
<OpenBackgroundSelectorButton {state} />
|
||||
|
|
|
@ -44,7 +44,7 @@
|
|||
})
|
||||
.filter((link) => !link.startsWith("https://wiki.openstreetmap.org/wiki/File:"))
|
||||
|
||||
const attributedImages = AllImageProviders.loadImagesFrom(images)
|
||||
let attributedImages = AllImageProviders.loadImagesFrom(images)
|
||||
/**
|
||||
* Class of the little icons indicating 'opened', 'comment' and 'resolved'
|
||||
*/
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
export let state: SpecialVisualizationState
|
||||
export let id: WayId
|
||||
const t = Translations.t.split
|
||||
let snapTolerance = 5 // meter
|
||||
let step:
|
||||
| "initial"
|
||||
| "loading_way"
|
||||
|
@ -60,7 +61,7 @@
|
|||
{
|
||||
theme: state?.theme?.id,
|
||||
},
|
||||
5
|
||||
snapTolerance
|
||||
)
|
||||
await state.changes?.applyAction(splitAction)
|
||||
// We throw away the old map and splitpoints, and create a new map from scratch
|
||||
|
@ -87,7 +88,13 @@
|
|||
{:else if step === "splitting"}
|
||||
<div class="interactive border-interactive flex flex-col p-2">
|
||||
<div class="h-80 w-full">
|
||||
<WaySplitMap {state} {splitPoints} {osmWay} />
|
||||
<WaySplitMap
|
||||
{state}
|
||||
{splitPoints}
|
||||
{osmWay}
|
||||
{snapTolerance}
|
||||
mapProperties={{ rasterLayer: state.mapProperties.rasterLayer }}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex w-full flex-wrap-reverse md:flex-nowrap">
|
||||
<BackButton
|
||||
|
|
|
@ -43,11 +43,19 @@
|
|||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const baseQuestions = (layer?.tagRenderings ?? [])?.filter(
|
||||
(tr) => allowed(tr.labels) && tr.question !== undefined
|
||||
)
|
||||
|
||||
/**
|
||||
* Ids of skipped questions
|
||||
*/
|
||||
let skippedQuestions = new UIEventSource<Set<string>>(new Set<string>())
|
||||
let layerDisabledForTheme = state.userRelatedState.getThemeDisabled(state.theme.id, layer.id)
|
||||
layerDisabledForTheme.addCallbackAndRunD((disabled) => {
|
||||
skippedQuestions.set(new Set(disabled.concat(Array.from(skippedQuestions.data))))
|
||||
})
|
||||
let questionboxElem: HTMLDivElement
|
||||
let questionsToAsk = tags.map(
|
||||
(tags) => {
|
||||
|
@ -95,6 +103,7 @@
|
|||
let skipped: number = 0
|
||||
|
||||
let loginEnabled = state.featureSwitches.featureSwitchEnableLogin
|
||||
let debug = state.featureSwitches.featureSwitchIsDebugging
|
||||
|
||||
function skip(question: { id: string }, didAnswer: boolean = false) {
|
||||
skippedQuestions.data.add(question.id) // Must use ID, the config object might be a copy of the original
|
||||
|
@ -117,43 +126,77 @@
|
|||
class="marker-questionbox-root"
|
||||
class:hidden={$questionsToAsk.length === 0 && skipped === 0 && answered === 0}
|
||||
>
|
||||
{#if $showAllQuestionsAtOnce}
|
||||
<div class="flex flex-col gap-y-1">
|
||||
{#each $allQuestionsToAsk as question (question.id)}
|
||||
<TagRenderingQuestionDynamic config={question} {tags} {selectedElement} {state} {layer} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if $firstQuestion !== undefined}
|
||||
<TagRenderingQuestionDynamic
|
||||
config={$firstQuestion}
|
||||
{layer}
|
||||
{selectedElement}
|
||||
{state}
|
||||
{tags}
|
||||
on:saved={() => {
|
||||
skip($firstQuestion, true)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="secondary"
|
||||
on:click={() => {
|
||||
skip($firstQuestion)
|
||||
}}
|
||||
slot="cancel"
|
||||
>
|
||||
<Tr t={Translations.t.general.skip} />
|
||||
</button>
|
||||
</TagRenderingQuestionDynamic>
|
||||
{/if}
|
||||
|
||||
{#if $allQuestionsToAsk.length === 0}
|
||||
<div class="thanks">
|
||||
<Tr t={Translations.t.general.questionBox.done} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 mb-8">
|
||||
{#if skipped + answered > 0}
|
||||
<div class="thanks">
|
||||
<Tr t={Translations.t.general.questionBox.done} />
|
||||
</div>
|
||||
{#if answered === 0}
|
||||
{#if skipped === 1}
|
||||
<Tr t={Translations.t.general.questionBox.skippedOne} />
|
||||
{:else}
|
||||
<Tr t={Translations.t.general.questionBox.skippedMultiple.Subs({ skipped })} />
|
||||
{/if}
|
||||
{:else if answered === 1}
|
||||
{#if skipped === 0}
|
||||
<Tr t={Translations.t.general.questionBox.answeredOne} />
|
||||
<div class="flex justify-center">
|
||||
{#if answered === 0}
|
||||
{#if skipped === 1}
|
||||
<Tr t={Translations.t.general.questionBox.skippedOne} />
|
||||
{:else}
|
||||
<Tr t={Translations.t.general.questionBox.skippedMultiple.Subs({ skipped })} />
|
||||
{/if}
|
||||
{:else if answered === 1}
|
||||
{#if skipped === 0}
|
||||
<Tr t={Translations.t.general.questionBox.answeredOne} />
|
||||
{:else if skipped === 1}
|
||||
<Tr t={Translations.t.general.questionBox.answeredOneSkippedOne} />
|
||||
{:else}
|
||||
<Tr
|
||||
t={Translations.t.general.questionBox.answeredOneSkippedMultiple.Subs({ skipped })}
|
||||
/>
|
||||
{/if}
|
||||
{:else if skipped === 0}
|
||||
<Tr t={Translations.t.general.questionBox.answeredMultiple.Subs({ answered })} />
|
||||
{:else if skipped === 1}
|
||||
<Tr t={Translations.t.general.questionBox.answeredOneSkippedOne} />
|
||||
<Tr
|
||||
t={Translations.t.general.questionBox.answeredMultipleSkippedOne.Subs({ answered })}
|
||||
/>
|
||||
{:else}
|
||||
<Tr
|
||||
t={Translations.t.general.questionBox.answeredOneSkippedMultiple.Subs({ skipped })}
|
||||
t={Translations.t.general.questionBox.answeredMultipleSkippedMultiple.Subs({
|
||||
answered,
|
||||
skipped,
|
||||
})}
|
||||
/>
|
||||
{/if}
|
||||
{:else if skipped === 0}
|
||||
<Tr t={Translations.t.general.questionBox.answeredMultiple.Subs({ answered })} />
|
||||
{:else if skipped === 1}
|
||||
<Tr
|
||||
t={Translations.t.general.questionBox.answeredMultipleSkippedOne.Subs({ answered })}
|
||||
/>
|
||||
{:else}
|
||||
<Tr
|
||||
t={Translations.t.general.questionBox.answeredMultipleSkippedMultiple.Subs({
|
||||
answered,
|
||||
skipped,
|
||||
})}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if skipped > 0}
|
||||
{#if skipped + $skippedQuestions.size > 0}
|
||||
<button
|
||||
class="w-full"
|
||||
on:click={() => {
|
||||
|
@ -165,43 +208,21 @@
|
|||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<div>
|
||||
{#if $showAllQuestionsAtOnce}
|
||||
<div class="flex flex-col gap-y-1">
|
||||
{#each $allQuestionsToAsk as question (question.id)}
|
||||
<TagRenderingQuestionDynamic
|
||||
config={question}
|
||||
{tags}
|
||||
{selectedElement}
|
||||
{state}
|
||||
{layer}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if $firstQuestion !== undefined}
|
||||
<TagRenderingQuestionDynamic
|
||||
config={$firstQuestion}
|
||||
{layer}
|
||||
{selectedElement}
|
||||
{state}
|
||||
{tags}
|
||||
on:saved={() => {
|
||||
skip($firstQuestion, true)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="secondary"
|
||||
on:click={() => {
|
||||
skip($firstQuestion)
|
||||
}}
|
||||
slot="cancel"
|
||||
>
|
||||
<Tr t={Translations.t.general.skip} />
|
||||
</button>
|
||||
</TagRenderingQuestionDynamic>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $skippedQuestions.size - skipped > 0}
|
||||
<button
|
||||
class="w-full"
|
||||
on:click={() => {
|
||||
skippedQuestions.setData(new Set())
|
||||
skipped = 0
|
||||
}}
|
||||
>
|
||||
Show the disabled questions for this object
|
||||
</button>
|
||||
{/if}
|
||||
{#if $debug}
|
||||
Skipped questions are {Array.from($skippedQuestions).join(", ")}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
@ -36,6 +36,8 @@
|
|||
import { Modal } from "flowbite-svelte"
|
||||
import Popup from "../../Base/Popup.svelte"
|
||||
import If from "../../Base/If.svelte"
|
||||
import DotMenu from "../../Base/DotMenu.svelte"
|
||||
import SidebarUnit from "../../Base/SidebarUnit.svelte"
|
||||
|
||||
export let config: TagRenderingConfig
|
||||
export let tags: UIEventSource<Record<string, string>>
|
||||
|
@ -338,10 +340,42 @@
|
|||
.then((changes) => state.changes.applyChanges(changes))
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
let disabledInTheme = state.userRelatedState.getThemeDisabled(state.theme.id, layer?.id)
|
||||
let menuIsOpened = new UIEventSource(false)
|
||||
|
||||
function disableQuestion() {
|
||||
const newList = Utils.Dedup([config.id, ...disabledInTheme.data])
|
||||
disabledInTheme.set(newList)
|
||||
menuIsOpened.set(false)
|
||||
}
|
||||
|
||||
function enableQuestion() {
|
||||
const newList = disabledInTheme.data?.filter((id) => id !== config.id)
|
||||
disabledInTheme.set(newList)
|
||||
menuIsOpened.set(false)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if question !== undefined}
|
||||
<div class={clss}>
|
||||
{#if layer.isNormal()}
|
||||
<LoginToggle {state}>
|
||||
<DotMenu hideBackground={true} open={menuIsOpened}>
|
||||
<SidebarUnit>
|
||||
{#if $disabledInTheme.indexOf(config.id) >= 0}
|
||||
<button on:click={() => enableQuestion()}>
|
||||
<Tr t={Translations.t.general.questions.enable} />
|
||||
</button>
|
||||
{:else}
|
||||
<button on:click={() => disableQuestion()}>
|
||||
<Tr t={Translations.t.general.questions.disable} />
|
||||
</button>
|
||||
{/if}
|
||||
</SidebarUnit>
|
||||
</DotMenu>
|
||||
</LoginToggle>
|
||||
{/if}
|
||||
<form
|
||||
class="relative flex flex-col overflow-y-auto px-4"
|
||||
style="max-height: 75vh"
|
||||
|
@ -554,10 +588,7 @@
|
|||
</div>
|
||||
</Popup>
|
||||
|
||||
<div
|
||||
class="sticky bottom-0 flex flex-wrap justify-between"
|
||||
style="z-index: 11"
|
||||
>
|
||||
<div class="sticky bottom-0 flex flex-wrap justify-between" style="z-index: 11">
|
||||
{#if $settableKeys && $isKnown && !matchesEmpty}
|
||||
<button class="as-link small text-sm" on:click={() => unknownModal.set(true)}>
|
||||
<Tr t={Translations.t.unknown.markUnknown} />
|
||||
|
|
|
@ -87,7 +87,7 @@ export interface SpecialVisualizationState {
|
|||
readonly geocodedImages: UIEventSource<Feature[]>
|
||||
readonly searchState: SearchState
|
||||
|
||||
getMatchingLayer(properties: Record<string, string>)
|
||||
getMatchingLayer(properties: Record<string, string>): LayerConfig | undefined
|
||||
|
||||
showCurrentLocationOn(map: Store<MlMap>): ShowDataLayer
|
||||
reportError(message: string | Error | XMLHttpRequest, extramessage?: string): Promise<void>
|
||||
|
|
|
@ -2,7 +2,11 @@ import Combine from "./Base/Combine"
|
|||
import { FixedUiElement } from "./Base/FixedUiElement"
|
||||
import BaseUIElement from "./BaseUIElement"
|
||||
import Title from "./Base/Title"
|
||||
import { RenderingSpecification, SpecialVisualization, SpecialVisualizationState } from "./SpecialVisualization"
|
||||
import {
|
||||
RenderingSpecification,
|
||||
SpecialVisualization,
|
||||
SpecialVisualizationState,
|
||||
} from "./SpecialVisualization"
|
||||
import { HistogramViz } from "./Popup/HistogramViz"
|
||||
import MinimapViz from "./Popup/MinimapViz.svelte"
|
||||
import { ShareLinkViz } from "./Popup/ShareLinkViz"
|
||||
|
@ -93,6 +97,7 @@ import ClearCaches from "./Popup/ClearCaches.svelte"
|
|||
import GroupedView from "./Popup/GroupedView.svelte"
|
||||
import { QuestionableTagRenderingConfigJson } from "../Models/ThemeConfig/Json/QuestionableTagRenderingConfigJson"
|
||||
import NoteCommentElement from "./Popup/Notes/NoteCommentElement.svelte"
|
||||
import DisabledQuestions from "./Popup/DisabledQuestions.svelte"
|
||||
import FediverseLink from "./Popup/FediverseLink.svelte"
|
||||
import ImageCarousel from "./Image/ImageCarousel.svelte"
|
||||
|
||||
|
@ -120,7 +125,7 @@ class NearbyImageVis implements SpecialVisualization {
|
|||
tags: UIEventSource<Record<string, string>>,
|
||||
args: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): SvelteUIElement {
|
||||
const isOpen = args[0] === "open"
|
||||
const readonly = args[1] === "readonly"
|
||||
|
@ -187,7 +192,7 @@ class StealViz implements SpecialVisualization {
|
|||
selectedElement: otherFeature,
|
||||
state,
|
||||
layer,
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
if (elements.length === 1) {
|
||||
|
@ -195,8 +200,8 @@ class StealViz implements SpecialVisualization {
|
|||
}
|
||||
return new Combine(elements).SetClass("flex flex-col")
|
||||
},
|
||||
[state.indexedFeatures.featuresById],
|
||||
),
|
||||
[state.indexedFeatures.featuresById]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -248,11 +253,11 @@ class CloseNoteViz implements SpecialVisualization {
|
|||
public constr(
|
||||
state: SpecialVisualizationState,
|
||||
tags: UIEventSource<Record<string, string>>,
|
||||
args: string[],
|
||||
args: string[]
|
||||
): SvelteUIElement {
|
||||
const { text, icon, idkey, comment, minZoom, zoomButton } = Utils.ParseVisArgs(
|
||||
this.args,
|
||||
args,
|
||||
args
|
||||
)
|
||||
|
||||
return new SvelteUIElement(CloseNoteButton, {
|
||||
|
@ -293,7 +298,7 @@ export class QuestionViz implements SpecialVisualization {
|
|||
tags: UIEventSource<Record<string, string>>,
|
||||
args: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): SvelteUIElement {
|
||||
const labels = args[0]
|
||||
?.split(";")
|
||||
|
@ -325,7 +330,7 @@ export default class SpecialVisualizations {
|
|||
for (const specialVisualization of SpecialVisualizations.specialVisualizations) {
|
||||
SpecialVisualizations.specialVisualisationsDict.set(
|
||||
specialVisualization.funcName,
|
||||
specialVisualization,
|
||||
specialVisualization
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -345,15 +350,15 @@ export default class SpecialVisualizations {
|
|||
viz.docs,
|
||||
viz.args.length > 0
|
||||
? MarkdownUtils.table(
|
||||
["name", "default", "description"],
|
||||
viz.args.map((arg) => {
|
||||
let defaultArg = arg.defaultValue ?? "_undefined_"
|
||||
if (defaultArg == "") {
|
||||
defaultArg = "_empty string_"
|
||||
}
|
||||
return [arg.name, defaultArg, arg.doc]
|
||||
}),
|
||||
)
|
||||
["name", "default", "description"],
|
||||
viz.args.map((arg) => {
|
||||
let defaultArg = arg.defaultValue ?? "_undefined_"
|
||||
if (defaultArg == "") {
|
||||
defaultArg = "_empty string_"
|
||||
}
|
||||
return [arg.name, defaultArg, arg.doc]
|
||||
})
|
||||
)
|
||||
: undefined,
|
||||
"#### Example usage of " + viz.funcName,
|
||||
"<code>" + example + "</code>",
|
||||
|
@ -362,18 +367,18 @@ export default class SpecialVisualizations {
|
|||
|
||||
public static constructSpecification(
|
||||
template: string,
|
||||
extraMappings: SpecialVisualization[] = [],
|
||||
extraMappings: SpecialVisualization[] = []
|
||||
): RenderingSpecification[] {
|
||||
return SpecialVisualisationUtils.constructSpecification(
|
||||
template,
|
||||
SpecialVisualizations.specialVisualisationsDict,
|
||||
extraMappings,
|
||||
extraMappings
|
||||
)
|
||||
}
|
||||
|
||||
public static HelpMessage(): string {
|
||||
const helpTexts: string[] = SpecialVisualizations.specialVisualizations.map((viz) =>
|
||||
SpecialVisualizations.DocumentationFor(viz),
|
||||
SpecialVisualizations.DocumentationFor(viz)
|
||||
)
|
||||
|
||||
const firstPart = new Combine([
|
||||
|
@ -406,10 +411,10 @@ export default class SpecialVisualizations {
|
|||
},
|
||||
},
|
||||
null,
|
||||
" ",
|
||||
),
|
||||
" "
|
||||
)
|
||||
).SetClass("code"),
|
||||
"In other words: use `{ \"before\": ..., \"after\": ..., \"special\": {\"type\": ..., \"argname\": ...argvalue...}`. The args are in the `special` block; an argvalue can be a string, a translation or another value. (Refer to class `RewriteSpecial` in case of problems)",
|
||||
'In other words: use `{ "before": ..., "after": ..., "special": {"type": ..., "argname": ...argvalue...}`. The args are in the `special` block; an argvalue can be a string, a translation or another value. (Refer to class `RewriteSpecial` in case of problems)',
|
||||
])
|
||||
.SetClass("flex flex-col")
|
||||
.AsMarkdown()
|
||||
|
@ -447,10 +452,10 @@ export default class SpecialVisualizations {
|
|||
assignTo: state.userRelatedState.language,
|
||||
availableLanguages: languages,
|
||||
preferredLanguages: state.osmConnection.userDetails.map(
|
||||
(ud) => ud.languages,
|
||||
(ud) => ud.languages
|
||||
),
|
||||
})
|
||||
}),
|
||||
})
|
||||
)
|
||||
},
|
||||
},
|
||||
|
@ -489,7 +494,7 @@ export default class SpecialVisualizations {
|
|||
state: SpecialVisualizationState,
|
||||
tagSource: UIEventSource<Record<string, string>>,
|
||||
args: string[],
|
||||
feature: Feature,
|
||||
feature: Feature
|
||||
): SvelteUIElement {
|
||||
return new SvelteUIElement(MinimapViz, { state, args, feature, tagSource })
|
||||
},
|
||||
|
@ -501,7 +506,7 @@ export default class SpecialVisualizations {
|
|||
|
||||
constr(
|
||||
state: SpecialVisualizationState,
|
||||
tagSource: UIEventSource<Record<string, string>>,
|
||||
tagSource: UIEventSource<Record<string, string>>
|
||||
): BaseUIElement {
|
||||
return new VariableUiElement(
|
||||
tagSource
|
||||
|
@ -511,7 +516,7 @@ export default class SpecialVisualizations {
|
|||
return new SvelteUIElement(SplitRoadWizard, { id, state })
|
||||
}
|
||||
return undefined
|
||||
}),
|
||||
})
|
||||
)
|
||||
},
|
||||
},
|
||||
|
@ -525,7 +530,7 @@ export default class SpecialVisualizations {
|
|||
tagSource: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement {
|
||||
if (feature.geometry.type !== "Point") {
|
||||
return undefined
|
||||
|
@ -548,7 +553,7 @@ export default class SpecialVisualizations {
|
|||
tagSource: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement {
|
||||
if (!layer.deletion) {
|
||||
return undefined
|
||||
|
@ -574,7 +579,7 @@ export default class SpecialVisualizations {
|
|||
tags: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
) {
|
||||
if (feature.geometry.type !== "LineString") {
|
||||
return undefined
|
||||
|
@ -582,10 +587,13 @@ export default class SpecialVisualizations {
|
|||
const t = Translations.t.general.download
|
||||
|
||||
return new SvelteUIElement(ExportFeatureButton, {
|
||||
tags, feature, layer,
|
||||
tags,
|
||||
feature,
|
||||
layer,
|
||||
mimetype: "{gpx=application/gpx+xml}",
|
||||
extension: "gpx",
|
||||
construct: (feature: Feature<LineString>, title: string) => GeoOperations.toGpx(feature, title),
|
||||
construct: (feature: Feature<LineString>, title: string) =>
|
||||
GeoOperations.toGpx(feature, title),
|
||||
helpertext: t.downloadGpxHelper,
|
||||
maintext: t.downloadFeatureAsGpx,
|
||||
})
|
||||
|
@ -603,7 +611,7 @@ export default class SpecialVisualizations {
|
|||
state: SpecialVisualizationState,
|
||||
tagSource: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
feature: Feature
|
||||
): BaseUIElement {
|
||||
const [lon, lat] = GeoOperations.centerpointCoordinates(feature)
|
||||
return new SvelteUIElement(CreateNewNote, {
|
||||
|
@ -666,7 +674,7 @@ export default class SpecialVisualizations {
|
|||
.map((tags) => tags[args[0]])
|
||||
.map((wikidata) => {
|
||||
wikidata = Utils.NoEmpty(
|
||||
wikidata?.split(";")?.map((wd) => wd.trim()) ?? [],
|
||||
wikidata?.split(";")?.map((wd) => wd.trim()) ?? []
|
||||
)[0]
|
||||
const entry = Wikidata.LoadWikidataEntry(wikidata)
|
||||
return new VariableUiElement(
|
||||
|
@ -676,9 +684,9 @@ export default class SpecialVisualizations {
|
|||
}
|
||||
const response = <WikidataResponse>e["success"]
|
||||
return Translation.fromMap(response.labels)
|
||||
}),
|
||||
})
|
||||
)
|
||||
}),
|
||||
})
|
||||
),
|
||||
},
|
||||
new MapillaryLinkVis(),
|
||||
|
@ -692,7 +700,7 @@ export default class SpecialVisualizations {
|
|||
tags: UIEventSource<Record<string, string>>,
|
||||
_,
|
||||
__,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
) => new SvelteUIElement(AllTagsPanel, { tags, layer }),
|
||||
},
|
||||
{
|
||||
|
@ -775,7 +783,7 @@ export default class SpecialVisualizations {
|
|||
nameKey: nameKey,
|
||||
fallbackName,
|
||||
},
|
||||
state.featureSwitchIsTesting,
|
||||
state.featureSwitchIsTesting
|
||||
)
|
||||
return new SvelteUIElement(StarsBarIcon, {
|
||||
score: reviews.average,
|
||||
|
@ -809,7 +817,7 @@ export default class SpecialVisualizations {
|
|||
nameKey: nameKey,
|
||||
fallbackName,
|
||||
},
|
||||
state.featureSwitchIsTesting,
|
||||
state.featureSwitchIsTesting
|
||||
)
|
||||
return new SvelteUIElement(ReviewForm, { reviews, state, tags, feature, layer })
|
||||
},
|
||||
|
@ -840,7 +848,7 @@ export default class SpecialVisualizations {
|
|||
nameKey: nameKey,
|
||||
fallbackName,
|
||||
},
|
||||
state.featureSwitchIsTesting,
|
||||
state.featureSwitchIsTesting
|
||||
)
|
||||
return new SvelteUIElement(AllReviews, { reviews, state, tags, feature, layer })
|
||||
},
|
||||
|
@ -866,7 +874,7 @@ export default class SpecialVisualizations {
|
|||
tagSource: UIEventSource<Record<string, string>>,
|
||||
args: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement {
|
||||
return new Combine([
|
||||
SpecialVisualizations.specialVisualisationsDict["create_review"].constr(
|
||||
|
@ -874,14 +882,14 @@ export default class SpecialVisualizations {
|
|||
tagSource,
|
||||
args,
|
||||
feature,
|
||||
layer,
|
||||
layer
|
||||
),
|
||||
SpecialVisualizations.specialVisualisationsDict["list_reviews"].constr(
|
||||
state,
|
||||
tagSource,
|
||||
args,
|
||||
feature,
|
||||
layer,
|
||||
layer
|
||||
),
|
||||
])
|
||||
},
|
||||
|
@ -899,7 +907,7 @@ export default class SpecialVisualizations {
|
|||
constr(
|
||||
state: SpecialVisualizationState,
|
||||
_: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
argument: string[]
|
||||
): BaseUIElement {
|
||||
const [text] = argument
|
||||
return new SvelteUIElement(ImportReviewIdentity, { state, text })
|
||||
|
@ -956,7 +964,7 @@ export default class SpecialVisualizations {
|
|||
constr(
|
||||
state: SpecialVisualizationState,
|
||||
tags: UIEventSource<Record<string, string>>,
|
||||
args: string[],
|
||||
args: string[]
|
||||
): SvelteUIElement {
|
||||
const keyToUse = args[0]
|
||||
const prefix = args[1]
|
||||
|
@ -993,17 +1001,17 @@ export default class SpecialVisualizations {
|
|||
return undefined
|
||||
}
|
||||
const allUnits: Unit[] = [].concat(
|
||||
...(state?.theme?.layers?.map((lyr) => lyr.units) ?? []),
|
||||
...(state?.theme?.layers?.map((lyr) => lyr.units) ?? [])
|
||||
)
|
||||
const unit = allUnits.filter((unit) =>
|
||||
unit.isApplicableToKey(key),
|
||||
unit.isApplicableToKey(key)
|
||||
)[0]
|
||||
if (unit === undefined) {
|
||||
return value
|
||||
}
|
||||
const getCountry = () => tagSource.data._country
|
||||
return unit.asHumanLongValue(value, getCountry)
|
||||
}),
|
||||
})
|
||||
)
|
||||
},
|
||||
},
|
||||
|
@ -1015,10 +1023,13 @@ export default class SpecialVisualizations {
|
|||
constr: (state, tags, args, feature, layer) => {
|
||||
const t = Translations.t.general.download
|
||||
return new SvelteUIElement(ExportFeatureButton, {
|
||||
tags, feature, layer,
|
||||
tags,
|
||||
feature,
|
||||
layer,
|
||||
mimetype: "application/vnd.geo+json",
|
||||
extension: "geojson",
|
||||
construct: (feature: Feature<LineString>) => JSON.stringify(feature, null, " "),
|
||||
construct: (feature: Feature<LineString>) =>
|
||||
JSON.stringify(feature, null, " "),
|
||||
maintext: t.downloadFeatureAsGeojson,
|
||||
helpertext: t.downloadGeoJsonHelper,
|
||||
})
|
||||
|
@ -1054,7 +1065,7 @@ export default class SpecialVisualizations {
|
|||
constr: (state) => {
|
||||
return new SubtleButton(
|
||||
new SvelteUIElement(Trash).SetClass("h-6"),
|
||||
Translations.t.general.removeLocationHistory,
|
||||
Translations.t.general.removeLocationHistory
|
||||
).onClick(() => {
|
||||
state.historicalUserLocations.features.setData([])
|
||||
state.selectedElement.setData(undefined)
|
||||
|
@ -1095,10 +1106,10 @@ export default class SpecialVisualizations {
|
|||
new SvelteUIElement(NoteCommentElement, {
|
||||
comment,
|
||||
state,
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
).SetClass("flex flex-col")
|
||||
}),
|
||||
})
|
||||
),
|
||||
},
|
||||
{
|
||||
|
@ -1131,7 +1142,7 @@ export default class SpecialVisualizations {
|
|||
tagsSource: UIEventSource<Record<string, string>>,
|
||||
_: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
) =>
|
||||
new VariableUiElement(
|
||||
tagsSource.map((tags) => {
|
||||
|
@ -1151,7 +1162,7 @@ export default class SpecialVisualizations {
|
|||
})
|
||||
.SetClass("px-1")
|
||||
.setSpan()
|
||||
}),
|
||||
})
|
||||
),
|
||||
},
|
||||
{
|
||||
|
@ -1167,8 +1178,8 @@ export default class SpecialVisualizations {
|
|||
const challenge = Stores.FromPromise(
|
||||
Utils.downloadJsonCached<MaprouletteTask>(
|
||||
`${Maproulette.defaultEndpoint}/challenge/${parentId}`,
|
||||
24 * 60 * 60 * 1000,
|
||||
),
|
||||
24 * 60 * 60 * 1000
|
||||
)
|
||||
)
|
||||
|
||||
return new VariableUiElement(
|
||||
|
@ -1193,7 +1204,7 @@ export default class SpecialVisualizations {
|
|||
} else {
|
||||
return [title, new List(listItems)]
|
||||
}
|
||||
}),
|
||||
})
|
||||
)
|
||||
},
|
||||
docs: "Fetches the metadata of MapRoulette campaign that this task is part of and shows those details (namely `title`, `description` and `instruction`).\n\nThis reads the property `mr_challengeId` to detect the parent campaign.",
|
||||
|
@ -1207,15 +1218,15 @@ export default class SpecialVisualizations {
|
|||
"\n" +
|
||||
"```json\n" +
|
||||
"{\n" +
|
||||
" \"id\": \"mark_duplicate\",\n" +
|
||||
" \"render\": {\n" +
|
||||
" \"special\": {\n" +
|
||||
" \"type\": \"maproulette_set_status\",\n" +
|
||||
" \"message\": {\n" +
|
||||
" \"en\": \"Mark as not found or false positive\"\n" +
|
||||
' "id": "mark_duplicate",\n' +
|
||||
' "render": {\n' +
|
||||
' "special": {\n' +
|
||||
' "type": "maproulette_set_status",\n' +
|
||||
' "message": {\n' +
|
||||
' "en": "Mark as not found or false positive"\n' +
|
||||
" },\n" +
|
||||
" \"status\": \"2\",\n" +
|
||||
" \"image\": \"close\"\n" +
|
||||
' "status": "2",\n' +
|
||||
' "image": "close"\n' +
|
||||
" }\n" +
|
||||
" }\n" +
|
||||
"}\n" +
|
||||
|
@ -1291,7 +1302,7 @@ export default class SpecialVisualizations {
|
|||
(l) =>
|
||||
l.name !== null &&
|
||||
l.title &&
|
||||
state.perLayer.get(l.id) !== undefined,
|
||||
state.perLayer.get(l.id) !== undefined
|
||||
)
|
||||
.map(
|
||||
(l) => {
|
||||
|
@ -1301,8 +1312,8 @@ export default class SpecialVisualizations {
|
|||
const fsBboxed = new BBoxFeatureSourceForLayer(fs, bbox)
|
||||
return new StatisticsPanel(fsBboxed)
|
||||
},
|
||||
[state.mapProperties.bounds],
|
||||
),
|
||||
[state.mapProperties.bounds]
|
||||
)
|
||||
)
|
||||
},
|
||||
},
|
||||
|
@ -1372,7 +1383,7 @@ export default class SpecialVisualizations {
|
|||
constr(
|
||||
state: SpecialVisualizationState,
|
||||
tagSource: UIEventSource<Record<string, string>>,
|
||||
args: string[],
|
||||
args: string[]
|
||||
): SvelteUIElement {
|
||||
let [text, href, classnames, download, ariaLabel, icon] = args
|
||||
if (download === "") {
|
||||
|
@ -1410,7 +1421,7 @@ export default class SpecialVisualizations {
|
|||
},
|
||||
},
|
||||
null,
|
||||
" ",
|
||||
" "
|
||||
) +
|
||||
"\n```",
|
||||
args: [
|
||||
|
@ -1434,7 +1445,7 @@ export default class SpecialVisualizations {
|
|||
featureTags: UIEventSource<Record<string, string>>,
|
||||
args: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
) {
|
||||
const [key, tr, classesRaw] = args
|
||||
let classes = classesRaw ?? ""
|
||||
|
@ -1452,7 +1463,7 @@ export default class SpecialVisualizations {
|
|||
"Could not create a special visualization for multi(",
|
||||
args.join(", ") + ")",
|
||||
"no properties found for object",
|
||||
feature.properties.id,
|
||||
feature.properties.id
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
@ -1468,7 +1479,7 @@ export default class SpecialVisualizations {
|
|||
elements.push(subsTr)
|
||||
}
|
||||
return elements
|
||||
}),
|
||||
})
|
||||
)
|
||||
},
|
||||
},
|
||||
|
@ -1488,7 +1499,7 @@ export default class SpecialVisualizations {
|
|||
tagSource: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement {
|
||||
return new VariableUiElement(
|
||||
tagSource.map((tags) => {
|
||||
|
@ -1500,7 +1511,7 @@ export default class SpecialVisualizations {
|
|||
console.error("Cannot create a translation for", v, "due to", e)
|
||||
return JSON.stringify(v)
|
||||
}
|
||||
}),
|
||||
})
|
||||
)
|
||||
},
|
||||
},
|
||||
|
@ -1520,11 +1531,10 @@ export default class SpecialVisualizations {
|
|||
tags: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement {
|
||||
const key = argument[0]
|
||||
return new SvelteUIElement(FediverseLink, { key, tags, state })
|
||||
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -1543,7 +1553,7 @@ export default class SpecialVisualizations {
|
|||
tagSource: UIEventSource<Record<string, string>>,
|
||||
args: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement {
|
||||
return new FixedUiElement("{" + args[0] + "}")
|
||||
},
|
||||
|
@ -1564,7 +1574,7 @@ export default class SpecialVisualizations {
|
|||
tagSource: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement {
|
||||
const key = argument[0] ?? "value"
|
||||
return new VariableUiElement(
|
||||
|
@ -1582,12 +1592,12 @@ export default class SpecialVisualizations {
|
|||
} catch (e) {
|
||||
return new FixedUiElement(
|
||||
"Could not parse this tag: " +
|
||||
JSON.stringify(value) +
|
||||
" due to " +
|
||||
e,
|
||||
JSON.stringify(value) +
|
||||
" due to " +
|
||||
e
|
||||
).SetClass("alert")
|
||||
}
|
||||
}),
|
||||
})
|
||||
)
|
||||
},
|
||||
},
|
||||
|
@ -1608,7 +1618,7 @@ export default class SpecialVisualizations {
|
|||
tagSource: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement {
|
||||
const giggityUrl = argument[0]
|
||||
return new SvelteUIElement(Giggity, { tags: tagSource, state, giggityUrl })
|
||||
|
@ -1624,12 +1634,12 @@ export default class SpecialVisualizations {
|
|||
_: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement {
|
||||
const tags = (<ThemeViewState>(
|
||||
state
|
||||
)).geolocation.currentUserLocation.features.map(
|
||||
(features) => features[0]?.properties,
|
||||
(features) => features[0]?.properties
|
||||
)
|
||||
return new Combine([
|
||||
new SvelteUIElement(OrientationDebugPanel, {}),
|
||||
|
@ -1651,7 +1661,7 @@ export default class SpecialVisualizations {
|
|||
tagSource: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement {
|
||||
return new SvelteUIElement(MarkAsFavourite, {
|
||||
tags: tagSource,
|
||||
|
@ -1671,7 +1681,7 @@ export default class SpecialVisualizations {
|
|||
tagSource: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement {
|
||||
return new SvelteUIElement(MarkAsFavouriteMini, {
|
||||
tags: tagSource,
|
||||
|
@ -1691,7 +1701,7 @@ export default class SpecialVisualizations {
|
|||
tagSource: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement {
|
||||
return new SvelteUIElement(DirectionIndicator, { state, feature })
|
||||
},
|
||||
|
@ -1704,7 +1714,7 @@ export default class SpecialVisualizations {
|
|||
state: SpecialVisualizationState,
|
||||
tags: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
feature: Feature
|
||||
): SvelteUIElement {
|
||||
return new SvelteUIElement(QrCode, { state, tags, feature })
|
||||
},
|
||||
|
@ -1723,7 +1733,7 @@ export default class SpecialVisualizations {
|
|||
constr(
|
||||
state: SpecialVisualizationState,
|
||||
tagSource: UIEventSource<Record<string, string>>,
|
||||
args: string[],
|
||||
args: string[]
|
||||
): BaseUIElement {
|
||||
const key = args[0] === "" ? "_direction:centerpoint" : args[0]
|
||||
return new VariableUiElement(
|
||||
|
@ -1734,11 +1744,11 @@ export default class SpecialVisualizations {
|
|||
})
|
||||
.mapD((value) => {
|
||||
const dir = GeoOperations.bearingToHuman(
|
||||
GeoOperations.parseBearing(value),
|
||||
GeoOperations.parseBearing(value)
|
||||
)
|
||||
console.log("Human dir", dir)
|
||||
return Translations.t.general.visualFeedback.directionsAbsolute[dir]
|
||||
}),
|
||||
})
|
||||
)
|
||||
},
|
||||
},
|
||||
|
@ -1768,7 +1778,7 @@ export default class SpecialVisualizations {
|
|||
tagSource: UIEventSource<Record<string, string>>,
|
||||
args: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement {
|
||||
const url = args[0]
|
||||
const readonly = args[3] === "yes"
|
||||
|
@ -1794,12 +1804,12 @@ export default class SpecialVisualizations {
|
|||
tagSource: UIEventSource<Record<string, string>>,
|
||||
args: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement {
|
||||
return new Toggle(
|
||||
undefined,
|
||||
new SvelteUIElement(LoginButton, { osmConnection: state.osmConnection }),
|
||||
state.osmConnection.isLoggedIn,
|
||||
state.osmConnection.isLoggedIn
|
||||
)
|
||||
},
|
||||
},
|
||||
|
@ -1837,7 +1847,7 @@ export default class SpecialVisualizations {
|
|||
tags: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement {
|
||||
const key = argument[0] ?? "website"
|
||||
const useProxy = argument[1] !== "no"
|
||||
|
@ -1864,11 +1874,11 @@ export default class SpecialVisualizations {
|
|||
const features =
|
||||
await LinkedDataLoader.fetchVeloparkEntry(
|
||||
url,
|
||||
loadAll,
|
||||
loadAll
|
||||
)
|
||||
const feature =
|
||||
features.find(
|
||||
(f) => f.properties["ref:velopark"] === url,
|
||||
(f) => f.properties["ref:velopark"] === url
|
||||
) ?? features[0]
|
||||
const properties = feature.properties
|
||||
properties["ref:velopark"] = url
|
||||
|
@ -1878,7 +1888,7 @@ export default class SpecialVisualizations {
|
|||
console.error(e)
|
||||
throw e
|
||||
}
|
||||
})(),
|
||||
})()
|
||||
)
|
||||
}
|
||||
return Stores.FromPromiseWithErr(
|
||||
|
@ -1887,27 +1897,27 @@ export default class SpecialVisualizations {
|
|||
return await LinkedDataLoader.fetchJsonLd(
|
||||
url,
|
||||
{ country },
|
||||
useProxy ? "proxy" : "fetch-lod",
|
||||
useProxy ? "proxy" : "fetch-lod"
|
||||
)
|
||||
} catch (e) {
|
||||
console.log(
|
||||
"Could not get with proxy/download LOD, attempting to download directly. Error for ",
|
||||
url,
|
||||
"is",
|
||||
e,
|
||||
e
|
||||
)
|
||||
return await LinkedDataLoader.fetchJsonLd(
|
||||
url,
|
||||
{ country },
|
||||
"fetch-raw",
|
||||
"fetch-raw"
|
||||
)
|
||||
}
|
||||
})(),
|
||||
})()
|
||||
)
|
||||
})
|
||||
|
||||
externalData.addCallbackAndRunD((lod) =>
|
||||
console.log("linked_data_from_website received the following data:", lod),
|
||||
console.log("linked_data_from_website received the following data:", lod)
|
||||
)
|
||||
|
||||
return new Toggle(
|
||||
|
@ -1922,7 +1932,7 @@ export default class SpecialVisualizations {
|
|||
collapsed: isClosed,
|
||||
}),
|
||||
undefined,
|
||||
url.map((url) => !!url),
|
||||
url.map((url) => !!url)
|
||||
)
|
||||
},
|
||||
},
|
||||
|
@ -1942,7 +1952,7 @@ export default class SpecialVisualizations {
|
|||
tagSource: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement {
|
||||
const text = argument[0]
|
||||
const cssClasses = argument[1]
|
||||
|
@ -1964,7 +1974,7 @@ export default class SpecialVisualizations {
|
|||
tagSource: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement {
|
||||
const translation = tagSource.map((tags) => {
|
||||
const layer = state.theme.getMatchingLayer(tags)
|
||||
|
@ -1982,7 +1992,7 @@ export default class SpecialVisualizations {
|
|||
tagSource: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement {
|
||||
return new SvelteUIElement(PendingChangesIndicator, { state, compact: false })
|
||||
},
|
||||
|
@ -2002,7 +2012,7 @@ export default class SpecialVisualizations {
|
|||
tagSource: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): SvelteUIElement {
|
||||
return new SvelteUIElement<any, any, any>(ClearCaches, {
|
||||
msg: argument[0] ?? "Clear local caches",
|
||||
|
@ -2027,7 +2037,7 @@ export default class SpecialVisualizations {
|
|||
tags: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
selectedElement: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): SvelteUIElement {
|
||||
const [header, labelsStr] = argument
|
||||
const labels = labelsStr.split(";").map((x) => x.trim())
|
||||
|
@ -2050,7 +2060,7 @@ export default class SpecialVisualizations {
|
|||
tags: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
selectedElement: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): SvelteUIElement {
|
||||
const t = Translations.t.preset_type
|
||||
const question: QuestionableTagRenderingConfigJson = {
|
||||
|
@ -2090,7 +2100,7 @@ export default class SpecialVisualizations {
|
|||
tagSource: UIEventSource<Record<string, string>>,
|
||||
argument: string[],
|
||||
feature: Feature,
|
||||
layer: LayerConfig,
|
||||
layer: LayerConfig
|
||||
): BaseUIElement {
|
||||
const text = argument[0]
|
||||
return new SubtleButton(undefined, text).onClick(() => {
|
||||
|
@ -2098,6 +2108,15 @@ export default class SpecialVisualizations {
|
|||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
funcName: "disabled_questions",
|
||||
docs: "Shows which questions are disabled for every layer. Used in 'settings'",
|
||||
needsUrls: [],
|
||||
args: [],
|
||||
constr(state) {
|
||||
return new SvelteUIElement(DisabledQuestions, { state })
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
specialVisualizations.push(new AutoApplyButton(specialVisualizations))
|
||||
|
@ -2112,7 +2131,7 @@ export default class SpecialVisualizations {
|
|||
"Invalid special visualisation found: funcName is undefined or doesn't match " +
|
||||
regex +
|
||||
invalid.map((sp) => sp.i).join(", ") +
|
||||
". Did you perhaps type \n funcName: \"funcname\" // type declaration uses COLON\ninstead of:\n funcName = \"funcName\" // value definition uses EQUAL"
|
||||
'. Did you perhaps type \n funcName: "funcname" // type declaration uses COLON\ninstead of:\n funcName = "funcName" // value definition uses EQUAL'
|
||||
)
|
||||
}
|
||||
|
||||
|
|
88
src/UI/Statistics/AllStats.svelte
Normal file
88
src/UI/Statistics/AllStats.svelte
Normal file
|
@ -0,0 +1,88 @@
|
|||
<script lang="ts">
|
||||
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||
import { Utils } from "../../Utils"
|
||||
import Loading from "../Base/Loading.svelte"
|
||||
import type { FeatureCollection } from "geojson"
|
||||
import type { ChangeSetData } from "./ChangesetsOverview"
|
||||
import { ChangesetsOverview } from "./ChangesetsOverview"
|
||||
|
||||
import ThemeConfig from "../../Models/ThemeConfig/ThemeConfig"
|
||||
import mcChanges from "../../assets/generated/themes/mapcomplete-changes.json"
|
||||
import type { ThemeConfigJson } from "../../Models/ThemeConfig/Json/ThemeConfigJson"
|
||||
import { Accordion, AccordionItem } from "flowbite-svelte"
|
||||
import AccordionSingle from "../Flowbite/AccordionSingle.svelte"
|
||||
import Filterview from "../BigComponents/Filterview.svelte"
|
||||
import FilteredLayer from "../../Models/FilteredLayer"
|
||||
import type { OsmFeature } from "../../Models/OsmFeature"
|
||||
import SingleStat from "./SingleStat.svelte"
|
||||
import { DownloadIcon } from "@rgossiaux/svelte-heroicons/solid"
|
||||
import { GeoOperations } from "../../Logic/GeoOperations"
|
||||
|
||||
export let paths: string[]
|
||||
|
||||
let downloaded = 0
|
||||
const layer = new ThemeConfig(<ThemeConfigJson>mcChanges, true).layers[0]
|
||||
const filteredLayer = new FilteredLayer(layer)
|
||||
|
||||
let allData = <UIEventSource<(ChangeSetData & OsmFeature)[]>>UIEventSource.FromPromise(
|
||||
Promise.all(
|
||||
paths.map(async (p) => {
|
||||
const r = await Utils.downloadJson<FeatureCollection>(p)
|
||||
downloaded++
|
||||
return r
|
||||
})
|
||||
)
|
||||
).mapD((list) => [].concat(...list.map((f) => f.features)))
|
||||
|
||||
let overview = allData.mapD(
|
||||
(data) =>
|
||||
ChangesetsOverview.fromDirtyData(data).filter((cs) =>
|
||||
filteredLayer.isShown(<any>cs.properties)
|
||||
),
|
||||
[filteredLayer.currentFilter]
|
||||
)
|
||||
|
||||
const trs = layer.tagRenderings
|
||||
.filter((tr) => tr.mappings?.length > 0 || tr.freeform?.key !== undefined)
|
||||
.filter((tr) => tr.question !== undefined)
|
||||
|
||||
let diffInDays = overview.mapD((overview) => {
|
||||
const dateStrings = Utils.NoNull(overview._meta.map((cs) => cs.properties.date))
|
||||
const dates: number[] = dateStrings.map((d) => new Date(d).getTime())
|
||||
const mindate = Math.min(...dates)
|
||||
const maxdate = Math.max(...dates)
|
||||
return (maxdate - mindate) / (1000 * 60 * 60 * 24)
|
||||
})
|
||||
|
||||
function offerAsDownload() {
|
||||
const data = GeoOperations.toCSV($overview._meta, {
|
||||
ignoreTags: /^((deletion:node)|(import:node)|(move:node)|(soft-delete:))/,
|
||||
})
|
||||
Utils.offerContentsAsDownloadableFile(data, "statistics.csv", {
|
||||
mimetype: "text/csv",
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if downloaded < paths.length}
|
||||
<Loading>Loaded {downloaded} out of {paths.length}</Loading>
|
||||
{:else}
|
||||
<AccordionSingle>
|
||||
<span slot="header">Filters</span>
|
||||
<Filterview {filteredLayer} state={undefined} showLayerTitle={false} />
|
||||
</AccordionSingle>
|
||||
<Accordion>
|
||||
{#each trs as tr}
|
||||
<AccordionItem paddingDefault="p-0" inactiveClass="text-black">
|
||||
<span slot="header" class={"w-full p-2 text-base"}>
|
||||
{tr.question ?? tr.id}
|
||||
</span>
|
||||
<SingleStat {tr} overview={$overview} diffInDays={$diffInDays} />
|
||||
</AccordionItem>
|
||||
{/each}
|
||||
</Accordion>
|
||||
<button on:click={() => offerAsDownload()}>
|
||||
<DownloadIcon class="h-6 w-6" />
|
||||
Download as CSV
|
||||
</button>
|
||||
{/if}
|
139
src/UI/Statistics/ChangesetsOverview.ts
Normal file
139
src/UI/Statistics/ChangesetsOverview.ts
Normal file
|
@ -0,0 +1,139 @@
|
|||
import { Utils } from "../../Utils"
|
||||
import { Feature, Polygon } from "geojson"
|
||||
import { OsmFeature } from "../../Models/OsmFeature"
|
||||
export interface ChangeSetData extends Feature<Polygon> {
|
||||
id: number
|
||||
type: "Feature"
|
||||
geometry: {
|
||||
type: "Polygon"
|
||||
coordinates: [number, number][][]
|
||||
}
|
||||
properties: {
|
||||
check_user: null
|
||||
reasons: []
|
||||
tags: []
|
||||
features: []
|
||||
user: string
|
||||
uid: string
|
||||
editor: string
|
||||
comment: string
|
||||
comments_count: number
|
||||
source: string
|
||||
imagery_used: string
|
||||
date: string
|
||||
reviewed_features: []
|
||||
create: number
|
||||
modify: number
|
||||
delete: number
|
||||
area: number
|
||||
is_suspect: boolean
|
||||
// harmful: any
|
||||
checked: boolean
|
||||
// check_date: any
|
||||
host: string
|
||||
theme: string
|
||||
imagery: string
|
||||
language: string
|
||||
}
|
||||
}
|
||||
|
||||
export class ChangesetsOverview {
|
||||
private static readonly theme_remappings = {
|
||||
metamap: "maps",
|
||||
groen: "buurtnatuur",
|
||||
"updaten van metadata met mapcomplete": "buurtnatuur",
|
||||
"Toevoegen of dit natuurreservaat toegangkelijk is": "buurtnatuur",
|
||||
"wiki:mapcomplete/fritures": "fritures",
|
||||
"wiki:MapComplete/Fritures": "fritures",
|
||||
lits: "lit",
|
||||
pomp: "cyclofix",
|
||||
"wiki:user:joost_schouppe/campersite": "campersite",
|
||||
"wiki-user-joost_schouppe-geveltuintjes": "geveltuintjes",
|
||||
"wiki-user-joost_schouppe-campersite": "campersite",
|
||||
"wiki-User-joost_schouppe-campersite": "campersite",
|
||||
"wiki-User-joost_schouppe-geveltuintjes": "geveltuintjes",
|
||||
"wiki:User:joost_schouppe/campersite": "campersite",
|
||||
arbres: "arbres_llefia",
|
||||
aed_brugge: "aed",
|
||||
"https://llefia.org/arbres/mapcomplete.json": "arbres_llefia",
|
||||
"https://llefia.org/arbres/mapcomplete1.json": "arbres_llefia",
|
||||
"toevoegen of dit natuurreservaat toegangkelijk is": "buurtnatuur",
|
||||
"testing mapcomplete 0.0.0": "buurtnatuur",
|
||||
entrances: "indoor",
|
||||
"https://raw.githubusercontent.com/osmbe/play/master/mapcomplete/geveltuinen/geveltuinen.json":
|
||||
"geveltuintjes",
|
||||
}
|
||||
|
||||
public static readonly valuesToSum: ReadonlyArray<string> = [
|
||||
"create",
|
||||
"modify",
|
||||
"delete",
|
||||
"answer",
|
||||
"move",
|
||||
"deletion",
|
||||
"add-image",
|
||||
"plantnet-ai-detection",
|
||||
"import",
|
||||
"conflation",
|
||||
"link-image",
|
||||
"soft-delete",
|
||||
]
|
||||
public readonly _meta: (ChangeSetData & OsmFeature)[]
|
||||
|
||||
private constructor(meta: (ChangeSetData & OsmFeature)[]) {
|
||||
this._meta = Utils.NoNull(meta)
|
||||
}
|
||||
|
||||
public static fromDirtyData(meta: (ChangeSetData & OsmFeature)[]): ChangesetsOverview {
|
||||
return new ChangesetsOverview(meta?.map((cs) => ChangesetsOverview.cleanChangesetData(cs)))
|
||||
}
|
||||
|
||||
private static cleanChangesetData(cs: ChangeSetData & OsmFeature): ChangeSetData & OsmFeature {
|
||||
if (cs === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (cs.properties.editor?.startsWith("iD")) {
|
||||
// We also fetch based on hashtag, so some edits with iD show up as well
|
||||
return undefined
|
||||
}
|
||||
if (cs.properties.theme === undefined) {
|
||||
cs.properties.theme = cs.properties.comment.substr(
|
||||
cs.properties.comment.lastIndexOf("#") + 1
|
||||
)
|
||||
}
|
||||
cs.properties.theme = cs.properties.theme.toLowerCase()
|
||||
const remapped = ChangesetsOverview.theme_remappings[cs.properties.theme]
|
||||
cs.properties.theme = remapped ?? cs.properties.theme
|
||||
if (cs.properties.theme.startsWith("https://raw.githubusercontent.com/")) {
|
||||
cs.properties.theme =
|
||||
"gh://" + cs.properties.theme.substr("https://raw.githubusercontent.com/".length)
|
||||
}
|
||||
if (cs.properties.modify + cs.properties.delete + cs.properties.create == 0) {
|
||||
cs.properties.theme = "EMPTY CS"
|
||||
}
|
||||
try {
|
||||
cs.properties.host = new URL(cs.properties.host).host
|
||||
} catch (e) {
|
||||
// pass
|
||||
}
|
||||
return cs
|
||||
}
|
||||
|
||||
public filter(predicate: (cs: ChangeSetData) => boolean) {
|
||||
return new ChangesetsOverview(this._meta.filter(predicate))
|
||||
}
|
||||
|
||||
public sum(key: string, excludeThemes: Set<string>): number {
|
||||
let s = 0
|
||||
for (const feature of this._meta) {
|
||||
if (excludeThemes.has(feature.properties.theme)) {
|
||||
continue
|
||||
}
|
||||
const parsed = Number(feature.properties[key])
|
||||
if (!isNaN(parsed)) {
|
||||
s += parsed
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
50
src/UI/Statistics/SingleStat.svelte
Normal file
50
src/UI/Statistics/SingleStat.svelte
Normal file
|
@ -0,0 +1,50 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Shows the statistics for a single item
|
||||
*/
|
||||
import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig"
|
||||
import ToSvelte from "../Base/ToSvelte.svelte"
|
||||
import TagRenderingChart, { StackedRenderingChart } from "../BigComponents/TagRenderingChart"
|
||||
import { ChangesetsOverview } from "./ChangesetsOverview"
|
||||
|
||||
export let overview: ChangesetsOverview
|
||||
export let diffInDays: number
|
||||
export let tr: TagRenderingConfig
|
||||
|
||||
let total: number = undefined
|
||||
if (tr.freeform?.key !== undefined) {
|
||||
total = new Set(overview._meta.map((f) => f.properties[tr.freeform.key])).size
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if total > 1}
|
||||
{total} unique values
|
||||
{/if}
|
||||
<h3>By number of changesets</h3>
|
||||
|
||||
<div class="flex">
|
||||
<ToSvelte
|
||||
construct={new TagRenderingChart(overview._meta, tr, {
|
||||
groupToOtherCutoff: total > 50 ? 25 : total > 10 ? 3 : 0,
|
||||
chartstyle: "width: 24rem; height: 24rem",
|
||||
chartType: "doughnut",
|
||||
sort: true,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ToSvelte
|
||||
construct={new StackedRenderingChart(tr, overview._meta, {
|
||||
period: diffInDays <= 367 ? "day" : "month",
|
||||
groupToOtherCutoff: total > 50 ? 25 : total > 10 ? 3 : 0,
|
||||
})}
|
||||
/>
|
||||
|
||||
<h3>By number of modifications</h3>
|
||||
<ToSvelte
|
||||
construct={new StackedRenderingChart(tr, overview._meta, {
|
||||
period: diffInDays <= 367 ? "day" : "month",
|
||||
groupToOtherCutoff: total > 50 ? 10 : 0,
|
||||
sumFields: ChangesetsOverview.valuesToSum,
|
||||
})}
|
||||
/>
|
26
src/UI/Statistics/StatisticsGui.svelte
Normal file
26
src/UI/Statistics/StatisticsGui.svelte
Normal file
|
@ -0,0 +1,26 @@
|
|||
<script lang="ts">
|
||||
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||
import { Utils } from "../../Utils"
|
||||
import Loading from "../Base/Loading.svelte"
|
||||
import AllStats from "./AllStats.svelte"
|
||||
|
||||
let homeUrl =
|
||||
"https://raw.githubusercontent.com/pietervdvn/MapComplete-data/main/changeset-metadata/"
|
||||
let stats_files = "file-overview.json"
|
||||
|
||||
let indexFile = UIEventSource.FromPromise(Utils.downloadJson<string[]>(homeUrl + stats_files))
|
||||
</script>
|
||||
|
||||
<div class="m-4">
|
||||
<div class="flex justify-between">
|
||||
<h2>Statistics of changes made with MapComplete</h2>
|
||||
<a href="/" class="button">Back to index</a>
|
||||
</div>
|
||||
{#if $indexFile === undefined}
|
||||
<Loading>Loading index file...</Loading>
|
||||
{:else}
|
||||
<AllStats
|
||||
paths={$indexFile.filter((p) => p.startsWith("stats")).map((p) => homeUrl + "/" + p)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
|
@ -1,358 +1,4 @@
|
|||
/**
|
||||
* The statistics-gui shows statistics from previous MapComplete-edits
|
||||
*/
|
||||
import { UIEventSource } from "../Logic/UIEventSource"
|
||||
import { VariableUiElement } from "./Base/VariableUIElement"
|
||||
import Loading from "./Base/Loading"
|
||||
import { Utils } from "../Utils"
|
||||
import Combine from "./Base/Combine"
|
||||
import TagRenderingChart, { StackedRenderingChart } from "./BigComponents/TagRenderingChart"
|
||||
import BaseUIElement from "./BaseUIElement"
|
||||
import Title from "./Base/Title"
|
||||
import { FixedUiElement } from "./Base/FixedUiElement"
|
||||
import List from "./Base/List"
|
||||
import ThemeConfig from "../Models/ThemeConfig/ThemeConfig"
|
||||
import mcChanges from "../../src/assets/generated/themes/mapcomplete-changes.json"
|
||||
import SvelteUIElement from "./Base/SvelteUIElement"
|
||||
import Filterview from "./BigComponents/Filterview.svelte"
|
||||
import FilteredLayer from "../Models/FilteredLayer"
|
||||
import { SubtleButton } from "./Base/SubtleButton"
|
||||
import { GeoOperations } from "../Logic/GeoOperations"
|
||||
import { FeatureCollection, Polygon } from "geojson"
|
||||
import { Feature } from "geojson"
|
||||
import { default as StatisticsSvelte } from "../UI/Statistics/StatisticsGui.svelte"
|
||||
|
||||
class StatsticsForOverviewFile extends Combine {
|
||||
constructor(homeUrl: string, paths: string[]) {
|
||||
paths = paths.filter((p) => !p.endsWith("file-overview.json"))
|
||||
const layer = new ThemeConfig(<any>mcChanges, true).layers[0]
|
||||
const filteredLayer = new FilteredLayer(layer)
|
||||
const filterPanel = new Combine([
|
||||
new Title("Filters"),
|
||||
new SvelteUIElement(Filterview, { filteredLayer }),
|
||||
])
|
||||
filteredLayer.currentFilter.addCallbackAndRun((tf) => {
|
||||
console.log("Filters are", tf)
|
||||
})
|
||||
const downloaded = new UIEventSource<{ features: ChangeSetData[] }[]>([])
|
||||
|
||||
for (const filepath of paths) {
|
||||
if (filepath.endsWith("file-overview.json")) {
|
||||
continue
|
||||
}
|
||||
Utils.downloadJson(homeUrl + filepath).then((data) => {
|
||||
if (data === undefined) {
|
||||
return
|
||||
}
|
||||
if (data.features === undefined) {
|
||||
data.features = data
|
||||
}
|
||||
data?.features?.forEach((item) => {
|
||||
item.properties = { ...item.properties, ...item.properties.metadata }
|
||||
delete item.properties.metadata
|
||||
})
|
||||
downloaded.data.push(data)
|
||||
downloaded.ping()
|
||||
})
|
||||
}
|
||||
|
||||
const loading = new Loading(
|
||||
new VariableUiElement(
|
||||
downloaded.map((dl) => "Downloaded " + dl.length + " items out of " + paths.length)
|
||||
)
|
||||
)
|
||||
|
||||
super([
|
||||
filterPanel,
|
||||
new VariableUiElement(
|
||||
downloaded.map(
|
||||
(downloaded) => {
|
||||
if (downloaded.length !== paths.length) {
|
||||
return loading
|
||||
}
|
||||
|
||||
const overview = ChangesetsOverview.fromDirtyData(
|
||||
[].concat(...downloaded.map((d) => d.features))
|
||||
).filter((cs) => filteredLayer.isShown(<any>cs.properties))
|
||||
console.log("Overview is", overview)
|
||||
|
||||
if (overview._meta.length === 0) {
|
||||
return "No data matched the filter"
|
||||
}
|
||||
|
||||
const dateStrings = Utils.NoNull(
|
||||
overview._meta.map((cs) => cs.properties.date)
|
||||
)
|
||||
const dates: number[] = dateStrings.map((d) => new Date(d).getTime())
|
||||
const mindate = Math.min(...dates)
|
||||
const maxdate = Math.max(...dates)
|
||||
|
||||
const diffInDays = (maxdate - mindate) / (1000 * 60 * 60 * 24)
|
||||
console.log("Diff in days is ", diffInDays, "got", overview._meta.length)
|
||||
const trs = layer.tagRenderings.filter(
|
||||
(tr) => tr.mappings?.length > 0 || tr.freeform?.key !== undefined
|
||||
)
|
||||
|
||||
const allKeys = new Set<string>()
|
||||
for (const cs of overview._meta) {
|
||||
for (const propertiesKey in cs.properties) {
|
||||
allKeys.add(propertiesKey)
|
||||
}
|
||||
}
|
||||
console.log("All keys:", allKeys)
|
||||
|
||||
const valuesToSum = [
|
||||
"create",
|
||||
"modify",
|
||||
"delete",
|
||||
"answer",
|
||||
"move",
|
||||
"deletion",
|
||||
"add-image",
|
||||
"plantnet-ai-detection",
|
||||
"import",
|
||||
"conflation",
|
||||
"link-image",
|
||||
"soft-delete",
|
||||
]
|
||||
|
||||
const allThemes = Utils.Dedup(overview._meta.map((f) => f.properties.theme))
|
||||
|
||||
const excludedThemes = new Set<string>()
|
||||
if (allThemes.length > 1) {
|
||||
excludedThemes.add("grb")
|
||||
excludedThemes.add("etymology")
|
||||
}
|
||||
const summedValues = valuesToSum
|
||||
.map((key) => [key, overview.sum(key, excludedThemes)])
|
||||
.filter((kv) => kv[1] != 0)
|
||||
.map((kv) => kv.join(": "))
|
||||
const elements: BaseUIElement[] = [
|
||||
new Title(
|
||||
allThemes.length === 1
|
||||
? "General statistics for " + allThemes[0]
|
||||
: "General statistics (excluding etymology- and GRB-theme changes)"
|
||||
),
|
||||
new Combine([
|
||||
overview._meta.length + " changesets match the filters",
|
||||
new List(summedValues),
|
||||
]).SetClass("flex flex-col border rounded-xl"),
|
||||
|
||||
new Title("Breakdown"),
|
||||
]
|
||||
for (const tr of trs) {
|
||||
if (tr.question === undefined) {
|
||||
continue
|
||||
}
|
||||
console.log(tr)
|
||||
let total = undefined
|
||||
if (tr.freeform?.key !== undefined) {
|
||||
total = new Set(
|
||||
overview._meta.map((f) => f.properties[tr.freeform.key])
|
||||
).size
|
||||
}
|
||||
|
||||
try {
|
||||
elements.push(
|
||||
new Combine([
|
||||
new Title(tr.question ?? tr.id).SetClass("p-2"),
|
||||
total > 1 ? total + " unique value" : undefined,
|
||||
new Title("By number of changesets", 4).SetClass("p-2"),
|
||||
new StackedRenderingChart(tr, <any>overview._meta, {
|
||||
period: diffInDays <= 367 ? "day" : "month",
|
||||
groupToOtherCutoff:
|
||||
total > 50 ? 25 : total > 10 ? 3 : 0,
|
||||
}).SetStyle("width: 75%; height: 600px"),
|
||||
new TagRenderingChart(<any>overview._meta, tr, {
|
||||
groupToOtherCutoff:
|
||||
total > 50 ? 25 : total > 10 ? 3 : 0,
|
||||
chartType: "doughnut",
|
||||
chartclasses: "w-8 h-8",
|
||||
sort: true,
|
||||
}).SetStyle("width: 25rem"),
|
||||
new Title("By number of modifications", 4).SetClass("p-2"),
|
||||
new StackedRenderingChart(
|
||||
tr,
|
||||
<any>Utils.Clone(overview._meta),
|
||||
{
|
||||
period: diffInDays <= 367 ? "day" : "month",
|
||||
groupToOtherCutoff: total > 50 ? 10 : 0,
|
||||
sumFields: valuesToSum,
|
||||
}
|
||||
).SetStyle("width: 100%; height: 600px"),
|
||||
]).SetClass("block border-2 border-subtle p-2 m-2 rounded-xl")
|
||||
)
|
||||
} catch (e) {
|
||||
console.log("Could not generate a chart", e)
|
||||
elements.push(
|
||||
new FixedUiElement(
|
||||
"No relevant information for " + tr.question.txt
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
elements.push(
|
||||
new SubtleButton(undefined, "Download as csv").onClick(() => {
|
||||
const data = GeoOperations.toCSV(overview._meta, {
|
||||
ignoreTags:
|
||||
/^((deletion:node)|(import:node)|(move:node)|(soft-delete:))/,
|
||||
})
|
||||
Utils.offerContentsAsDownloadableFile(data, "statistics.csv", {
|
||||
mimetype: "text/csv",
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
return new Combine(elements)
|
||||
},
|
||||
[filteredLayer.currentFilter]
|
||||
)
|
||||
).SetClass("block w-full h-full"),
|
||||
])
|
||||
this.SetClass("block w-full h-full")
|
||||
}
|
||||
}
|
||||
|
||||
class StatisticsGUI extends VariableUiElement {
|
||||
private static readonly homeUrl =
|
||||
"https://raw.githubusercontent.com/pietervdvn/MapComplete-data/main/changeset-metadata/"
|
||||
private static readonly stats_files = "file-overview.json"
|
||||
|
||||
constructor() {
|
||||
const index = UIEventSource.FromPromise(
|
||||
Utils.downloadJson(StatisticsGUI.homeUrl + StatisticsGUI.stats_files)
|
||||
)
|
||||
super(
|
||||
index.map((paths) => {
|
||||
if (paths === undefined) {
|
||||
return new Loading("Loading overview...")
|
||||
}
|
||||
|
||||
return new StatsticsForOverviewFile(StatisticsGUI.homeUrl, paths)
|
||||
})
|
||||
)
|
||||
this.SetClass("block w-full h-full")
|
||||
}
|
||||
}
|
||||
|
||||
class ChangesetsOverview {
|
||||
private static readonly theme_remappings = {
|
||||
metamap: "maps",
|
||||
groen: "buurtnatuur",
|
||||
"updaten van metadata met mapcomplete": "buurtnatuur",
|
||||
"Toevoegen of dit natuurreservaat toegangkelijk is": "buurtnatuur",
|
||||
"wiki:mapcomplete/fritures": "fritures",
|
||||
"wiki:MapComplete/Fritures": "fritures",
|
||||
lits: "lit",
|
||||
pomp: "cyclofix",
|
||||
"wiki:user:joost_schouppe/campersite": "campersite",
|
||||
"wiki-user-joost_schouppe-geveltuintjes": "geveltuintjes",
|
||||
"wiki-user-joost_schouppe-campersite": "campersite",
|
||||
"wiki-User-joost_schouppe-campersite": "campersite",
|
||||
"wiki-User-joost_schouppe-geveltuintjes": "geveltuintjes",
|
||||
"wiki:User:joost_schouppe/campersite": "campersite",
|
||||
arbres: "arbres_llefia",
|
||||
aed_brugge: "aed",
|
||||
"https://llefia.org/arbres/mapcomplete.json": "arbres_llefia",
|
||||
"https://llefia.org/arbres/mapcomplete1.json": "arbres_llefia",
|
||||
"toevoegen of dit natuurreservaat toegangkelijk is": "buurtnatuur",
|
||||
"testing mapcomplete 0.0.0": "buurtnatuur",
|
||||
entrances: "indoor",
|
||||
"https://raw.githubusercontent.com/osmbe/play/master/mapcomplete/geveltuinen/geveltuinen.json":
|
||||
"geveltuintjes",
|
||||
}
|
||||
public readonly _meta: ChangeSetData[]
|
||||
|
||||
private constructor(meta: ChangeSetData[]) {
|
||||
this._meta = Utils.NoNull(meta)
|
||||
}
|
||||
|
||||
public static fromDirtyData(meta: ChangeSetData[]): ChangesetsOverview {
|
||||
return new ChangesetsOverview(meta?.map((cs) => ChangesetsOverview.cleanChangesetData(cs)))
|
||||
}
|
||||
|
||||
private static cleanChangesetData(cs: ChangeSetData): ChangeSetData {
|
||||
if (cs === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (cs.properties.editor?.startsWith("iD")) {
|
||||
// We also fetch based on hashtag, so some edits with iD show up as well
|
||||
return undefined
|
||||
}
|
||||
if (cs.properties.theme === undefined) {
|
||||
cs.properties.theme = cs.properties.comment.substr(
|
||||
cs.properties.comment.lastIndexOf("#") + 1
|
||||
)
|
||||
}
|
||||
cs.properties.theme = cs.properties.theme.toLowerCase()
|
||||
const remapped = ChangesetsOverview.theme_remappings[cs.properties.theme]
|
||||
cs.properties.theme = remapped ?? cs.properties.theme
|
||||
if (cs.properties.theme.startsWith("https://raw.githubusercontent.com/")) {
|
||||
cs.properties.theme =
|
||||
"gh://" + cs.properties.theme.substr("https://raw.githubusercontent.com/".length)
|
||||
}
|
||||
if (cs.properties.modify + cs.properties.delete + cs.properties.create == 0) {
|
||||
cs.properties.theme = "EMPTY CS"
|
||||
}
|
||||
try {
|
||||
cs.properties.host = new URL(cs.properties.host).host
|
||||
} catch (e) {}
|
||||
return cs
|
||||
}
|
||||
|
||||
public filter(predicate: (cs: ChangeSetData) => boolean) {
|
||||
return new ChangesetsOverview(this._meta.filter(predicate))
|
||||
}
|
||||
|
||||
public sum(key: string, excludeThemes: Set<string>): number {
|
||||
let s = 0
|
||||
for (const feature of this._meta) {
|
||||
if (excludeThemes.has(feature.properties.theme)) {
|
||||
continue
|
||||
}
|
||||
const parsed = Number(feature.properties[key])
|
||||
if (!isNaN(parsed)) {
|
||||
s += parsed
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
interface ChangeSetData extends Feature<Polygon> {
|
||||
id: number
|
||||
type: "Feature"
|
||||
geometry: {
|
||||
type: "Polygon"
|
||||
coordinates: [number, number][][]
|
||||
}
|
||||
properties: {
|
||||
check_user: null
|
||||
reasons: []
|
||||
tags: []
|
||||
features: []
|
||||
user: string
|
||||
uid: string
|
||||
editor: string
|
||||
comment: string
|
||||
comments_count: number
|
||||
source: string
|
||||
imagery_used: string
|
||||
date: string
|
||||
reviewed_features: []
|
||||
create: number
|
||||
modify: number
|
||||
delete: number
|
||||
area: number
|
||||
is_suspect: boolean
|
||||
harmful: any
|
||||
checked: boolean
|
||||
check_date: any
|
||||
host: string
|
||||
theme: string
|
||||
imagery: string
|
||||
language: string
|
||||
}
|
||||
}
|
||||
|
||||
new StatisticsGUI().AttachTo("main")
|
||||
new SvelteUIElement(StatisticsSvelte).AttachTo("main")
|
||||
|
|
|
@ -1,2 +1,94 @@
|
|||
<script lang="ts">
|
||||
import FileSelector from "./Base/FileSelector.svelte"
|
||||
import { UIEventSource } from "../Logic/UIEventSource"
|
||||
import ExifReader from "exifreader"
|
||||
import Constants from "../Models/Constants"
|
||||
import { AuthorizedPanoramax, ImageData } from "panoramax-js"
|
||||
import PanoramaxImageProvider from "../Logic/ImageProviders/Panoramax"
|
||||
|
||||
let log = new UIEventSource<string[]>(["Select a file to test..."])
|
||||
|
||||
function l(...txt: ReadonlyArray<string | number>) {
|
||||
console.log(...txt)
|
||||
log.set([...log.data, txt.join(" ")])
|
||||
}
|
||||
|
||||
async function onSubmit(fs: FileList) {
|
||||
const f = fs[0]
|
||||
l("Files are", f.name)
|
||||
|
||||
let [lon, lat] = [3.5, 51.2]
|
||||
let datetime = new Date().toISOString()
|
||||
try {
|
||||
l("Trying to read EXIF-data from the file...")
|
||||
const tags = await ExifReader.load(f)
|
||||
l("Exif data loaded")
|
||||
l("GPSLatitude.value is :", JSON.stringify(tags?.GPSLatitude.value))
|
||||
l("GPSLongitude.value is :", JSON.stringify(tags?.GPSLongitude.value))
|
||||
|
||||
const [[latD], [latM], [latS, latSDenom]] = <
|
||||
[[number, number], [number, number], [number, number]]
|
||||
>tags?.GPSLatitude?.value
|
||||
const [[lonD], [lonM], [lonS, lonSDenom]] = <
|
||||
[[number, number], [number, number], [number, number]]
|
||||
>tags?.GPSLongitude?.value
|
||||
const exifLat = latD + latM / 60 + latS / (3600 * latSDenom)
|
||||
const exifLon = lonD + lonM / 60 + lonS / (3600 * lonSDenom)
|
||||
if (
|
||||
typeof exifLat === "number" &&
|
||||
!isNaN(exifLat) &&
|
||||
typeof exifLon === "number" &&
|
||||
!isNaN(exifLon) &&
|
||||
!(exifLat === 0 && exifLon === 0)
|
||||
) {
|
||||
lat = exifLat
|
||||
lon = exifLon
|
||||
l("Using EXIFLAT + EXIFLON")
|
||||
} else {
|
||||
l("NOT using exifLat and exifLon: invalid value detected")
|
||||
}
|
||||
l("Lat and lon are", lat, lon)
|
||||
l("Datetime value is", JSON.stringify(tags.DateTime))
|
||||
const [date, time] = tags.DateTime.value[0].split(" ")
|
||||
datetime = new Date(date.replaceAll(":", "-") + "T" + time).toISOString()
|
||||
l("Datetime parsed is", datetime)
|
||||
console.log("Tags are", tags)
|
||||
} catch (e) {
|
||||
console.error("Could not read EXIF-tags")
|
||||
l("Could not read the exif tags:", e, JSON.stringify(e))
|
||||
}
|
||||
|
||||
try {
|
||||
const p = new AuthorizedPanoramax(Constants.panoramax.url, Constants.panoramax.token)
|
||||
const sequenceId = "7f34cf53-27ff-46c9-ac22-78511fa8457a" // test-sequence
|
||||
l("Fetching sequence number...")
|
||||
const sequence: { id: string; "stats:items": { count: number } } = (
|
||||
await p.mySequences()
|
||||
).find((s) => s.id === sequenceId)
|
||||
l("Sequence number is", sequence["stats:items"].count, "now attempting upload")
|
||||
const img = <ImageData>await p.addImage(f, sequence, {
|
||||
lon,
|
||||
lat,
|
||||
datetime,
|
||||
isBlurred: false,
|
||||
exifOverride: {
|
||||
Artist: "TEST ACCOUNT",
|
||||
},
|
||||
})
|
||||
l("Upload completed. Adding meta")
|
||||
PanoramaxImageProvider.singleton.addKnownMeta(img)
|
||||
l("Meta added")
|
||||
} catch (e) {
|
||||
l("Error while uploading:", e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<FileSelector accept="image/jpg" multiple={false} on:submit={(f) => onSubmit(f.detail)}>
|
||||
<div class="border border-black p-1">Select file</div>
|
||||
</FileSelector>
|
||||
<div class="flex flex-col">
|
||||
{#each $log as logl}
|
||||
<div>{logl}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
@ -54,7 +54,6 @@
|
|||
|
||||
let theme = state.theme
|
||||
let maplibremap: UIEventSource<MlMap> = state.map
|
||||
let state_selectedElement = state.selectedElement
|
||||
let selectedElement: UIEventSource<Feature> = new UIEventSource<Feature>(undefined)
|
||||
let compass = Orientation.singleton.alpha
|
||||
let compassLoaded = Orientation.singleton.gotMeasurement
|
||||
|
@ -99,7 +98,7 @@
|
|||
|
||||
state.mapProperties.installCustomKeyboardHandler(viewport)
|
||||
|
||||
let selectedLayer: Store<LayerConfig> = state.selectedElement.mapD((element) => {
|
||||
let selectedLayer: Store<LayerConfig> = selectedElement.mapD((element) => {
|
||||
if (element.properties.id.startsWith("current_view")) {
|
||||
return currentViewLayer
|
||||
}
|
||||
|
@ -458,7 +457,7 @@
|
|||
}}
|
||||
>
|
||||
<div slot="close-button" />
|
||||
<SelectedElementPanel {state} selected={$state_selectedElement} />
|
||||
<SelectedElementPanel {state} selected={$selectedElement} />
|
||||
</Drawer>
|
||||
{/if}
|
||||
|
||||
|
@ -472,7 +471,7 @@
|
|||
}}
|
||||
>
|
||||
<span slot="close-button" />
|
||||
<SelectedElementPanel absolute={false} {state} selected={$state_selectedElement} />
|
||||
<SelectedElementPanel absolute={false} {state} selected={$selectedElement} />
|
||||
</FloatOver>
|
||||
{:else}
|
||||
<FloatOver
|
||||
|
@ -480,11 +479,7 @@
|
|||
state.selectedElement.setData(undefined)
|
||||
}}
|
||||
>
|
||||
<SelectedElementView
|
||||
{state}
|
||||
layer={$selectedLayer}
|
||||
selectedElement={$state_selectedElement}
|
||||
/>
|
||||
<SelectedElementView {state} layer={$selectedLayer} selectedElement={$selectedElement} />
|
||||
</FloatOver>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
21
src/Utils.ts
21
src/Utils.ts
|
@ -1291,6 +1291,21 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
|||
return newD
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* {"a": "b", "c":"d"} // => {"b":"a", "d":"c"}
|
||||
*/
|
||||
public static transposeMapSimple<K extends string, V extends string>(
|
||||
d: Record<K, V>
|
||||
): Record<V, K> {
|
||||
const inv = <Record<V, K>>{}
|
||||
for (const k in d) {
|
||||
const v = d[k]
|
||||
inv[v] = k
|
||||
}
|
||||
return inv
|
||||
}
|
||||
|
||||
/**
|
||||
* Utils.colorAsHex({r: 255, g: 128, b: 0}) // => "#ff8000"
|
||||
* Utils.colorAsHex(undefined) // => undefined
|
||||
|
@ -1534,10 +1549,10 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
|||
let _: string
|
||||
const matchWithFuncName = stackItem.match(regex)
|
||||
if (matchWithFuncName) {
|
||||
[_, functionName, path, line, column] = matchWithFuncName
|
||||
;[_, functionName, path, line, column] = matchWithFuncName
|
||||
} else {
|
||||
const regexNoFuncName: RegExp = new RegExp("at ([a-zA-Z0-9-/.]+):([0-9]+):([0-9]+)");
|
||||
[_, path, line, column] = stackItem.match(regexNoFuncName)
|
||||
const regexNoFuncName: RegExp = new RegExp("at ([a-zA-Z0-9-/.]+):([0-9]+):([0-9]+)")
|
||||
;[_, path, line, column] = stackItem.match(regexNoFuncName)
|
||||
}
|
||||
|
||||
const markdownLocation = path.substring(path.indexOf("MapComplete/src") + 11) + "#L" + line
|
||||
|
|
|
@ -915,7 +915,7 @@ export class SvgToPdf {
|
|||
| "poster_a3"
|
||||
| "poster_a2"
|
||||
| "current_view_a4"
|
||||
|"current_view_a4_portrait"
|
||||
| "current_view_a4_portrait"
|
||||
| "current_view_a3_portrait"
|
||||
| "current_view_a3_landscape",
|
||||
PdfTemplateInfo
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"contributors": [
|
||||
{
|
||||
"commits": 8533,
|
||||
"commits": 8650,
|
||||
"contributor": "Pieter Vander Vennet"
|
||||
},
|
||||
{
|
||||
"commits": 483,
|
||||
"commits": 495,
|
||||
"contributor": "Robin van der Linde"
|
||||
},
|
||||
{
|
||||
|
@ -13,7 +13,7 @@
|
|||
"contributor": "Tobias"
|
||||
},
|
||||
{
|
||||
"commits": 40,
|
||||
"commits": 42,
|
||||
"contributor": "dependabot[bot]"
|
||||
},
|
||||
{
|
||||
|
@ -152,6 +152,10 @@
|
|||
"commits": 6,
|
||||
"contributor": "David Haberthür"
|
||||
},
|
||||
{
|
||||
"commits": 4,
|
||||
"contributor": "Languages add-on"
|
||||
},
|
||||
{
|
||||
"commits": 4,
|
||||
"contributor": "Daniele Santini"
|
||||
|
@ -164,10 +168,6 @@
|
|||
"commits": 4,
|
||||
"contributor": "Ward Beyens"
|
||||
},
|
||||
{
|
||||
"commits": 3,
|
||||
"contributor": "Languages add-on"
|
||||
},
|
||||
{
|
||||
"commits": 3,
|
||||
"contributor": "Thierry1030"
|
||||
|
@ -180,6 +180,14 @@
|
|||
"commits": 3,
|
||||
"contributor": "Léo Villeveygoux"
|
||||
},
|
||||
{
|
||||
"commits": 2,
|
||||
"contributor": "Jens Köcke"
|
||||
},
|
||||
{
|
||||
"commits": 2,
|
||||
"contributor": "Jim Kats"
|
||||
},
|
||||
{
|
||||
"commits": 2,
|
||||
"contributor": "Kim Minwoo"
|
||||
|
|
|
@ -130,29 +130,6 @@
|
|||
"url": "https://github.com/ZeLonewolf/openstreetmap-americana/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MapTiler",
|
||||
"url": "https://api.maptiler.com/maps/15cc8f61-0353-4be6-b8da-13daea5f7432/style.json?key=GvoVAJgu46I5rZapJuAy",
|
||||
"style": "https://api.maptiler.com/maps/15cc8f61-0353-4be6-b8da-13daea5f7432/style.json?key=GvoVAJgu46I5rZapJuAy",
|
||||
"category": "osmbasedmap",
|
||||
"id": "maptiler",
|
||||
"type": "vector",
|
||||
"attribution": {
|
||||
"text": "Maptiler",
|
||||
"url": "https://www.maptiler.com/copyright/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MapTiler Carto",
|
||||
"url": "https://api.maptiler.com/maps/openstreetmap/style.json?key=GvoVAJgu46I5rZapJuAy",
|
||||
"category": "osmbasedmap",
|
||||
"id": "maptiler.carto",
|
||||
"type": "vector",
|
||||
"attribution": {
|
||||
"text": "Maptiler",
|
||||
"url": "https://www.maptiler.com/copyright/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Alidade Smooth",
|
||||
"url": "https://tiles-eu.stadiamaps.com/styles/alidade_smooth.json?key=14c5a900-7137-42f7-9cb9-fff0f4696f75",
|
||||
|
|
|
@ -1709,6 +1709,10 @@
|
|||
{
|
||||
"if": "value=polygon_centroid",
|
||||
"then": "Show an icon at a polygon centroid (but not if it is a way)"
|
||||
},
|
||||
{
|
||||
"if": "value=waypoints",
|
||||
"then": "Show an icon on every intermediate point of a way"
|
||||
}
|
||||
],
|
||||
"multianswer": "true"
|
||||
|
@ -10561,7 +10565,7 @@
|
|||
"title": "value.title"
|
||||
},
|
||||
"type": "array",
|
||||
"description": "<div class='flex'>\n <div>\nPresets for this layer.\nA preset consists of one or more attributes (tags), a title and optionally a description and optionally example images.\nWhen the contributor wishes to add a point to OpenStreetMap, they'll:\n1. Press the 'add new point'-button\n2. Choose a preset from the list of all presets\n3. Confirm the choice. In this step, the `description` (if set) and `exampleImages` (if given) will be shown\n4. Confirm the location\n5. A new point will be created with the attributes that were defined in the preset\nIf no presets are defined, the button which invites to add a new preset will not be shown.\n</div>\n<video controls autoplay muted src='./Docs/Screenshots/AddNewItemScreencast.webm' class='w-64'/>\n</div>"
|
||||
"description": "<div class='flex'>\n <div>\nPresets for this layer.\nA preset consists of one or more attributes (tags), a title and optionally a description and optionally example images.\nWhen the contributor wishes to add a point to OpenStreetMap, they'll:\n1. Press the 'add new point'-button\n2. Choose a preset from the list of all presets\n3. Confirm the choice. In this step, the `description` (if set) and `exampleImages` (if given) will be shown\n4. Confirm the location\n5. A new point will be created with the attributes that were defined in the preset\nIf no presets are defined, the button which invites to add a new preset will not be shown.\n</div>\n<video controls autoplay muted src='https://github.com/pietervdvn/MapComplete/raw/refs/heads/master/Docs/Screenshots/AddNewItemScreencast.webm' class='w-64'/>\n</div>"
|
||||
},
|
||||
{
|
||||
"path": [
|
||||
|
@ -10815,6 +10819,10 @@
|
|||
"if": "value=cycleways_and_roads",
|
||||
"then": "cycleways_and_roads - All infrastructure that someone can cycle over, accompanied with questions about this infrastructure"
|
||||
},
|
||||
{
|
||||
"if": "value=cyclist_waiting_aid",
|
||||
"then": "cyclist_waiting_aid - Various pieces of infrastructure that aid cyclists while they wait at a traffic light."
|
||||
},
|
||||
{
|
||||
"if": "value=defibrillator",
|
||||
"then": "defibrillator - A layer showing defibrillators which can be used in case of emergency. This contains public defibrillators, but also defibrillators which might need staff to fetch the actual device"
|
||||
|
@ -11219,6 +11227,14 @@
|
|||
"if": "value=surveillance_camera",
|
||||
"then": "surveillance_camera - This layer shows surveillance cameras and allows a contributor to update information and add new cameras"
|
||||
},
|
||||
{
|
||||
"if": "value=tactile_map",
|
||||
"then": "tactile_map - Layer showing tactile maps, which can be used by visually impaired people to navigate the city."
|
||||
},
|
||||
{
|
||||
"if": "value=tactile_model",
|
||||
"then": "tactile_model - Layer showing tactile models, three-dimensional models of the surrounding area."
|
||||
},
|
||||
{
|
||||
"if": "value=tertiary_education",
|
||||
"then": "tertiary_education - Layer with all tertiary education institutes (ISCED:2011 levels 6,7 and 8)"
|
||||
|
@ -11457,7 +11473,14 @@
|
|||
]
|
||||
},
|
||||
"labels": {
|
||||
"description": "What labels should be applied on this tagRendering?\n\nA list of labels. These are strings that are used for various purposes, e.g. to only include a subset of the tagRenderings when reusing a layer\n\nSpecial values:\n- \"hidden\": do not show this tagRendering. Useful in it is used by e.g. an accordion\n- \"description\": this label is a description used in the search",
|
||||
"description": "question: What labels should be applied on this tagRendering?\n\nA list of labels. These are strings that are used for various purposes, e.g. to only include a subset of the tagRenderings when reusing a layer\n\nSpecial values:\n- \"hidden\": do not show this tagRendering. Useful in it is used by e.g. an accordion\n- \"description\": this label is a description used in the search",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"onSoftDelete": {
|
||||
"description": "question: What tags should be applied when the object is soft-deleted?",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
|
@ -12595,9 +12618,23 @@
|
|||
"labels"
|
||||
],
|
||||
"required": false,
|
||||
"hints": {},
|
||||
"hints": {
|
||||
"question": "What labels should be applied on this tagRendering?"
|
||||
},
|
||||
"type": "array",
|
||||
"description": "What labels should be applied on this tagRendering?\nA list of labels. These are strings that are used for various purposes, e.g. to only include a subset of the tagRenderings when reusing a layer\nSpecial values:\n- \"hidden\": do not show this tagRendering. Useful in it is used by e.g. an accordion\n- \"description\": this label is a description used in the search"
|
||||
"description": "A list of labels. These are strings that are used for various purposes, e.g. to only include a subset of the tagRenderings when reusing a layer\nSpecial values:\n- \"hidden\": do not show this tagRendering. Useful in it is used by e.g. an accordion\n- \"description\": this label is a description used in the search"
|
||||
},
|
||||
{
|
||||
"path": [
|
||||
"tagRenderings",
|
||||
"onSoftDelete"
|
||||
],
|
||||
"required": false,
|
||||
"hints": {
|
||||
"question": "What tags should be applied when the object is soft-deleted?"
|
||||
},
|
||||
"type": "array",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"path": [
|
||||
|
@ -13931,9 +13968,24 @@
|
|||
"labels"
|
||||
],
|
||||
"required": false,
|
||||
"hints": {},
|
||||
"hints": {
|
||||
"question": "What labels should be applied on this tagRendering?"
|
||||
},
|
||||
"type": "array",
|
||||
"description": "What labels should be applied on this tagRendering?\nA list of labels. These are strings that are used for various purposes, e.g. to only include a subset of the tagRenderings when reusing a layer\nSpecial values:\n- \"hidden\": do not show this tagRendering. Useful in it is used by e.g. an accordion\n- \"description\": this label is a description used in the search"
|
||||
"description": "A list of labels. These are strings that are used for various purposes, e.g. to only include a subset of the tagRenderings when reusing a layer\nSpecial values:\n- \"hidden\": do not show this tagRendering. Useful in it is used by e.g. an accordion\n- \"description\": this label is a description used in the search"
|
||||
},
|
||||
{
|
||||
"path": [
|
||||
"tagRenderings",
|
||||
"override",
|
||||
"onSoftDelete"
|
||||
],
|
||||
"required": false,
|
||||
"hints": {
|
||||
"question": "What tags should be applied when the object is soft-deleted?"
|
||||
},
|
||||
"type": "array",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"path": [
|
||||
|
@ -15301,9 +15353,24 @@
|
|||
"labels"
|
||||
],
|
||||
"required": false,
|
||||
"hints": {},
|
||||
"hints": {
|
||||
"question": "What labels should be applied on this tagRendering?"
|
||||
},
|
||||
"type": "array",
|
||||
"description": "What labels should be applied on this tagRendering?\nA list of labels. These are strings that are used for various purposes, e.g. to only include a subset of the tagRenderings when reusing a layer\nSpecial values:\n- \"hidden\": do not show this tagRendering. Useful in it is used by e.g. an accordion\n- \"description\": this label is a description used in the search"
|
||||
"description": "A list of labels. These are strings that are used for various purposes, e.g. to only include a subset of the tagRenderings when reusing a layer\nSpecial values:\n- \"hidden\": do not show this tagRendering. Useful in it is used by e.g. an accordion\n- \"description\": this label is a description used in the search"
|
||||
},
|
||||
{
|
||||
"path": [
|
||||
"tagRenderings",
|
||||
"renderings",
|
||||
"onSoftDelete"
|
||||
],
|
||||
"required": false,
|
||||
"hints": {
|
||||
"question": "What tags should be applied when the object is soft-deleted?"
|
||||
},
|
||||
"type": "array",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"path": [
|
||||
|
@ -16688,9 +16755,25 @@
|
|||
"labels"
|
||||
],
|
||||
"required": false,
|
||||
"hints": {},
|
||||
"hints": {
|
||||
"question": "What labels should be applied on this tagRendering?"
|
||||
},
|
||||
"type": "array",
|
||||
"description": "What labels should be applied on this tagRendering?\nA list of labels. These are strings that are used for various purposes, e.g. to only include a subset of the tagRenderings when reusing a layer\nSpecial values:\n- \"hidden\": do not show this tagRendering. Useful in it is used by e.g. an accordion\n- \"description\": this label is a description used in the search"
|
||||
"description": "A list of labels. These are strings that are used for various purposes, e.g. to only include a subset of the tagRenderings when reusing a layer\nSpecial values:\n- \"hidden\": do not show this tagRendering. Useful in it is used by e.g. an accordion\n- \"description\": this label is a description used in the search"
|
||||
},
|
||||
{
|
||||
"path": [
|
||||
"tagRenderings",
|
||||
"renderings",
|
||||
"override",
|
||||
"onSoftDelete"
|
||||
],
|
||||
"required": false,
|
||||
"hints": {
|
||||
"question": "What tags should be applied when the object is soft-deleted?"
|
||||
},
|
||||
"type": "array",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"path": [
|
||||
|
@ -18050,9 +18133,24 @@
|
|||
"labels"
|
||||
],
|
||||
"required": false,
|
||||
"hints": {},
|
||||
"hints": {
|
||||
"question": "What labels should be applied on this tagRendering?"
|
||||
},
|
||||
"type": "array",
|
||||
"description": "What labels should be applied on this tagRendering?\nA list of labels. These are strings that are used for various purposes, e.g. to only include a subset of the tagRenderings when reusing a layer\nSpecial values:\n- \"hidden\": do not show this tagRendering. Useful in it is used by e.g. an accordion\n- \"description\": this label is a description used in the search"
|
||||
"description": "A list of labels. These are strings that are used for various purposes, e.g. to only include a subset of the tagRenderings when reusing a layer\nSpecial values:\n- \"hidden\": do not show this tagRendering. Useful in it is used by e.g. an accordion\n- \"description\": this label is a description used in the search"
|
||||
},
|
||||
{
|
||||
"path": [
|
||||
"tagRenderings",
|
||||
"renderings",
|
||||
"onSoftDelete"
|
||||
],
|
||||
"required": false,
|
||||
"hints": {
|
||||
"question": "What tags should be applied when the object is soft-deleted?"
|
||||
},
|
||||
"type": "array",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"path": [
|
||||
|
@ -19437,9 +19535,25 @@
|
|||
"labels"
|
||||
],
|
||||
"required": false,
|
||||
"hints": {},
|
||||
"hints": {
|
||||
"question": "What labels should be applied on this tagRendering?"
|
||||
},
|
||||
"type": "array",
|
||||
"description": "What labels should be applied on this tagRendering?\nA list of labels. These are strings that are used for various purposes, e.g. to only include a subset of the tagRenderings when reusing a layer\nSpecial values:\n- \"hidden\": do not show this tagRendering. Useful in it is used by e.g. an accordion\n- \"description\": this label is a description used in the search"
|
||||
"description": "A list of labels. These are strings that are used for various purposes, e.g. to only include a subset of the tagRenderings when reusing a layer\nSpecial values:\n- \"hidden\": do not show this tagRendering. Useful in it is used by e.g. an accordion\n- \"description\": this label is a description used in the search"
|
||||
},
|
||||
{
|
||||
"path": [
|
||||
"tagRenderings",
|
||||
"renderings",
|
||||
"override",
|
||||
"onSoftDelete"
|
||||
],
|
||||
"required": false,
|
||||
"hints": {
|
||||
"question": "What tags should be applied when the object is soft-deleted?"
|
||||
},
|
||||
"type": "array",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"path": [
|
||||
|
|
|
@ -767,6 +767,10 @@
|
|||
"if": "value=cycleways_and_roads",
|
||||
"then": "<b>cycleways_and_roads</b> (builtin) - All infrastructure that someone can cycle over, accompanied with questions about this infrastructure"
|
||||
},
|
||||
{
|
||||
"if": "value=cyclist_waiting_aid",
|
||||
"then": "<b>cyclist_waiting_aid</b> (builtin) - Various pieces of infrastructure that aid cyclists while they wait at a traffic light."
|
||||
},
|
||||
{
|
||||
"if": "value=defibrillator",
|
||||
"then": "<b>defibrillator</b> (builtin) - A layer showing defibrillators which can be used in case of emergency. This contains public defibrillators, but also defibrillators which might need staff to fetch the actual device"
|
||||
|
@ -1171,6 +1175,14 @@
|
|||
"if": "value=surveillance_camera",
|
||||
"then": "<b>surveillance_camera</b> (builtin) - This layer shows surveillance cameras and allows a contributor to update information and add new cameras"
|
||||
},
|
||||
{
|
||||
"if": "value=tactile_map",
|
||||
"then": "<b>tactile_map</b> (builtin) - Layer showing tactile maps, which can be used by visually impaired people to navigate the city."
|
||||
},
|
||||
{
|
||||
"if": "value=tactile_model",
|
||||
"then": "<b>tactile_model</b> (builtin) - Layer showing tactile models, three-dimensional models of the surrounding area."
|
||||
},
|
||||
{
|
||||
"if": "value=tertiary_education",
|
||||
"then": "<b>tertiary_education</b> (builtin) - Layer with all tertiary education institutes (ISCED:2011 levels 6,7 and 8)"
|
||||
|
@ -13463,6 +13475,10 @@
|
|||
"if": "value=cycleways_and_roads",
|
||||
"then": "cycleways_and_roads - All infrastructure that someone can cycle over, accompanied with questions about this infrastructure"
|
||||
},
|
||||
{
|
||||
"if": "value=cyclist_waiting_aid",
|
||||
"then": "cyclist_waiting_aid - Various pieces of infrastructure that aid cyclists while they wait at a traffic light."
|
||||
},
|
||||
{
|
||||
"if": "value=defibrillator",
|
||||
"then": "defibrillator - A layer showing defibrillators which can be used in case of emergency. This contains public defibrillators, but also defibrillators which might need staff to fetch the actual device"
|
||||
|
@ -13867,6 +13883,14 @@
|
|||
"if": "value=surveillance_camera",
|
||||
"then": "surveillance_camera - This layer shows surveillance cameras and allows a contributor to update information and add new cameras"
|
||||
},
|
||||
{
|
||||
"if": "value=tactile_map",
|
||||
"then": "tactile_map - Layer showing tactile maps, which can be used by visually impaired people to navigate the city."
|
||||
},
|
||||
{
|
||||
"if": "value=tactile_model",
|
||||
"then": "tactile_model - Layer showing tactile models, three-dimensional models of the surrounding area."
|
||||
},
|
||||
{
|
||||
"if": "value=tertiary_education",
|
||||
"then": "tertiary_education - Layer with all tertiary education institutes (ISCED:2011 levels 6,7 and 8)"
|
||||
|
@ -35195,6 +35219,10 @@
|
|||
"if": "value=cycleways_and_roads",
|
||||
"then": "cycleways_and_roads - All infrastructure that someone can cycle over, accompanied with questions about this infrastructure"
|
||||
},
|
||||
{
|
||||
"if": "value=cyclist_waiting_aid",
|
||||
"then": "cyclist_waiting_aid - Various pieces of infrastructure that aid cyclists while they wait at a traffic light."
|
||||
},
|
||||
{
|
||||
"if": "value=defibrillator",
|
||||
"then": "defibrillator - A layer showing defibrillators which can be used in case of emergency. This contains public defibrillators, but also defibrillators which might need staff to fetch the actual device"
|
||||
|
@ -35599,6 +35627,14 @@
|
|||
"if": "value=surveillance_camera",
|
||||
"then": "surveillance_camera - This layer shows surveillance cameras and allows a contributor to update information and add new cameras"
|
||||
},
|
||||
{
|
||||
"if": "value=tactile_map",
|
||||
"then": "tactile_map - Layer showing tactile maps, which can be used by visually impaired people to navigate the city."
|
||||
},
|
||||
{
|
||||
"if": "value=tactile_model",
|
||||
"then": "tactile_model - Layer showing tactile models, three-dimensional models of the surrounding area."
|
||||
},
|
||||
{
|
||||
"if": "value=tertiary_education",
|
||||
"then": "tertiary_education - Layer with all tertiary education institutes (ISCED:2011 levels 6,7 and 8)"
|
||||
|
|
|
@ -921,9 +921,22 @@
|
|||
"labels"
|
||||
],
|
||||
"required": false,
|
||||
"hints": {},
|
||||
"hints": {
|
||||
"question": "What labels should be applied on this tagRendering?"
|
||||
},
|
||||
"type": "array",
|
||||
"description": "What labels should be applied on this tagRendering?\nA list of labels. These are strings that are used for various purposes, e.g. to only include a subset of the tagRenderings when reusing a layer\nSpecial values:\n- \"hidden\": do not show this tagRendering. Useful in it is used by e.g. an accordion\n- \"description\": this label is a description used in the search"
|
||||
"description": "A list of labels. These are strings that are used for various purposes, e.g. to only include a subset of the tagRenderings when reusing a layer\nSpecial values:\n- \"hidden\": do not show this tagRendering. Useful in it is used by e.g. an accordion\n- \"description\": this label is a description used in the search"
|
||||
},
|
||||
{
|
||||
"path": [
|
||||
"onSoftDelete"
|
||||
],
|
||||
"required": false,
|
||||
"hints": {
|
||||
"question": "What tags should be applied when the object is soft-deleted?"
|
||||
},
|
||||
"type": "array",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"path": [
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"contributors": [
|
||||
{
|
||||
"commits": 487,
|
||||
"commits": 488,
|
||||
"contributor": "Pieter Vander Vennet"
|
||||
},
|
||||
{
|
||||
"commits": 420,
|
||||
"commits": 421,
|
||||
"contributor": "kjon"
|
||||
},
|
||||
{
|
||||
|
@ -13,11 +13,11 @@
|
|||
"contributor": "paunofu"
|
||||
},
|
||||
{
|
||||
"commits": 122,
|
||||
"commits": 127,
|
||||
"contributor": "Anonymous"
|
||||
},
|
||||
{
|
||||
"commits": 102,
|
||||
"commits": 106,
|
||||
"contributor": "mcliquid"
|
||||
},
|
||||
{
|
||||
|
@ -30,34 +30,34 @@
|
|||
},
|
||||
{
|
||||
"commits": 70,
|
||||
"contributor": "danieldegroot2"
|
||||
"contributor": "mike140"
|
||||
},
|
||||
{
|
||||
"commits": 56,
|
||||
"contributor": "mike140"
|
||||
"commits": 70,
|
||||
"contributor": "danieldegroot2"
|
||||
},
|
||||
{
|
||||
"commits": 53,
|
||||
"contributor": "Harry Bond"
|
||||
},
|
||||
{
|
||||
"commits": 50,
|
||||
"commits": 52,
|
||||
"contributor": "Jiří Podhorecký"
|
||||
},
|
||||
{
|
||||
"commits": 45,
|
||||
"commits": 51,
|
||||
"contributor": "gallegonovato"
|
||||
},
|
||||
{
|
||||
"commits": 44,
|
||||
"contributor": "Babos Gábor"
|
||||
},
|
||||
{
|
||||
"commits": 44,
|
||||
"contributor": "Supaplex"
|
||||
},
|
||||
{
|
||||
"commits": 43,
|
||||
"contributor": "Babos Gábor"
|
||||
},
|
||||
{
|
||||
"commits": 37,
|
||||
"commits": 38,
|
||||
"contributor": "Lucas"
|
||||
},
|
||||
{
|
||||
|
@ -100,6 +100,10 @@
|
|||
"commits": 16,
|
||||
"contributor": "macpac"
|
||||
},
|
||||
{
|
||||
"commits": 15,
|
||||
"contributor": "Ettore Atalan"
|
||||
},
|
||||
{
|
||||
"commits": 15,
|
||||
"contributor": "WaldiS"
|
||||
|
@ -112,10 +116,6 @@
|
|||
"commits": 14,
|
||||
"contributor": "J. Lavoie"
|
||||
},
|
||||
{
|
||||
"commits": 13,
|
||||
"contributor": "Ettore Atalan"
|
||||
},
|
||||
{
|
||||
"commits": 13,
|
||||
"contributor": "Olivier"
|
||||
|
@ -148,6 +148,10 @@
|
|||
"commits": 11,
|
||||
"contributor": "Túllio Franca"
|
||||
},
|
||||
{
|
||||
"commits": 10,
|
||||
"contributor": "small"
|
||||
},
|
||||
{
|
||||
"commits": 10,
|
||||
"contributor": "Jeff Huang"
|
||||
|
@ -272,6 +276,14 @@
|
|||
"commits": 6,
|
||||
"contributor": "lvgx"
|
||||
},
|
||||
{
|
||||
"commits": 5,
|
||||
"contributor": "foxandpotatoes"
|
||||
},
|
||||
{
|
||||
"commits": 5,
|
||||
"contributor": "Ignacio"
|
||||
},
|
||||
{
|
||||
"commits": 5,
|
||||
"contributor": "Thibault Molleman"
|
||||
|
@ -316,6 +328,10 @@
|
|||
"commits": 5,
|
||||
"contributor": "Alexey Shabanov"
|
||||
},
|
||||
{
|
||||
"commits": 4,
|
||||
"contributor": "Weblate Admin"
|
||||
},
|
||||
{
|
||||
"commits": 4,
|
||||
"contributor": "André Marcelo Alvarenga"
|
||||
|
@ -340,6 +356,10 @@
|
|||
"commits": 4,
|
||||
"contributor": "Jan Zabel"
|
||||
},
|
||||
{
|
||||
"commits": 3,
|
||||
"contributor": "Eric Armijo"
|
||||
},
|
||||
{
|
||||
"commits": 3,
|
||||
"contributor": "Andrii Holovin"
|
||||
|
@ -384,10 +404,6 @@
|
|||
"commits": 3,
|
||||
"contributor": "liimee"
|
||||
},
|
||||
{
|
||||
"commits": 3,
|
||||
"contributor": "foxandpotatoes"
|
||||
},
|
||||
{
|
||||
"commits": 3,
|
||||
"contributor": "Sasha"
|
||||
|
@ -424,6 +440,10 @@
|
|||
"commits": 3,
|
||||
"contributor": "SiegbjornSitumeang"
|
||||
},
|
||||
{
|
||||
"commits": 2,
|
||||
"contributor": "SmallSoap"
|
||||
},
|
||||
{
|
||||
"commits": 2,
|
||||
"contributor": "Kim Minwoo"
|
||||
|
@ -516,10 +536,6 @@
|
|||
"commits": 2,
|
||||
"contributor": "Localizer"
|
||||
},
|
||||
{
|
||||
"commits": 2,
|
||||
"contributor": "Eric Armijo"
|
||||
},
|
||||
{
|
||||
"commits": 2,
|
||||
"contributor": "MeblIkea"
|
||||
|
@ -558,7 +574,19 @@
|
|||
},
|
||||
{
|
||||
"commits": 1,
|
||||
"contributor": "Ignacio"
|
||||
"contributor": "Héctor Ochoa Ortiz"
|
||||
},
|
||||
{
|
||||
"commits": 1,
|
||||
"contributor": "Gábor"
|
||||
},
|
||||
{
|
||||
"commits": 1,
|
||||
"contributor": "Roger"
|
||||
},
|
||||
{
|
||||
"commits": 1,
|
||||
"contributor": "M1chaelWang"
|
||||
},
|
||||
{
|
||||
"commits": 1,
|
||||
|
|
|
@ -68,7 +68,8 @@
|
|||
|
||||
@font-face {
|
||||
font-family: "Source Sans Pro";
|
||||
src: url("/assets/source-sans-pro.regular.ttf") format("woff");
|
||||
/*This path might seem incorrect. However, 'index.css' will be compiled and placed in 'public/css', from where this path _is_ correct*/
|
||||
src: url("../assets/fonts/source-sans-pro.regular.ttf") format("woff");
|
||||
}
|
||||
|
||||
/***********************************************************************\
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue