Merge latest develop

This commit is contained in:
Pieter Vander Vennet 2024-12-10 01:42:00 +01:00
commit 17450deb82
386 changed files with 12073 additions and 25528 deletions

View file

@ -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
}

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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
}
}

View file

@ -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))
}

View file

@ -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), {

View file

@ -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)) {

View file

@ -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
}
}

View file

@ -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")

View file

@ -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",

View file

@ -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
}
/**

View file

@ -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'

View file

@ -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 {

View file

@ -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, [])
}
}

View file

@ -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

View file

@ -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}`
}

View file

@ -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] === "") {

View file

@ -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

View file

@ -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"
}

View file

@ -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)

View file

@ -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/"

View file

@ -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"]
/**

View file

@ -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
}

View file

@ -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: {

View file

@ -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>

View file

@ -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>{

View file

@ -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`
)
}

View file

@ -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

View file

@ -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
)[]

View file

@ -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[]
}

View file

@ -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

View file

@ -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
>

View file

@ -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`
}
}

View file

@ -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

View file

@ -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_")) {

View file

@ -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;
}

View file

@ -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
$: {

View file

@ -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>

View file

@ -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 +

View file

@ -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"
}

View file

@ -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"
/>

View file

@ -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)

View file

@ -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>

View file

@ -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}
&nbsp;({option.osmTags.asHumanString()})
&nbsp;({option.osmTags.asHumanString()})
{/if}
</option>
{/each}

View file

@ -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>

View file

@ -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

View file

@ -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[]][] = []

View file

@ -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++

View 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}

View 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}

View 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}

View 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}

View 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
}
}

View 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>

View file

@ -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} />

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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
}
}

View file

@ -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
View 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
View file

@ -0,0 +1,5 @@
import InspectorGUI from "./InspectorGUI.svelte"
new InspectorGUI({
target: document.getElementById("main"),
})

View file

@ -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) => {

View file

@ -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(), {

View file

@ -64,7 +64,7 @@
onDestroy(
globalFilter.addCallbackAndRun((globalFilter) => {
console.log("Global filters are", globalFilter)
_globalFilter = globalFilter ?? []
_globalFilter = globalFilter?.filter((gf) => gf.onNewPoint !== undefined) ?? []
})
)
$: {

View file

@ -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"])

View file

@ -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,

View 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}

View 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}

View file

@ -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>

View file

@ -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>

View file

@ -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} />

View file

@ -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'
*/

View file

@ -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

View file

@ -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}

View file

@ -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} />

View file

@ -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>

View file

@ -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'
)
}

View 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}

View 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
}
}

View 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,
})}
/>

View 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>

View file

@ -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")

View file

@ -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>

View file

@ -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}

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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",

View file

@ -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": [

View file

@ -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)"

View file

@ -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": [

View file

@ -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,

View file

@ -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");
}
/***********************************************************************\