Merge master

This commit is contained in:
Pieter Vander Vennet 2024-04-02 19:07:11 +02:00
commit 890816d2dd
424 changed files with 40595 additions and 3354 deletions

View file

@ -3,9 +3,10 @@ import Constants from "../../Models/Constants"
import { UIEventSource } from "../UIEventSource"
import { Utils } from "../../Utils"
import { Feature } from "geojson"
import { ImageUploadManager } from "../ImageProviders/ImageUploadManager"
export default class PendingChangesUploader {
constructor(changes: Changes, selectedFeature: UIEventSource<Feature>) {
constructor(changes: Changes, selectedFeature: UIEventSource<Feature>, uploader : ImageUploadManager) {
changes.pendingChanges
.stabilized(Constants.updateTimeoutSec * 1000)
.addCallback(() => changes.flushChanges("Flushing changes due to timeout"))
@ -48,7 +49,9 @@ export default class PendingChangesUploader {
}
function onunload(e) {
if (changes.pendingChanges.data.length == 0) {
const pendingChanges = changes.pendingChanges.data.length
const uploadingImages = uploader.isUploading.data
if (pendingChanges == 0 && !uploadingImages) {
return
}
changes.flushChanges("onbeforeunload - probably closing or something similar")

View file

@ -96,6 +96,9 @@ export default class FeaturePropertiesStore {
if (newId === undefined) {
// We removed the node/way/relation with type 'type' and id 'oldId' on openstreetmap!
const element = this._elements.get(oldId)
if(!element || element.data === undefined){
return
}
element.data._deleted = "yes"
element.ping()
return

View file

@ -40,7 +40,7 @@ export default class GenericImageProvider extends ImageProvider {
return undefined
}
public DownloadAttribution(url: string) {
public DownloadAttribution(_) {
return undefined
}
}

View file

@ -67,7 +67,7 @@ export default abstract class ImageProvider {
public abstract ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]>
public abstract DownloadAttribution(url: string): Promise<LicenseInfo>
public abstract DownloadAttribution(providedImage: ProvidedImage): Promise<LicenseInfo>
public abstract apiUrls(): string[]

View file

@ -24,6 +24,7 @@ export class ImageUploadManager {
private readonly _uploadRetriedSuccess: Map<string, UIEventSource<number>> = new Map()
private readonly _osmConnection: OsmConnection
private readonly _changes: Changes
public readonly isUploading: Store<boolean>
constructor(
layout: LayoutConfig,
@ -37,6 +38,13 @@ export class ImageUploadManager {
this._layout = layout
this._osmConnection = osmConnection
this._changes = changes
const failed = this.getCounterFor(this._uploadFailed, "*")
const done = this.getCounterFor(this._uploadFinished, "*")
this.isUploading = this.getCounterFor(this._uploadStarted, "*").map(startedCount => {
return startedCount > failed.data + done.data
}, [failed, done])
}
/**
@ -101,7 +109,6 @@ export class ImageUploadManager {
"osmid:" + tags.id,
].join("\n")
console.log("Upload done, creating ")
const action = await this.uploadImageWithLicense(
featureId,
title,
@ -110,6 +117,9 @@ export class ImageUploadManager {
targetKey,
tags?.data?.["_orig_theme"]
)
if (!action) {
return
}
if (!isNaN(Number(featureId))) {
// This is a map note
const url = action._url
@ -145,6 +155,7 @@ export class ImageUploadManager {
} catch (e) {
console.error("Could again not upload image due to", e)
this.increaseCountFor(this._uploadFailed, featureId)
return undefined
}
}
console.log("Uploading done, creating action for", featureId)

View file

@ -78,16 +78,23 @@ export class Imgur extends ImageProvider implements ImageUploader {
*
* const data = {"data":{"id":"I9t6B7B","title":"Station Knokke","description":"author:Pieter Vander Vennet\r\nlicense:CC-BY 4.0\r\nosmid:node\/9812712386","datetime":1655052078,"type":"image\/jpeg","animated":false,"width":2400,"height":1795,"size":910872,"views":2,"bandwidth":1821744,"vote":null,"favorite":false,"nsfw":false,"section":null,"account_url":null,"account_id":null,"is_ad":false,"in_most_viral":false,"has_sound":false,"tags":[],"ad_type":0,"ad_url":"","edited":"0","in_gallery":false,"link":"https:\/\/i.imgur.com\/I9t6B7B.jpg","ad_config":{"safeFlags":["not_in_gallery","share"],"highRiskFlags":[],"unsafeFlags":["sixth_mod_unsafe"],"wallUnsafeFlags":[],"showsAds":false,"showAdLevel":1}},"success":true,"status":200}
* Utils.injectJsonDownloadForTests("https://api.imgur.com/3/image/E0RuAK3", data)
* const licenseInfo = await Imgur.singleton.DownloadAttribution("https://i.imgur.com/E0RuAK3.jpg")
* const licenseInfo = await Imgur.singleton.DownloadAttribution({url: "https://i.imgur.com/E0RuAK3.jpg"})
* const expected = new LicenseInfo()
* expected.licenseShortName = "CC-BY 4.0"
* expected.artist = "Pieter Vander Vennet"
* expected.date = new Date(1655052078000)
* expected.views = 2
* licenseInfo // => expected
* const licenseInfoJpeg = await Imgur.singleton.DownloadAttribution({url:"https://i.imgur.com/E0RuAK3.jpeg"})
* licenseInfoJpeg // => expected
* const licenseInfoUpperCase = await Imgur.singleton.DownloadAttribution({url: "https://i.imgur.com/E0RuAK3.JPEG"})
* licenseInfoUpperCase // => expected
*
*
*/
public async DownloadAttribution(url: string): Promise<LicenseInfo> {
const hash = url.substr("https://i.imgur.com/".length).split(".jpg")[0]
public async DownloadAttribution(providedImage: {url: string}): Promise<LicenseInfo> {
const url = providedImage.url
const hash = url.substr("https://i.imgur.com/".length).split(/\.jpe?g/i)[0]
const apiUrl = "https://api.imgur.com/3/image/" + hash
const response = await Utils.downloadJsonCached(apiUrl, 365 * 24 * 60 * 60, {

View file

@ -133,12 +133,21 @@ export class Mapillary extends ImageProvider {
return [this.PrepareUrlAsync(key, value)]
}
public async DownloadAttribution(_: string): Promise<LicenseInfo> {
public async DownloadAttribution(providedImage: ProvidedImage): Promise<LicenseInfo> {
const mapillaryId = providedImage.id
const metadataUrl =
"https://graph.mapillary.com/" +
mapillaryId +
"?fields=thumb_1024_url,thumb_original_url,captured_at,creator&access_token=" +
Constants.mapillary_client_token_v4
const response = await Utils.downloadJsonCached(metadataUrl, 60 * 60)
const license = new LicenseInfo()
license.artist = undefined
license.artist = response["creator"]["username"]
license.license = "CC BY-SA 4.0"
// license.license = "Creative Commons Attribution-ShareAlike 4.0 International License";
license.attributionRequired = true
license.date = new Date(response["captured_at"])
return license
}
@ -151,16 +160,19 @@ export class Mapillary extends ImageProvider {
const metadataUrl =
"https://graph.mapillary.com/" +
mapillaryId +
"?fields=thumb_1024_url,thumb_original_url&access_token=" +
"?fields=thumb_1024_url,thumb_original_url,captured_at,creator&access_token=" +
Constants.mapillary_client_token_v4
const response = await Utils.downloadJsonCached(metadataUrl, 60 * 60)
const url = <string>response["thumb_1024_url"]
const url_hd = <string>response["thumb_original_url"]
return {
const date = new Date()
date.setTime(response["captured_at"])
return <ProvidedImage> {
id: "" + mapillaryId,
url,
url_hd,
provider: this,
date,
key,
}
}

View file

@ -53,7 +53,7 @@ export class WikidataImageProvider extends ImageProvider {
return allImages
}
public DownloadAttribution(_: string): Promise<any> {
public DownloadAttribution(_): Promise<any> {
throw new Error("Method not implemented; shouldn't be needed!")
}
}

View file

@ -13,7 +13,7 @@ export class WikimediaImageProvider extends ImageProvider {
public static readonly singleton = new WikimediaImageProvider()
public static readonly apiUrls = [
"https://commons.wikimedia.org/wiki/",
"https://upload.wikimedia.org",
"https://upload.wikimedia.org"
]
public static readonly commonsPrefixes = [...WikimediaImageProvider.apiUrls, "File:"]
private readonly commons_key = "wikimedia_commons"
@ -31,13 +31,18 @@ export class WikimediaImageProvider extends ImageProvider {
return path.substring(path.lastIndexOf("/") + 1)
}
private static PrepareUrl(value: string): string {
private static PrepareUrl(value: string, useHd = false): string {
if (value.toLowerCase().startsWith("https://commons.wikimedia.org/wiki/")) {
return value
}
return `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(
const baseUrl = `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(
value
)}?width=500&height=400`
)}`
if (useHd) {
return baseUrl
}
return baseUrl + `?width=500&height=400`
}
private static startsWithCommonsPrefix(value: string): boolean {
@ -109,8 +114,8 @@ export class WikimediaImageProvider extends ImageProvider {
return [Promise.resolve(this.UrlForImage("File:" + value))]
}
public async DownloadAttribution(filename: string): Promise<LicenseInfo> {
filename = WikimediaImageProvider.ExtractFileName(filename)
public async DownloadAttribution(img: ProvidedImage): Promise<LicenseInfo> {
const filename = WikimediaImageProvider.ExtractFileName(img.url)
if (filename === "") {
return undefined
@ -166,9 +171,10 @@ export class WikimediaImageProvider extends ImageProvider {
}
return {
url: WikimediaImageProvider.PrepareUrl(image),
url_hd: WikimediaImageProvider.PrepareUrl(image, true),
key: undefined,
provider: this,
id: image,
id: image
}
}
}

View file

@ -5,6 +5,20 @@ export default class ChangeLocationAction extends OsmChangeAction {
private readonly _id: number
private readonly _newLonLat: [number, number]
private readonly _meta: { theme: string; reason: string }
static metatags: {
readonly key?: string
readonly value?: string
readonly docs: string
readonly changeType: string[]
readonly specialMotivation?: boolean
}[] = [
{
value: "relocated|improve_accuraccy|...",
docs: "Will appear if the ",
changeType: ["move"],
specialMotivation: true,
},
]
constructor(
id: string,

View file

@ -4,6 +4,27 @@ import { TagsFilter } from "../../Tags/TagsFilter"
import { OsmTags } from "../../../Models/OsmFeature"
export default class ChangeTagAction extends OsmChangeAction {
static metatags: {
readonly key?: string
readonly value?: string
readonly docs: string
readonly changeType: string[]
readonly specialMotivation?: boolean
}[] = [
{
changeType: ["answer"],
docs: "Indicates the number of questions that have been answered",
},
{ changeType: ["soft-delete"], docs: "Indicates the number of soft-deleted items" },
{
changeType: ["add-image"],
docs: "Indicates the number of images that have been added in this changeset",
},
{
changeType: ["link-image"],
docs: "Indicates the number of images that have been linked in this changeset",
},
]
private readonly _elementId: string
/**
* The tags to apply onto the object

View file

@ -13,6 +13,12 @@ import { ChangesetHandler, ChangesetTag } from "./ChangesetHandler"
import { OsmConnection } from "./OsmConnection"
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
import OsmObjectDownloader from "./OsmObjectDownloader"
import Combine from "../../UI/Base/Combine"
import BaseUIElement from "../../UI/BaseUIElement"
import Title from "../../UI/Base/Title"
import Table from "../../UI/Base/Table"
import ChangeLocationAction from "./Actions/ChangeLocationAction"
import ChangeTagAction from "./Actions/ChangeTagAction"
/**
* Handles all changes made to OSM.
@ -99,6 +105,97 @@ export class Changes {
return changes
}
public static getDocs(): BaseUIElement {
function addSource(items: any[], src: string) {
items.forEach((i) => {
i["source"] = src
})
return items
}
const metatagsDocs: {
key?: string
value?: string
docs: string
changeType?: string[]
specialMotivation?: boolean
source?: string
}[] = [
...addSource(
[
{
key: "comment",
docs: "The changeset comment. Will be a fixed string, mentioning the theme",
},
{
key: "theme",
docs: "The name of the theme that was used to create this change. ",
},
{
key: "source",
value: "survey",
docs: "The contributor had their geolocation enabled while making changes",
},
{
key: "change_within_{distance}",
docs: "If the contributor enabled their geolocation, this will hint how far away they were from the objects they edited. This gives an indication of proximity and if they truly surveyed or were armchair-mapping",
},
{
key: "change_over_{distance}",
docs: "If the contributor enabled their geolocation, this will hint how far away they were from the objects they edited. If they were over 5000m away, the might have been armchair-mapping",
},
{
key: "created_by",
value: "MapComplete <version>",
docs: "The piece of software used to create this changeset; will always start with MapComplete, followed by the version number",
},
{
key: "locale",
value: "en|nl|de|...",
docs: "The code of the language that the contributor used MapComplete in. Hints what language the user speaks.",
},
{
key: "host",
value: "https://mapcomplete.org/<theme>",
docs: "The URL that the contributor used to make changes. One can see the used instance with this",
},
{
key: "imagery",
docs: "The identifier of the used background layer, this will probably be an identifier from the [editor layer index](https://github.com/osmlab/editor-layer-index)",
},
],
"default"
),
...addSource(ChangeTagAction.metatags, "ChangeTag"),
...addSource(ChangeLocationAction.metatags, "ChangeLocation"),
// TODO
/*
...DeleteAction.metatags,
...LinkImageAction.metatags,
...OsmChangeAction.metatags,
...RelationSplitHandler.metatags,
...ReplaceGeometryAction.metatags,
...SplitAction.metatags,*/
]
return new Combine([
new Title("Metatags on a changeset", 1),
"You might encounter the following metatags on a changeset:",
new Table(
["key", "value", "explanation", "source"],
metatagsDocs.map(({ key, value, docs, source, changeType, specialMotivation }) => [
key ?? changeType?.join(", ") ?? "",
value,
new Combine([
docs,
specialMotivation
? "This might give a reason per modified node or way"
: "",
]),
source,
])
),
])
}
private static GetNeededIds(changes: ChangeDescription[]) {
return Utils.Dedup(changes.filter((c) => c.id >= 0).map((c) => c.type + "/" + c.id))
}

View file

@ -6,6 +6,14 @@ import Constants from "../../Models/Constants"
import { Changes } from "./Changes"
import { Utils } from "../../Utils"
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
import ChangeLocationAction from "./Actions/ChangeLocationAction"
import ChangeTagAction from "./Actions/ChangeTagAction"
import DeleteAction from "./Actions/DeleteAction"
import LinkImageAction from "./Actions/LinkImageAction"
import OsmChangeAction from "./Actions/OsmChangeAction"
import RelationSplitHandler from "./Actions/RelationSplitHandler"
import ReplaceGeometryAction from "./Actions/ReplaceGeometryAction"
import SplitAction from "./Actions/SplitAction"
export interface ChangesetTag {
key: string

View file

@ -52,6 +52,7 @@ export class OsmConnection {
private readonly _iframeMode: Boolean | boolean
private readonly _singlePage: boolean
private isChecking = false
private readonly _doCheckRegularly
constructor(options?: {
dryRun?: Store<boolean>
@ -59,12 +60,17 @@ export class OsmConnection {
oauth_token?: UIEventSource<string>
// Used to keep multiple changesets open and to write to the correct changeset
singlePage?: boolean
attemptLogin?: true | boolean
attemptLogin?: true | boolean,
/**
* If true: automatically check if we're still online every 5 minutes + fetch messages
*/
checkOnlineRegularly?: true | boolean
}) {
options ??= {}
this.fakeUser = options?.fakeUser ?? false
this._singlePage = options?.singlePage ?? true
this._oauth_config = Constants.osmAuthConfig
this._doCheckRegularly = options?.checkOnlineRegularly ?? true
console.debug("Using backend", this._oauth_config.url)
this._iframeMode = Utils.runningFromConsole ? false : window !== window.top
@ -91,9 +97,11 @@ export class OsmConnection {
ud.name = "Fake user"
ud.totalMessages = 42
ud.languages = ["en"]
this.loadingStatus.setData("logged-in")
}
const self = this
this.UpdateCapabilities()
this.isLoggedIn = this.userDetails.map(
(user) =>
user.loggedIn &&
@ -111,11 +119,11 @@ export class OsmConnection {
this._dryRun = options.dryRun ?? new UIEventSource<boolean>(false)
this.updateAuthObject()
if(!this.fakeUser){
self.CheckForMessagesContinuously()
}
this.preferencesHandler = new OsmPreferences(
this.auth,
<any /*This is needed to make the tests work*/>this
)
this.preferencesHandler = new OsmPreferences(this.auth, this, this.fakeUser)
if (options.oauth_token?.data !== undefined) {
console.log(options.oauth_token.data)
@ -187,23 +195,27 @@ export class OsmConnection {
const self = this
console.log("Trying to log in...")
this.updateAuthObject()
LocalStorageSource.Get("location_before_login").setData(
Utils.runningFromConsole ? undefined : window.location.href
)
this.auth.xhr(
{
method: "GET",
path: "/api/0.6/user/details",
path: "/api/0.6/user/details"
},
function (err, details: XMLDocument) {
function(err, details: XMLDocument) {
if (err != null) {
console.log(err)
console.log("Could not login due to:", err)
self.loadingStatus.setData("error")
if (err.status == 401) {
console.log("Clearing tokens...")
// Not authorized - our token probably got revoked
self.auth.logout()
self.LogOut()
} else {
console.log("Other error. Status:", err.status)
self.apiIsOnline.setData("unreachable")
}
return
}
@ -213,8 +225,6 @@ export class OsmConnection {
return
}
self.CheckForMessagesContinuously()
// details is an XML DOM of user details
let userInfo = details.getElementsByTagName("user")[0]
@ -303,12 +313,12 @@ export class OsmConnection {
<any>{
method,
options: {
header,
header
},
content,
path: `/api/0.6/${path}`,
path: `/api/0.6/${path}`
},
function (err, response) {
function(err, response) {
if (err !== null) {
error(err)
} else {
@ -388,7 +398,7 @@ export class OsmConnection {
"notes.json",
content,
{
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"
},
true
)
@ -400,6 +410,7 @@ export class OsmConnection {
}
public static GpxTrackVisibility = ["private", "public", "trackable", "identifiable"] as const
public async uploadGpxTrack(
gpx: string,
options: {
@ -428,7 +439,7 @@ export class OsmConnection {
file: gpx,
description: options.description,
tags: options.labels?.join(",") ?? "",
visibility: options.visibility,
visibility: options.visibility
}
if (!contents.description) {
@ -436,9 +447,9 @@ export class OsmConnection {
}
const extras = {
file:
'; filename="' +
"; filename=\"" +
(options.filename ?? "gpx_track_mapcomplete_" + new Date().toISOString()) +
'"\r\nContent-Type: application/gpx+xml',
"\"\r\nContent-Type: application/gpx+xml"
}
const boundary = "987654"
@ -446,7 +457,7 @@ export class OsmConnection {
let body = ""
for (const key in contents) {
body += "--" + boundary + "\r\n"
body += 'Content-Disposition: form-data; name="' + key + '"'
body += "Content-Disposition: form-data; name=\"" + key + "\""
if (extras[key] !== undefined) {
body += extras[key]
}
@ -457,7 +468,7 @@ export class OsmConnection {
const response = await this.post("gpx/create", body, {
"Content-Type": "multipart/form-data; boundary=" + boundary,
"Content-Length": body.length,
"Content-Length": body.length
})
const parsed = JSON.parse(response)
console.log("Uploaded GPX track", parsed)
@ -480,9 +491,9 @@ export class OsmConnection {
{
method: "POST",
path: `/api/0.6/notes/${id}/comment?text=${encodeURIComponent(text)}`,
path: `/api/0.6/notes/${id}/comment?text=${encodeURIComponent(text)}`
},
function (err, _) {
function(err, _) {
if (err !== null) {
error(err)
} else {
@ -497,7 +508,7 @@ export class OsmConnection {
* To be called by land.html
*/
public finishLogin(callback: (previousURL: string) => void) {
this.auth.authenticate(function () {
this.auth.authenticate(function() {
// Fully authed at this point
console.log("Authentication successful!")
const previousLocation = LocalStorageSource.Get("location_before_login")
@ -536,7 +547,7 @@ export class OsmConnection {
? "https://mapcomplete.org/land.html"
: window.location.protocol + "//" + window.location.host + "/land.html",
singlepage: !standalone,
auto: true,
auto: true
})
}
@ -545,26 +556,44 @@ export class OsmConnection {
if (this.isChecking) {
return
}
this.isChecking = true
Stores.Chronic(5 * 60 * 1000).addCallback((_) => {
if (self.isLoggedIn.data) {
Stores.Chronic(3 * 1000).addCallback((_) => {
if (!(self.apiIsOnline.data === "unreachable" || self.apiIsOnline.data === "offline")) {
return
}
try {
console.log("Api is offline - trying to reconnect...")
self.AttemptLogin()
} catch (e) {
console.log("Could not login due to", e)
}
})
this.isChecking = true
if (!this._doCheckRegularly) {
return
}
Stores.Chronic(60 * 5 * 1000).addCallback((_) => {
if (self.isLoggedIn.data) {
try {
self.AttemptLogin()
} catch (e) {
console.log("Could not login due to", e)
}
}
})
}
private UpdateCapabilities(): void {
const self = this
if (this.fakeUser) {
return
}
this.FetchCapabilities().then(({ api, gpx }) => {
self.apiIsOnline.setData(api)
self.gpxServiceIsOnline.setData(gpx)
this.apiIsOnline.setData(api)
this.gpxServiceIsOnline.setData(gpx)
})
}
private readonly _userInfoCache: Record<number, any> = {}
public async getInformationAboutUser(id: number): Promise<{
id: number
display_name: string
@ -587,6 +616,7 @@ export class OsmConnection {
this._userInfoCache[id] = parsed
return parsed
}
private async FetchCapabilities(): Promise<{ api: OsmServiceState; gpx: OsmServiceState }> {
if (Utils.runningFromConsole) {
return { api: "online", gpx: "online" }

View file

@ -2,6 +2,9 @@ import { UIEventSource } from "../UIEventSource"
import UserDetails, { OsmConnection } from "./OsmConnection"
import { Utils } from "../../Utils"
import { LocalStorageSource } from "../Web/LocalStorageSource"
// @ts-ignore
import { osmAuth } from "osm-auth"
import OSMAuthInstance = OSMAuth.OSMAuthInstance
export class OsmPreferences {
/**
@ -17,16 +20,17 @@ export class OsmPreferences {
* @private
*/
private readonly preferenceSources = new Map<string, UIEventSource<string>>()
private auth: any
private readonly auth: OSMAuthInstance
private userDetails: UIEventSource<UserDetails>
private longPreferences = {}
private readonly _fakeUser: boolean
constructor(auth, osmConnection: OsmConnection) {
constructor(auth: OSMAuthInstance, osmConnection: OsmConnection, fakeUser: boolean = false) {
this.auth = auth
this._fakeUser = fakeUser
this.userDetails = osmConnection.userDetails
const self = this
osmConnection.OnLoggedIn(() => {
self.UpdatePreferences(true)
this.UpdatePreferences(true)
return true
})
}
@ -212,8 +216,21 @@ export class OsmPreferences {
})
}
removeAllWithPrefix(prefix: string) {
for (const key in this.preferences.data) {
if (key.startsWith(prefix)) {
this.GetPreference(key, "", { prefix: "" }).setData(undefined)
console.log("Clearing preference", key)
}
}
this.preferences.ping()
}
private UpdatePreferences(forceUpdate?: boolean) {
const self = this
if (this._fakeUser) {
return
}
this.auth.xhr(
{
method: "GET",
@ -272,13 +289,15 @@ export class OsmPreferences {
}
const self = this
console.debug("Updating preference", k, " to ", Utils.EllipsesAfter(v, 15))
if (this._fakeUser) {
return
}
if (v === undefined || v === "") {
this.auth.xhr(
{
method: "DELETE",
path: "/api/0.6/user/preferences/" + encodeURIComponent(k),
options: { header: { "Content-Type": "text/plain" } },
headers: { "Content-Type": "text/plain" },
},
function (error) {
if (error) {
@ -297,7 +316,7 @@ export class OsmPreferences {
{
method: "PUT",
path: "/api/0.6/user/preferences/" + encodeURIComponent(k),
options: { header: { "Content-Type": "text/plain" } },
headers: { "Content-Type": "text/plain" },
content: v,
},
function (error) {
@ -311,14 +330,4 @@ export class OsmPreferences {
}
)
}
removeAllWithPrefix(prefix: string) {
for (const key in this.preferences.data) {
if (key.startsWith(prefix)) {
this.GetPreference(key, "", { prefix: "" }).setData(undefined)
console.log("Clearing preference", key)
}
}
this.preferences.ping()
}
}

View file

@ -78,7 +78,7 @@ export class And extends TagsFilter {
return { and: this.and.map((a) => a.asJson()) }
}
asHumanString(linkToWiki: boolean, shorten: boolean, properties: Record<string, string>) {
asHumanString(linkToWiki?: boolean, shorten?: boolean, properties?: Record<string, string>) {
return this.and
.map((t) => {
let e = t.asHumanString(linkToWiki, shorten, properties)
@ -159,7 +159,7 @@ export class And extends TagsFilter {
return [].concat(...this.and.map((subkeys) => subkeys.usedTags()))
}
asChange(properties: Record<string, string>): { k: string; v: string }[] {
asChange(properties: Readonly<Record<string, string>>): { k: string; v: string }[] {
const result = []
for (const tagsFilter of this.and) {
result.push(...tagsFilter.asChange(properties))

View file

@ -3,7 +3,7 @@ import { TagConfigJson } from "../../Models/ThemeConfig/Json/TagConfigJson"
import { Tag } from "./Tag"
import { ExpressionSpecification } from "maplibre-gl"
export default class ComparingTag implements TagsFilter {
export default class ComparingTag extends TagsFilter {
private readonly _key: string
private readonly _predicate: (value: string) => boolean
private readonly _representation: "<" | ">" | "<=" | ">="
@ -15,13 +15,14 @@ export default class ComparingTag implements TagsFilter {
representation: "<" | ">" | "<=" | ">=",
boundary: string
) {
super()
this._key = key
this._predicate = predicate
this._representation = representation
this._boundary = boundary
}
asChange(_: Record<string, string>): { k: string; v: string }[] {
asChange(_: Readonly<Record<string, string>>): { k: string; v: string }[] {
throw "A comparable tag can not be used to be uploaded to OSM"
}

View file

@ -96,7 +96,7 @@ export class Or extends TagsFilter {
return [].concat(...this.or.map((subkeys) => subkeys.usedTags()))
}
asChange(properties: Record<string, string>): { k: string; v: string }[] {
asChange(properties: Readonly<Record<string, string>>): { k: string; v: string }[] {
const result = []
for (const tagsFilter of this.or) {
result.push(...tagsFilter.asChange(properties))

View file

@ -13,12 +13,13 @@ import { ExpressionSpecification } from "maplibre-gl"
* The 'key' is always fixed and should not contain substitutions.
* This cannot be used to query features
*/
export default class SubstitutingTag implements TagsFilter {
export default class SubstitutingTag extends TagsFilter {
private readonly _key: string
private readonly _value: string
private readonly _invert: boolean
constructor(key: string, value: string, invert = false) {
super()
this._key = key
this._value = value
this._invert = invert
@ -42,7 +43,7 @@ export default class SubstitutingTag implements TagsFilter {
return new Tag(this._key, Utils.SubstituteKeys(this._value, currentProperties))
}
asHumanString(linkToWiki: boolean, shorten: boolean, properties) {
asHumanString(linkToWiki?: boolean, shorten?: boolean, properties?: Record<string, string>) {
return (
this._key +
(this._invert ? "!" : "") +
@ -99,7 +100,7 @@ export default class SubstitutingTag implements TagsFilter {
return []
}
asChange(properties: Record<string, string>): { k: string; v: string }[] {
asChange(properties: Readonly<Record<string, string>>): { k: string; v: string }[] {
if (this._invert) {
throw "An inverted substituting tag can not be used to create a change"
}

View file

@ -729,7 +729,7 @@ export class TagUtils {
}
if (typeof json != "string") {
if (json["and"] !== undefined && json["or"] !== undefined) {
throw `Error while parsing a TagConfig: got an object where both 'and' and 'or' are defined. Did you override a value? Perhaps use \`"=parent": { ... }\` instead of \"parent": {...}\` to trigger a replacement and not a fuse of values`
throw `Error while parsing a TagConfig: got an object where both 'and' and 'or' are defined. Did you override a value? Perhaps use \`"=parent": { ... }\` instead of \"parent": {...}\` to trigger a replacement and not a fuse of values. The value is ${JSON.stringify(json)}`
}
if (json["and"] !== undefined) {
return new And(json["and"].map((t) => TagUtils.Tag(t, context)))

View file

@ -15,9 +15,9 @@ export abstract class TagsFilter {
abstract matchesProperties(properties: Record<string, string>): boolean
abstract asHumanString(
linkToWiki: boolean,
shorten: boolean,
properties: Record<string, string>
linkToWiki?: boolean,
shorten?: boolean,
properties?: Record<string, string>
): string
abstract asJson(): TagConfigJson
@ -34,9 +34,18 @@ export abstract class TagsFilter {
* Converts the tagsFilter into a list of key-values that should be uploaded to OSM.
* Throws an error if not applicable.
*
* Note: properties are the already existing tags-object. It is only used in the substituting tag
* @param properties are the already existing tags-object. It is only used in the substituting tag and will not be changed
*/
abstract asChange(properties: Record<string, string>): { k: string; v: string }[]
abstract asChange(properties: Readonly<Record<string, string>>): { k: string; v: string }[]
public applyOn(properties: Readonly<Record<string, string>>): Record<string, string> {
const copy = { ...properties }
const changes = this.asChange(properties)
for (const { k, v } of changes) {
copy[k] = v
}
return copy
}
/**
* Returns an optimized version (or self) of this tagsFilter

View file

@ -3,34 +3,33 @@ import { MangroveReviews, Review } from "mangrove-reviews-typescript"
import { Utils } from "../../Utils"
import { Feature, Position } from "geojson"
import { GeoOperations } from "../GeoOperations"
import ScriptUtils from "../../../scripts/ScriptUtils"
export class MangroveIdentity {
private readonly keypair: Store<CryptoKeyPair>
private readonly keypair: UIEventSource<CryptoKeyPair> = new UIEventSource<CryptoKeyPair>(undefined)
/**
* Same as the one in the user settings
*/
public readonly mangroveIdentity: UIEventSource<string>
private readonly key_id: Store<string>
private readonly key_id: UIEventSource<string> = new UIEventSource<string>(undefined)
private readonly _mangroveIdentityCreationDate: UIEventSource<string>
constructor(mangroveIdentity: UIEventSource<string>, mangroveIdentityCreationDate: UIEventSource<string>) {
this.mangroveIdentity = mangroveIdentity
this._mangroveIdentityCreationDate = mangroveIdentityCreationDate
const key_id = new UIEventSource<string>(undefined)
this.key_id = key_id
const keypairEventSource = new UIEventSource<CryptoKeyPair>(undefined)
this.keypair = keypairEventSource
mangroveIdentity.addCallbackAndRunD(async (data) => {
if (!data) {
return
}
const keypair = await MangroveReviews.jwkToKeypair(JSON.parse(data))
keypairEventSource.setData(keypair)
const pem = await MangroveReviews.publicToPem(keypair.publicKey)
key_id.setData(pem)
await this.setKeypair(data)
})
}
private async setKeypair(data: string){
console.log("Setting keypair from",data)
const keypair = await MangroveReviews.jwkToKeypair(JSON.parse(data))
this.keypair.setData(keypair)
const pem = await MangroveReviews.publicToPem(keypair.publicKey)
this.key_id.setData(pem)
}
/**
* Creates an identity if none exists already.
* Is written into the UIEventsource, which was passed into the constructor
@ -43,7 +42,9 @@ export class MangroveIdentity {
// Identity has been loaded via osmPreferences by now - we don't overwrite
return
}
console.log("Creating a new Mangrove identity!")
this.keypair.setData(keypair)
const pem = await MangroveReviews.publicToPem(keypair.publicKey)
this.key_id.setData(pem)
this.mangroveIdentity.setData(JSON.stringify(jwk))
this._mangroveIdentityCreationDate.setData(new Date().toISOString())
}
@ -52,7 +53,7 @@ export class MangroveIdentity {
* Only called to create a review.
*/
async getKeypair(): Promise<CryptoKeyPair> {
if (this.keypair.data ?? "" === "") {
if (this.keypair.data === undefined) {
// We want to create a review, but it seems like no key has been setup at this moment
// We create the key
try {
@ -70,31 +71,51 @@ export class MangroveIdentity {
return this.key_id
}
private geoReviewsById: Store<(Review & { kid: string; signature: string })[]> =
undefined
public getGeoReviews(): Store<(Review & { kid: string, signature: string })[] | undefined> {
if (!this.geoReviewsById) {
const all = this.getAllReviews()
this.geoReviewsById = this.getAllReviews().mapD(reviews => reviews.filter(
review => {
try {
const subjectUrl = new URL(review.sub)
return subjectUrl.protocol === "geo:"
} catch (e) {
return false
}
}
))
}
return this.geoReviewsById
}
private allReviewsById: UIEventSource<(Review & { kid: string; signature: string })[]> =
undefined
/**
* Gets all reviews that are made for the current identity.
* The returned store will contain `undefined` if still loading
*/
public getAllReviews(): Store<(Review & { kid: string; signature: string })[]> {
public getAllReviews(): Store<(Review & { kid: string; signature: string })[] | undefined> {
if (this.allReviewsById !== undefined) {
return this.allReviewsById
}
this.allReviewsById = new UIEventSource([])
this.key_id.map((pem) => {
this.allReviewsById = new UIEventSource(undefined)
this.key_id.map(async (pem) => {
if (pem === undefined) {
return []
}
MangroveReviews.getReviews({
kid: pem,
}).then((allReviews) => {
this.allReviewsById.setData(
allReviews.reviews.map((r) => ({
...r,
...r.payload,
}))
)
const allReviews = await MangroveReviews.getReviews({
kid: pem
})
this.allReviewsById.setData(
allReviews.reviews.map((r) => ({
...r,
...r.payload
}))
)
})
return this.allReviewsById
}
@ -125,6 +146,7 @@ export default class FeatureReviews {
private readonly _uncertainty: number
private readonly _name: Store<string>
private readonly _identity: MangroveIdentity
private readonly _testmode: Store<boolean>
private constructor(
feature: Feature,
@ -134,11 +156,13 @@ export default class FeatureReviews {
nameKey?: "name" | string
fallbackName?: string
uncertaintyRadius?: number
}
},
testmode?: Store<boolean>
) {
const centerLonLat = GeoOperations.centerpointCoordinates(feature)
;[this._lon, this._lat] = centerLonLat
this._identity = mangroveIdentity
this._testmode = testmode ?? new ImmutableStore(false)
const nameKey = options?.nameKey ?? "name"
if (feature.geometry.type === "Point") {
@ -210,19 +234,20 @@ export default class FeatureReviews {
public static construct(
feature: Feature,
tagsSource: UIEventSource<Record<string, string>>,
mangroveIdentity?: MangroveIdentity,
options?: {
mangroveIdentity: MangroveIdentity,
options: {
nameKey?: "name" | string
fallbackName?: string
uncertaintyRadius?: number
}
) {
},
testmode: Store<boolean>
): FeatureReviews {
const key = feature.properties.id
const cached = FeatureReviews._featureReviewsCache[key]
if (cached !== undefined) {
return cached
}
const featureReviews = new FeatureReviews(feature, tagsSource, mangroveIdentity, options)
const featureReviews = new FeatureReviews(feature, tagsSource, mangroveIdentity, options,testmode )
FeatureReviews._featureReviewsCache[key] = featureReviews
return featureReviews
}
@ -243,17 +268,22 @@ export default class FeatureReviews {
}
const r: Review = {
sub: this.subjectUri.data,
...review,
...review
}
const keypair: CryptoKeyPair = await this._identity.getKeypair()
const jwt = await MangroveReviews.signReview(keypair, r)
const kid = await MangroveReviews.publicToPem(keypair.publicKey)
await MangroveReviews.submitReview(jwt)
if (!this._testmode.data) {
await MangroveReviews.submitReview(jwt)
} else {
console.log("Testmode enabled - not uploading review")
await Utils.waitFor(1000)
}
const reviewWithKid = {
...r,
kid,
signature: jwt,
madeByLoggedInUser: new ImmutableStore(true),
madeByLoggedInUser: new ImmutableStore(true)
}
this._reviews.data.push(reviewWithKid)
this._reviews.ping()
@ -301,7 +331,7 @@ export default class FeatureReviews {
signature: reviewData.signature,
madeByLoggedInUser: this._identity.getKeyId().map((user_key_id) => {
return reviewData.kid === user_key_id
}),
})
})
hasNew = true
}
@ -322,7 +352,7 @@ export default class FeatureReviews {
// https://www.rfc-editor.org/rfc/rfc5870#section-3.4.2
// `u` stands for `uncertainty`, https://www.rfc-editor.org/rfc/rfc5870#section-3.4.3
const self = this
return this._name.map(function (name) {
return this._name.map(function(name) {
let uri = `geo:${self._lat},${self._lon}?u=${Math.round(self._uncertainty)}`
if (name) {
uri += "&q=" + (dontEncodeName ? name : encodeURIComponent(name))

View file

@ -7,6 +7,10 @@ export type EliCategory =
| "qa"
| "elevation"
| "other"
/**
* This class has grown beyond the point of only containing Raster Layers
*/
export interface RasterLayerProperties {
/**
* The name of the imagery source
@ -19,7 +23,8 @@ export interface RasterLayerProperties {
readonly url: string
readonly category?: string | EliCategory
readonly type?: "vector" | string
readonly type?: "vector" | "raster" | string
readonly style?: string,
readonly attribution?: {
readonly url?: string

View file

@ -48,23 +48,12 @@ export class AvailableRasterLayers {
geometry: BBox.global.asGeometry(),
}
public static readonly maptilerDefaultLayer: RasterLayerPolygon = {
type: "Feature",
properties: {
name: "MapTiler",
url:
"https://api.maptiler.com/maps/15cc8f61-0353-4be6-b8da-13daea5f7432/style.json?key=" +
Constants.maptilerApiKey,
category: "osmbasedmap",
id: "maptiler",
type: "vector",
attribution: {
text: "Maptiler",
url: "https://www.maptiler.com/copyright/",
},
},
geometry: BBox.global.asGeometry(),
}
/**
* The default background layer that any theme uses which does not explicitly define a background
*/
public static readonly defaultBackgroundLayer: RasterLayerPolygon = AvailableRasterLayers.globalLayers.find(l => {
return l.properties.id === "protomaps.sunny"
})
public static layersAvailableAt(
location: Store<{ lon: number; lat: number }>,
@ -90,7 +79,7 @@ export class AvailableRasterLayers {
return GeoOperations.inside(lonlat, eliPolygon)
})
matching.unshift(AvailableRasterLayers.osmCarto)
matching.push(AvailableRasterLayers.maptilerDefaultLayer)
matching.push(AvailableRasterLayers.defaultBackgroundLayer)
if (enableBing?.data) {
matching.push(AvailableRasterLayers.bing)
}
@ -107,7 +96,7 @@ export class AvailableRasterLayers {
all.push(...AvailableRasterLayers.globalLayers.map((l) => l.properties.id))
all.push(...AvailableRasterLayers.EditorLayerIndex.map((l) => l.properties.id))
all.push(this.osmCarto.properties.id)
all.push(this.maptilerDefaultLayer.properties.id)
all.push(this.defaultBackgroundLayer.properties.id)
return new Set<string>(all)
}
}

View file

@ -277,9 +277,11 @@ export class ValidateTheme extends DesugaringStep<LayoutConfigJson> {
backgroundId === "photo" || backgroundId === "map" || backgroundId === "osmbasedmap"
if (!isCategory && !ValidateTheme._availableLayers.has(backgroundId)) {
const options = Array.from(ValidateTheme._availableLayers)
const nearby = Utils.sortedByLevenshteinDistance(backgroundId, options, t => t)
context
.enter("defaultBackgroundId")
.err("This layer ID is not known: " + backgroundId)
.err(`This layer ID is not known: ${backgroundId}. Perhaps you meant one of ${nearby.slice(0,5).join(", ")}`)
}
}
@ -850,7 +852,7 @@ class CheckTranslation extends DesugaringStep<Translatable> {
for (const key of keys) {
const lng = json[key]
if (lng === "") {
context.enter(lng).err("Got an empty string in translation for language " + lng)
context.enter(lng).err("Got an empty string in translation for language " + key)
}
// TODO validate that all subparts are here
@ -1012,6 +1014,13 @@ class MiscTagRenderingChecks extends DesugaringStep<TagRenderingConfigJson> {
) {
continue
}
if(json.freeform.key.indexOf("wikidata")>=0){
context
.enter("render")
.err(
`The rendering for language ${ln} does not contain \`{${json.freeform.key}}\`. Did you perhaps forget to set "freeform.type: 'wikidata'"?`
)
}
context
.enter("render")
.err(
@ -1264,7 +1273,7 @@ export class PrevalidateLayer extends DesugaringStep<LayerConfigJson> {
// It is tempting to add an index to this warning; however, due to labels the indices here might be different from the index in the tagRendering list
context
.enter("tagRenderings")
.err("Some tagrenderings have a duplicate id: " + duplicates.join(", "))
.err("Some tagrenderings have a duplicate id: " + duplicates.join(", ")+"\n"+JSON.stringify(json.tagRenderings.filter(tr=> duplicates.indexOf(tr["id"])>=0)))
}
}
@ -1837,6 +1846,7 @@ export class ValidateThemeEnsemble extends Conversion<
{
tags: TagsFilter
foundInTheme: string[]
isCounted: boolean
}
>
> {
@ -1855,10 +1865,11 @@ export class ValidateThemeEnsemble extends Conversion<
string,
{
tags: TagsFilter
foundInTheme: string[]
foundInTheme: string[],
isCounted: boolean
}
> {
const idToSource = new Map<string, { tags: TagsFilter; foundInTheme: string[] }>()
const idToSource = new Map<string, { tags: TagsFilter; foundInTheme: string[], isCounted: boolean }>()
for (const theme of json) {
for (const layer of theme.layers) {
@ -1879,7 +1890,7 @@ export class ValidateThemeEnsemble extends Conversion<
const id = layer.id
const tags = layer.source.osmTags
if (!idToSource.has(id)) {
idToSource.set(id, { tags, foundInTheme: [theme.id] })
idToSource.set(id, { tags, foundInTheme: [theme.id], isCounted: layer.doCount })
continue
}
@ -1888,6 +1899,7 @@ export class ValidateThemeEnsemble extends Conversion<
if (oldTags.shadows(tags) && tags.shadows(oldTags)) {
// All is good, all is well
oldTheme.push(theme.id)
idToSource.get(id).isCounted ||= layer.doCount
continue
}
context.err(

View file

@ -38,7 +38,7 @@ export default interface LineRenderingConfigJson {
/**
* question: Should a dasharray be used to render the lines?
* The dasharray defines 'pixels of line, pixels of gap, pixels of line, pixels of gap, ...'. For example, `5 6` will be 5 pixels of line followed by a 6 pixel gap.
* Cannot be a dynamic property due to a mapbox limitation
* Cannot be a dynamic property due to a MapLibre limitation (see https://github.com/maplibre/maplibre-gl-js/issues/1235)
* ifunset: Ways are rendered with a full line
*/
dashArray?: string

View file

@ -91,7 +91,7 @@ export default class LayerConfig extends WithContextLoader {
mercatorCrs: json.source["mercatorCrs"],
idKey: json.source["idKey"],
},
json.id
json.id,
)
}
@ -106,8 +106,8 @@ export default class LayerConfig extends WithContextLoader {
}
this.units = [].concat(
...(json.units ?? []).map((unitJson, i) =>
Unit.fromJson(unitJson, `${context}.unit[${i}]`)
)
Unit.fromJson(unitJson, `${context}.unit[${i}]`),
),
)
if (json.description !== undefined) {
@ -122,7 +122,7 @@ export default class LayerConfig extends WithContextLoader {
if (json.calculatedTags !== undefined) {
if (!official) {
console.warn(
`Unofficial theme ${this.id} with custom javascript! This is a security risk`
`Unofficial theme ${this.id} with custom javascript! This is a security risk`,
)
}
this.calculatedTags = []
@ -159,7 +159,7 @@ export default class LayerConfig extends WithContextLoader {
}
this.minzoomVisible = json.minzoomVisible ?? this.minzoom
this.shownByDefault = json.shownByDefault ?? true
this.doCount = json.isCounted ?? true
this.doCount = json.isCounted ?? this.shownByDefault ?? true
this.forceLoad = json.forceLoad ?? false
if (json.presets === null) json.presets = undefined
if (json.presets !== undefined && json.presets?.map === undefined) {
@ -191,7 +191,7 @@ export default class LayerConfig extends WithContextLoader {
tags: pr.tags.map((t) => TagUtils.SimpleTag(t)),
description: Translations.T(
pr.description,
`${translationContext}.presets.${i}.description`
`${translationContext}.presets.${i}.description`,
),
preciseInput: preciseInput,
exampleImages: pr.exampleImages,
@ -205,7 +205,7 @@ export default class LayerConfig extends WithContextLoader {
if (json.lineRendering) {
this.lineRendering = Utils.NoNull(json.lineRendering).map(
(r, i) => new LineRenderingConfig(r, `${context}[${i}]`)
(r, i) => new LineRenderingConfig(r, `${context}[${i}]`),
)
} else {
this.lineRendering = []
@ -213,7 +213,7 @@ export default class LayerConfig extends WithContextLoader {
if (json.pointRendering) {
this.mapRendering = Utils.NoNull(json.pointRendering).map(
(r, i) => new PointRenderingConfig(r, `${context}[${i}](${this.id})`)
(r, i) => new PointRenderingConfig(r, `${context}[${i}](${this.id})`),
)
} else {
this.mapRendering = []
@ -225,7 +225,7 @@ export default class LayerConfig extends WithContextLoader {
r.location.has("centroid") ||
r.location.has("projected_centerpoint") ||
r.location.has("start") ||
r.location.has("end")
r.location.has("end"),
)
if (
@ -247,7 +247,7 @@ export default class LayerConfig extends WithContextLoader {
Constants.priviliged_layers.indexOf(<any>this.id) < 0 &&
this.source !== null /*library layer*/ &&
!this.source?.geojsonSource?.startsWith(
"https://api.openstreetmap.org/api/0.6/notes.json"
"https://api.openstreetmap.org/api/0.6/notes.json",
)
) {
throw (
@ -266,7 +266,7 @@ export default class LayerConfig extends WithContextLoader {
typeof tr !== "string" &&
tr["builtin"] === undefined &&
tr["id"] === undefined &&
tr["rewrite"] === undefined
tr["rewrite"] === undefined,
) ?? []
if (missingIds?.length > 0 && official) {
console.error("Some tagRenderings of", this.id, "are missing an id:", missingIds)
@ -277,8 +277,8 @@ export default class LayerConfig extends WithContextLoader {
(tr, i) =>
new TagRenderingConfig(
<QuestionableTagRenderingConfigJson>tr,
this.id + ".tagRenderings[" + i + "]"
)
this.id + ".tagRenderings[" + i + "]",
),
)
if (
@ -352,7 +352,7 @@ export default class LayerConfig extends WithContextLoader {
public GetBaseTags(): Record<string, string> {
return TagUtils.changeAsProperties(
this.source?.osmTags?.asChange({ id: "node/-1" }) ?? [{ k: "id", v: "node/-1" }]
this.source?.osmTags?.asChange({ id: "node/-1" }) ?? [{ k: "id", v: "node/-1" }],
)
}
@ -365,7 +365,7 @@ export default class LayerConfig extends WithContextLoader {
neededLayer: string
}[] = [],
addedByDefault = false,
canBeIncluded = true
canBeIncluded = true,
): BaseUIElement {
const extraProps: (string | BaseUIElement)[] = []
@ -374,32 +374,32 @@ export default class LayerConfig extends WithContextLoader {
if (canBeIncluded) {
if (addedByDefault) {
extraProps.push(
"**This layer is included automatically in every theme. This layer might contain no points**"
"**This layer is included automatically in every theme. This layer might contain no points**",
)
}
if (this.shownByDefault === false) {
extraProps.push(
"This layer is not visible by default and must be enabled in the filter by the user. "
"This layer is not visible by default and must be enabled in the filter by the user. ",
)
}
if (this.title === undefined) {
extraProps.push(
"Elements don't have a title set and cannot be toggled nor will they show up in the dashboard. If you import this layer in your theme, override `title` to make this toggleable."
"Elements don't have a title set and cannot be toggled nor will they show up in the dashboard. If you import this layer in your theme, override `title` to make this toggleable.",
)
}
if (this.name === undefined && this.shownByDefault === false) {
extraProps.push(
"This layer is not visible by default and the visibility cannot be toggled, effectively resulting in a fully hidden layer. This can be useful, e.g. to calculate some metatags. If you want to render this layer (e.g. for debugging), enable it by setting the URL-parameter layer-<id>=true"
"This layer is not visible by default and the visibility cannot be toggled, effectively resulting in a fully hidden layer. This can be useful, e.g. to calculate some metatags. If you want to render this layer (e.g. for debugging), enable it by setting the URL-parameter layer-<id>=true",
)
}
if (this.name === undefined) {
extraProps.push(
"Not visible in the layer selection by default. If you want to make this layer toggable, override `name`"
"Not visible in the layer selection by default. If you want to make this layer toggable, override `name`",
)
}
if (this.mapRendering.length === 0) {
extraProps.push(
"Not rendered on the map by default. If you want to rendering this on the map, override `mapRenderings`"
"Not rendered on the map by default. If you want to rendering this on the map, override `mapRenderings`",
)
}
@ -411,23 +411,28 @@ export default class LayerConfig extends WithContextLoader {
: undefined,
"This layer is loaded from an external source, namely ",
new FixedUiElement(this.source.geojsonSource).SetClass("code"),
])
]),
)
}
} else {
extraProps.push(
"This layer can **not** be included in a theme. It is solely used by [special renderings](SpecialRenderings.md) showing a minimap with custom data."
"This layer can **not** be included in a theme. It is solely used by [special renderings](SpecialRenderings.md) showing a minimap with custom data.",
)
}
let usingLayer: BaseUIElement[] = []
if (usedInThemes?.length > 0 && !addedByDefault) {
usingLayer = [
new Title("Themes using this layer", 4),
new List(
(usedInThemes ?? []).map((id) => new Link(id, "https://mapcomplete.org/" + id))
),
]
if (!addedByDefault) {
if (usedInThemes?.length > 0) {
usingLayer = [
new Title("Themes using this layer", 4),
new List(
(usedInThemes ?? []).map((id) => new Link(id, "https://mapcomplete.org/" + id)),
),
]
} else if(this.source !== null) {
usingLayer = [new FixedUiElement("No themes use this layer")]
}
}
for (const dep of dependencies) {
@ -438,7 +443,7 @@ export default class LayerConfig extends WithContextLoader {
" into the layout as it depends on it: ",
dep.reason,
"(" + dep.context + ")",
])
]),
)
}
@ -447,7 +452,7 @@ export default class LayerConfig extends WithContextLoader {
new Combine([
"This layer is needed as dependency for layer",
new Link(revDep, "#" + revDep),
])
]),
)
}
@ -459,14 +464,14 @@ export default class LayerConfig extends WithContextLoader {
return undefined
}
const embedded: (Link | string)[] = values.values?.map((v) =>
Link.OsmWiki(values.key, v, true).SetClass("mr-2")
Link.OsmWiki(values.key, v, true).SetClass("mr-2"),
) ?? ["_no preset options defined, or no values in them_"]
return [
new Combine([
new Link(
"<img src='https://mapcomplete.org/assets/svg/statistics.svg' height='18px'>",
"https://taginfo.openstreetmap.org/keys/" + values.key + "#values",
true
true,
),
Link.OsmWiki(values.key),
]).SetClass("flex"),
@ -475,7 +480,7 @@ export default class LayerConfig extends WithContextLoader {
: new Link(values.type, "../SpecialInputElements.md#" + values.type),
new Combine(embedded).SetClass("flex"),
]
})
}),
)
let quickOverview: BaseUIElement = undefined
@ -485,7 +490,7 @@ export default class LayerConfig extends WithContextLoader {
"this quick overview is incomplete",
new Table(
["attribute", "type", "values which are supported by this layer"],
tableRows
tableRows,
).SetClass("zebra-table"),
]).SetClass("flex-col flex")
}
@ -499,7 +504,7 @@ export default class LayerConfig extends WithContextLoader {
(mr) =>
mr.RenderIcon(new ImmutableStore<OsmTags>({ id: "node/-1" }), {
includeBadges: false,
}).html
}).html,
)
.find((i) => i !== undefined)
}
@ -511,7 +516,7 @@ export default class LayerConfig extends WithContextLoader {
"Execute on overpass",
Overpass.AsOverpassTurboLink(<TagsFilter>this.source.osmTags.optimize())
.replaceAll("(", "%28")
.replaceAll(")", "%29")
.replaceAll(")", "%29"),
)
} catch (e) {
console.error("Could not generate overpasslink for " + this.id)
@ -533,19 +538,19 @@ export default class LayerConfig extends WithContextLoader {
const parts = neededTags["and"]
tagsDescription.push(
"Elements must match **all** of the following expressions:",
parts.map((p, i) => i + ". " + p.asHumanString(true, false, {})).join("\n")
parts.map((p, i) => i + ". " + p.asHumanString(true, false, {})).join("\n"),
)
} else if (neededTags["or"]) {
const parts = neededTags["or"]
tagsDescription.push(
"Elements must match **any** of the following expressions:",
parts.map((p) => " - " + p.asHumanString(true, false, {})).join("\n")
parts.map((p) => " - " + p.asHumanString(true, false, {})).join("\n"),
)
} else {
tagsDescription.push(
"Elements must match the expression **" +
neededTags.asHumanString(true, false, {}) +
"**"
neededTags.asHumanString(true, false, {}) +
"**",
)
}
@ -556,7 +561,7 @@ export default class LayerConfig extends WithContextLoader {
return new Combine([
new Combine([new Title(this.id, 1), iconImg, this.description, "\n"]).SetClass(
"flex flex-col"
"flex flex-col",
),
new List(extraProps),
...usingLayer,

View file

@ -313,6 +313,9 @@ export default class LayoutConfig implements LayoutInformation {
if (tags === undefined) {
return undefined
}
if(tags.id.startsWith("current_view")){
return this.getLayer("current_view")
}
for (const layer of this.layers) {
if (!layer.source) {
if (layer.isShown?.matchesProperties(tags)) {

View file

@ -7,7 +7,7 @@ import { TagsFilter } from "../../Logic/Tags/TagsFilter"
export default class LineRenderingConfig extends WithContextLoader {
public readonly color: TagRenderingConfig
public readonly width: TagRenderingConfig
public readonly dashArray: TagRenderingConfig
public readonly dashArray: string
public readonly lineCap: TagRenderingConfig
public readonly offset: TagRenderingConfig
public readonly fill: TagRenderingConfig
@ -19,7 +19,7 @@ export default class LineRenderingConfig extends WithContextLoader {
super(json, context)
this.color = this.tr("color", "#0000ff")
this.width = this.tr("width", "7")
this.dashArray = this.tr("dashArray", "")
this.dashArray = json.dashArray
this.lineCap = this.tr("lineCap", "round")
this.fill = this.tr("fill", undefined)
this.fillColor = this.tr("fillColor", undefined)

View file

@ -628,6 +628,22 @@ export default class TagRenderingConfig {
* config.constructChangeSpecification("", undefined, undefined, {}) // => undefined
* config.constructChangeSpecification("5", undefined, undefined, {}).optimize() // => new Tag("capacity", "5")
*
* // Should pick a mapping, even if freeform is used
* const config = new TagRenderingConfig({"id": "shop-types", render: "Shop type is {shop}", freeform: {key: "shop", addExtraTags:["fixme=freeform shop type used"]}, mappings:[{if: "shop=second_hand", then: "Second hand shop"}]})
* config.constructChangeSpecification("freeform", 1, undefined, {}).asHumanString(false, false, {}) // => "shop=freeform & fixme=freeform shop type used"
* config.constructChangeSpecification("freeform", undefined, undefined, {}).asHumanString(false, false, {}) // => "shop=freeform & fixme=freeform shop type used"
* config.constructChangeSpecification("second_hand", 1, undefined, {}).asHumanString(false, false, {}) // => "shop=second_hand"
*
*
* const config = new TagRenderingConfig({id: "oh", render: "{opening_hours}", question: {"en":"When open?"}, freeform: {key: "opening_hours"},
* mappings: [{ "if": "opening_hours=closed",
* "then": {
* "en": "Marked as closed for an unspecified time",
* },
* "hideInAnswer": true}] }
* const tags = config.constructChangeSpecification("Tu-Fr 05:30-09:30", undefined, undefined, { }}
* tags // =>new And([ new Tag("opening_hours", "Tu-Fr 05:30-09:30")])
*
* @param freeformValue The freeform value which will be applied as 'freeform.key'. Ignored if 'freeform.key' is not set
*
* @param singleSelectedMapping (Only used if multiAnswer == false): the single mapping to apply. Use (mappings.length) for the freeform
@ -640,6 +656,12 @@ export default class TagRenderingConfig {
multiSelectedMapping: boolean[] | undefined,
currentProperties: Record<string, string>
): UploadableTag {
console.log("constructChangeSpecification:", {
freeformValue,
singleSelectedMapping,
multiSelectedMapping,
currentProperties,
})
if (typeof freeformValue === "string") {
freeformValue = freeformValue?.trim()
}
@ -667,11 +689,22 @@ export default class TagRenderingConfig {
this.mappings.length == 0 ||
(singleSelectedMapping === this.mappings.length && !this.multiAnswer))
) {
const freeformOnly = { [this.freeform.key]: freeformValue }
const matchingMapping = this.mappings?.find((m) => m.if.matchesProperties(freeformOnly))
if (matchingMapping) {
return new And([matchingMapping.if, ...(matchingMapping.addExtraTags ?? [])])
}
// Either no mappings, or this is a radio-button selected freeform value
return new And([
const tag = new And([
new Tag(this.freeform.key, freeformValue),
...(this.freeform.addExtraTags ?? []),
])
const newProperties = tag.applyOn(currentProperties)
if (this.invalidValues?.matchesProperties(newProperties)) {
return undefined
}
return tag
}
if (this.multiAnswer) {

View file

@ -450,6 +450,19 @@ export default class ThemeViewState implements SpecialVisualizationState {
this.selectedElement.setData(feature)
}
public showCurrentLocationOn(map: Store<MlMap>): ShowDataLayer {
const id = "gps_location"
const flayerGps = this.layerState.filteredLayers.get(id)
const features = this.geolocation.currentUserLocation
return new ShowDataLayer(map, {
features,
doShowLayer: flayerGps.isDisplayed,
layer: flayerGps.layerDef,
metaTags: this.userRelatedState.preferencesAsTags,
selectedElement: this.selectedElement,
})
}
/**
* Various small methods that need to be called
*/
@ -674,7 +687,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
const summaryTileSource = new SummaryTileSource(
url.protocol + "//" + url.host + "/summary",
layers.map((l) => l.id),
this.mapProperties.zoom.map((z) => Math.max(Math.ceil(z), 0)),
this.mapProperties.zoom.map((z) => Math.max(Math.floor(z), 0)),
this.mapProperties,
{
isActive: this.mapProperties.zoom.map((z) => z <= maxzoom),
@ -682,6 +695,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
)
return new SummaryTileSourceRewriter(summaryTileSource, this.layerState.filteredLayers)
}
/**
* Add the special layers to the map
*/
@ -796,7 +810,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
new MetaTagging(this)
new TitleHandler(this.selectedElement, this.featureProperties, this)
new ChangeToElementsActor(this.changes, this.featureProperties)
new PendingChangesUploader(this.changes, this.selectedElement)
new PendingChangesUploader(this.changes, this.selectedElement, this.imageUploadManager)
new SelectedElementTagsUpdater(this)
new BackgroundLayerResetter(this.mapProperties.rasterLayer, this.availableLayers)
new PreferredRasterLayerSelector(

View file

@ -141,7 +141,7 @@
>
<Center class=" h-6 w-6" />
</div>
{:else}
{:else if !!$label}
<div
class={twMerge("soft relative rounded-full border border-black", size)}
on:click={() => focusMap()}

View file

@ -2,10 +2,10 @@
import { twMerge } from "tailwind-merge"
import Loading from "../../assets/svg/Loading.svelte"
export let cls: string = undefined
export let cls: string = "flex p-1 pl-2"
</script>
<div class={twMerge("flex p-1 pl-2", cls)}>
<div class={cls}>
<div class="min-w-6 h-6 w-6 shrink-0 animate-spin self-center">
<Loading />
</div>

View file

@ -3,12 +3,14 @@
import { twJoin } from "tailwind-merge"
import { Translation } from "../i18n/Translation"
import { ariaLabel } from "../../Utils/ariaLabel"
import { ImmutableStore, Store } from "../../Logic/UIEventSource"
/**
* A round button with an icon and possible a small text, which hovers above the map
*/
const dispatch = createEventDispatcher()
export let cls = "m-0.5 p-0.5 sm:p-1 md:m-1"
export let enabled : Store<boolean> = new ImmutableStore(true)
export let arialabel: Translation = undefined
</script>
@ -16,7 +18,7 @@
on:click={(e) => dispatch("click", e)}
on:keydown
use:ariaLabel={arialabel}
class={twJoin("pointer-events-auto relative h-fit w-fit rounded-full", cls)}
class={twJoin("pointer-events-auto relative h-fit w-fit rounded-full", cls, $enabled ? "" : "disabled")}
>
<slot />
</button>

View file

@ -1,31 +1,17 @@
<script lang="ts">
import { createEventDispatcher } from "svelte"
import BaseUIElement from "../BaseUIElement"
import Img from "./Img"
import { twJoin, twMerge } from "tailwind-merge"
import { twMerge } from "tailwind-merge"
export let imageUrl: string | BaseUIElement = undefined
export const message: string | BaseUIElement = undefined
export let options: {
imgSize?: string
extraClasses?: string
} = {}
let imgClasses = twJoin("block justify-center shrink-0 mr-4", options?.imgSize ?? "h-11 w-11")
const dispatch = createEventDispatcher<{ click }>()
</script>
<button
class={twMerge(options.extraClasses, "secondary no-image-background")}
on:click={(e) => dispatch("click", e)}
on:click
>
<slot name="image">
{#if imageUrl !== undefined}
{#if typeof imageUrl === "string"}
<Img src={imageUrl} class={imgClasses} />
{/if}
{/if}
</slot>
<slot name="image" />
<slot name="message" />
</button>

View file

@ -17,7 +17,8 @@ export default class Title extends BaseUIElement {
constructor(embedded: string | BaseUIElement, level: number = 3) {
super()
if (embedded === undefined) {
throw "A title should have some content. Undefined is not allowed"
console.warn("A title should have some content. Undefined is not allowed")
embedded = ""
}
if (typeof embedded === "string") {
this.title = new FixedUiElement(embedded)

View file

@ -66,7 +66,7 @@
/>
</If>
{filteredLayer.layerDef.name}
<Tr t={filteredLayer.layerDef.name}/>
{#if $zoomlevel < layer.minzoom}
<span class="alert">
@ -82,7 +82,7 @@
<!-- There are three (and a half) modes of filters: a single checkbox, a radio button/dropdown or with searchable fields -->
{#if filter.options.length === 1 && filter.options[0].fields.length === 0}
<Checkbox selected={getBooleanStateFor(filter)}>
{filter.options[0].question}
<Tr t={filter.options[0].question}/>
</Checkbox>
{/if}
@ -94,7 +94,7 @@
<Dropdown value={getStateFor(filter)}>
{#each filter.options as option, i}
<option value={i}>
{option.question}
<Tr t={option.question}/>
</option>
{/each}
</Dropdown>

View file

@ -6,6 +6,7 @@
import { UIEventSource } from "../../Logic/UIEventSource"
import { onDestroy } from "svelte"
import { Utils } from "../../Utils"
import Tr from "../Base/Tr.svelte"
export let filteredLayer: FilteredLayer
export let option: FilterConfigOption

View file

@ -2,7 +2,6 @@
import type { SpecialVisualizationState } from "../SpecialVisualization"
import LocationInput from "../InputElement/Helpers/LocationInput.svelte"
import { UIEventSource } from "../../Logic/UIEventSource"
import { Tiles } from "../../Models/TileRange"
import { Map as MlMap } from "maplibre-gl"
import { BBox } from "../../Logic/BBox"
import type { MapProperties } from "../../Models/MapProperties"
@ -15,7 +14,6 @@
import FeatureSourceMerger from "../../Logic/FeatureSource/Sources/FeatureSourceMerger"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { Utils } from "../../Utils"
import { createEventDispatcher } from "svelte"
import Move_arrows from "../../assets/svg/Move_arrows.svelte"
/**
@ -53,9 +51,6 @@
lat: number
}>(undefined)
const dispatch = createEventDispatcher<{ click: { lon: number; lat: number } }>()
const xyz = Tiles.embedded_tile(coordinate.lat, coordinate.lon, 16)
const map: UIEventSource<MlMap> = new UIEventSource<MlMap>(undefined)
let initialMapProperties: Partial<MapProperties> & { location } = {
zoom: new UIEventSource<number>(19),
@ -73,6 +68,7 @@
minzoom: new UIEventSource<number>(18),
rasterLayer: UIEventSource.feedFrom(state.mapProperties.rasterLayer),
}
state?.showCurrentLocationOn(map)
if (targetLayer) {
const featuresForLayer = state.perLayer.get(targetLayer.id)
@ -120,7 +116,7 @@
<LocationInput
{map}
on:click={(data) => dispatch("click", data)}
on:click
mapProperties={initialMapProperties}
value={preciseLocation}
initialCoordinate={coordinate}

View file

@ -1,15 +1,20 @@
<script lang="ts">
/**
* A mapcontrol button which allows the user to select a different background.
* Even though the component is very small, it gets it's own class as it is often reused
* Even though the component is very small, it gets its own class as it is often reused
*/
import { Square3Stack3dIcon } from "@babeard/svelte-heroicons/solid"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import Translations from "../i18n/Translations"
import MapControlButton from "../Base/MapControlButton.svelte"
import Tr from "../Base/Tr.svelte"
import StyleLoadingIndicator from "../Map/StyleLoadingIndicator.svelte"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { Map as MlMap } from "maplibre-gl"
import ThemeViewState from "../../Models/ThemeViewState"
export let state: SpecialVisualizationState
export let state: ThemeViewState
export let map: Store<MlMap> = undefined
export let hideTooltip = false
</script>
@ -17,7 +22,10 @@
arialabel={Translations.t.general.labels.background}
on:click={() => state.guistate.backgroundLayerSelectionIsOpened.setData(true)}
>
<Square3Stack3dIcon class="h-6 w-6" />
<StyleLoadingIndicator map={map ?? state.map} rasterLayer={state.mapProperties.rasterLayer} >
<Square3Stack3dIcon class="h-6 w-6" />
</StyleLoadingIndicator>
{#if !hideTooltip}
<Tr cls="mx-2" t={Translations.t.general.backgroundSwitch} />
{/if}

View file

@ -13,20 +13,25 @@
export let layer: LayerConfig
export let selectedElement: Feature
let tags: UIEventSource<Record<string, string>> = state.featureProperties.getStore(
selectedElement.properties.id
selectedElement.properties.id,
)
$: {
tags = state.featureProperties.getStore(selectedElement.properties.id)
}
let isTesting = state.featureSwitchIsTesting
let isDebugging = state.featureSwitches.featureSwitchIsDebugging
let metatags: Store<Record<string, string>> = state.userRelatedState.preferencesAsTags
</script>
{#if $tags._deleted === "yes"}
<Tr t={Translations.t.delete.isDeleted} />
{:else}
<div class="low-interaction flex border-b-2 border-black px-3 drop-shadow-md">
<div class="h-fit w-full overflow-auto sm:p-2" style="max-height: 20vh;">
<div class="low-interaction flex border-b-2 border-black px-3 drop-shadow-md">
<div class="h-fit w-full overflow-auto sm:p-2" style="max-height: 20vh;">
{#if $tags._deleted === "yes"}
<h3 class="p-4">
<Tr t={Translations.t.delete.deletedTitle} />
</h3>
{:else}
<div class="flex h-full w-full flex-grow flex-col">
<!-- Title element and title icons-->
<h3 class="m-0">
@ -34,12 +39,11 @@
<TagRenderingAnswer config={layer.title} {selectedElement} {state} {tags} {layer} />
</a>
</h3>
<div
class="no-weblate title-icons links-as-button mr-2 flex flex-row flex-wrap items-center gap-x-0.5 pt-0.5 sm:pt-1"
>
{#each layer.titleIcons as titleIconConfig}
{#if (titleIconConfig.condition?.matchesProperties($tags) ?? true) && (titleIconConfig.metacondition?.matchesProperties( { ...$metatags, ...$tags } ) ?? true) && titleIconConfig.IsKnown($tags)}
{#if (titleIconConfig.condition?.matchesProperties($tags) ?? true) && (titleIconConfig.metacondition?.matchesProperties({ ...$metatags, ...$tags }) ?? true) && titleIconConfig.IsKnown($tags)}
<div class={titleIconConfig.renderIconClass ?? "flex h-8 w-8 items-center"}>
<TagRenderingAnswer
config={titleIconConfig}
@ -52,23 +56,27 @@
</div>
{/if}
{/each}
{#if $isTesting || $isDebugging}
<a class="subtle" href="https://github.com/pietervdvn/MapComplete/blob/develop/Docs/Layers/{layer.id}.md"
target="_blank" rel="noreferrer noopener ">{layer.id}</a>
{/if}
</div>
</div>
</div>
<button
on:click={() => state.selectedElement.setData(undefined)}
use:ariaLabel={Translations.t.general.backToMap}
class="mt-2 h-fit shrink-0 rounded-full border-none p-0"
style="border: 0 !important; padding: 0 !important;"
>
<XCircleIcon aria-hidden={true} class="h-8 w-8" />
</button>
{/if}
</div>
{/if}
<button
class="mt-2 h-fit shrink-0 rounded-full border-none p-0"
on:click={() => state.selectedElement.setData(undefined)}
style="border: 0 !important; padding: 0 !important;"
use:ariaLabel={Translations.t.general.backToMap}
>
<XCircleIcon aria-hidden={true} class="h-8 w-8" />
</button>
</div>
<style>
:global(.title-icons a) {
display: block !important;
}
:global(.title-icons a) {
display: block !important;
}
</style>

View file

@ -8,17 +8,21 @@
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig"
import UserRelatedState from "../../Logic/State/UserRelatedState"
import Delete_icon from "../../assets/svg/Delete_icon.svelte"
import BackButton from "../Base/BackButton.svelte"
export let state: SpecialVisualizationState
export let layer: LayerConfig
export let selectedElement: Feature
export let highlightedRendering: UIEventSource<string> = undefined
export let tags: UIEventSource<Record<string, string>> = state?.featureProperties?.getStore(
selectedElement.properties.id
)
let layer: LayerConfig = selectedElement.properties.id === "settings" ? UserRelatedState.usersettingsConfig : state.layout.getMatchingLayer(tags.data)
let stillMatches = tags.map(tags => !layer?.source?.osmTags || layer.source.osmTags?.matchesProperties(tags))
let _metatags: Record<string, string>
@ -27,7 +31,7 @@
onDestroy(
state.userRelatedState.preferencesAsTags.addCallbackAndRun((tags) => {
_metatags = tags
})
}),
)
}
@ -36,22 +40,26 @@
(config) =>
(config.condition?.matchesProperties(tgs) ?? true) &&
(config.metacondition?.matchesProperties({ ...tgs, ..._metatags }) ?? true) &&
config.IsKnown(tgs)
)
config.IsKnown(tgs),
),
)
</script>
{#if !$stillMatches}
<div class="alert" aria-live="assertive">
<Tr t={Translations.t.delete.isChanged}/>
<div class="alert" aria-live="assertive">
<Tr t={Translations.t.delete.isChanged} />
</div>
{:else if $tags._deleted === "yes"}
<div aria-live="assertive">
<Tr t={Translations.t.delete.isDeleted} />
<div class="flex w-full flex-col p-2">
<div aria-live="assertive" class="alert flex items-center justify-center self-stretch">
<Delete_icon class="w-8 h-8 m-2" />
<Tr t={Translations.t.delete.isDeleted} />
</div>
<BackButton clss="self-stretch mt-4" on:click={() => state.selectedElement.setData(undefined)}>
<Tr t={Translations.t.general.returnToTheMap} />
</BackButton>
</div>
<button class="w-full" on:click={() => state.selectedElement.setData(undefined)}>
<Tr t={Translations.t.general.returnToTheMap} />
</button>
{:else}
<div
class="selected-element-view flex h-full w-full flex-col gap-y-2 overflow-y-auto p-1 px-4"

View file

@ -11,10 +11,11 @@
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
import { Utils } from "../../Utils"
import Svg from "../../Svg"
import ToSvelte from "../Base/ToSvelte.svelte"
import { DocumentDuplicateIcon } from "@rgossiaux/svelte-heroicons/outline"
import Share from "../../assets/svg/Share.svelte"
import ToSvelte from "../Base/ToSvelte.svelte"
import Img from "../Base/Img"
import Qr from "../../Utils/Qr"
export let state: ThemeViewState
const tr = Translations.t.general.sharescreen
@ -69,22 +70,32 @@
}
</script>
<div>
<Tr t={tr.intro} />
<div class="flex">
{#if typeof navigator?.share === "function"}
<button class="h-8 w-8 shrink-0 p-1" on:click={shareCurrentLink}>
<Share />
</button>
{/if}
{#if navigator.clipboard !== undefined}
<button class="no-image-background h-8 w-8 shrink-0 p-1" on:click={copyCurrentLink}>
<DocumentDuplicateIcon />
</button>
{/if}
<div class="literal-code" on:click={(e) => Utils.selectTextIn(e.target)}>
{linkToShare}
<div class="flex flex-col">
<div class="flex justify-between items-start">
<div class="flex flex-col">
<Tr t={tr.intro} />
<div class="flex">
{#if typeof navigator?.share === "function"}
<button class="h-8 w-8 shrink-0 p-1" on:click={shareCurrentLink}>
<Share />
</button>
{/if}
{#if navigator.clipboard !== undefined}
<button class="no-image-background h-8 w-8 shrink-0 p-1" on:click={copyCurrentLink}>
<DocumentDuplicateIcon />
</button>
{/if}
<div class="literal-code" on:click={(e) => Utils.selectTextIn(e.target)}>
{linkToShare}
</div>
</div>
</div>
<ToSvelte construct={() => new Img(new Qr(linkToShare).toImageElement(125)).SetStyle(
"width: 125px"
)} />
</div>
<div class="flex justify-center">
@ -95,29 +106,31 @@
<Tr t={tr.embedIntro} />
<div class="link-underline my-1 flex flex-col">
<label>
<input bind:checked={showWelcomeMessage} type="checkbox" />
<Tr t={tr.fsWelcomeMessage} />
</label>
<div class="flex flex-col interactive p-1">
<label>
<input bind:checked={enableLogin} type="checkbox" />
<Tr t={tr.fsUserbadge} />
</label>
</div>
<div class="literal-code m-1">
&lt;span class="literal-code iframe-code-block"&gt; <br />
&lt;iframe src="{linkToShare}"
<br />
allow="geolocation" width="100%" height="100%" style="min-width: 250px; min-height: 250px"
<br />
title="{state.layout.title?.txt ?? "MapComplete"} with MapComplete"&gt;
<br />
&lt;/iframe&gt;
<br />
&lt;/span&gt;
</div>
<div class="link-underline my-1 flex flex-col">
<label>
<input bind:checked={showWelcomeMessage} type="checkbox" id="share_show_welcome" />
<Tr t={tr.fsWelcomeMessage} />
</label>
<div class="literal-code m-1">
&lt;span class="literal-code iframe-code-block"&gt; <br />
&lt;iframe src="${url}"
<br />
allow="geolocation" width="100%" height="100%" style="min-width: 250px; min-height: 250px"
<br />
title="${state.layout.title?.txt ?? "MapComplete"} with MapComplete"&gt;
<br />
&lt;/iframe&gt;
<br />
&lt;/span&gt;
<label>
<input bind:checked={enableLogin} type="checkbox" id="share_enable_login"/>
<Tr t={tr.fsUserbadge} />
</label>
</div>
</div>
<Tr t={tr.documentation} cls="link-underline" />
<Tr cls="link-underline" t={tr.documentation} />
</div>

View file

@ -17,14 +17,17 @@ export default class StatisticsForLayerPanel extends VariableUiElement {
return new Loading("Loading data")
}
if (features.length === 0) {
return "No elements in view"
return new Combine([
"No elements in view for layer ",
layer.id
]).SetClass("block")
}
const els: BaseUIElement[] = []
const featuresForLayer = features
if (featuresForLayer.length === 0) {
return
}
els.push(new Title(layer.name.Clone(), 1).SetClass("mt-8"))
els.push(new Title(layer.name, 1).SetClass("mt-8"))
const layerStats = []
for (const tagRendering of layer?.tagRenderings ?? []) {

View file

@ -8,7 +8,9 @@ import { OsmFeature } from "../../Models/OsmFeature"
export interface TagRenderingChartOptions {
groupToOtherCutoff?: 3 | number
sort?: boolean
sort?: boolean,
hideUnkown?: boolean,
hideNotApplicable?: boolean
}
export class StackedRenderingChart extends ChartJs {
@ -19,12 +21,16 @@ 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?: string[],
hideUnknown?: boolean,
hideNotApplicable?: boolean
}
) {
const { labels, data } = TagRenderingChart.extractDataAndLabels(tr, features, {
sort: true,
groupToOtherCutoff: options?.groupToOtherCutoff,
hideNotApplicable: options?.hideNotApplicable,
hideUnkown: options?.hideUnknown
})
if (labels === undefined || data === undefined) {
console.error(
@ -36,7 +42,6 @@ export class StackedRenderingChart extends ChartJs {
)
throw "No labels or data given..."
}
// labels: ["cyclofix", "buurtnatuur", ...]; data : [ ["cyclofix-changeset", "cyclofix-changeset", ...], ["buurtnatuur-cs", "buurtnatuur-cs"], ... ]
for (let i = labels.length; i >= 0; i--) {
if (data[i]?.length != 0) {
@ -116,13 +121,13 @@ export class StackedRenderingChart extends ChartJs {
datasets.push({
data: countsPerDay,
backgroundColor,
label,
label
})
}
const perDayData = {
labels: trimmedDays,
datasets,
datasets
}
const config = <ChartConfiguration>{
@ -131,17 +136,17 @@ export class StackedRenderingChart extends ChartJs {
options: {
responsive: true,
legend: {
display: false,
display: false
},
scales: {
x: {
stacked: true,
stacked: true
},
y: {
stacked: true,
},
},
},
stacked: true
}
}
}
}
super(config)
}
@ -194,7 +199,7 @@ export default class TagRenderingChart extends Combine {
"rgba(255, 206, 86, 0.2)",
"rgba(75, 192, 192, 0.2)",
"rgba(153, 102, 255, 0.2)",
"rgba(255, 159, 64, 0.2)",
"rgba(255, 159, 64, 0.2)"
]
public static readonly borderColors = [
@ -203,7 +208,7 @@ export default class TagRenderingChart extends Combine {
"rgba(255, 206, 86, 1)",
"rgba(75, 192, 192, 1)",
"rgba(153, 102, 255, 1)",
"rgba(255, 159, 64, 1)",
"rgba(255, 159, 64, 1)"
]
/**
@ -239,12 +244,12 @@ export default class TagRenderingChart extends Combine {
const borderColor = [
TagRenderingChart.unkownBorderColor,
TagRenderingChart.otherBorderColor,
TagRenderingChart.notApplicableBorderColor,
TagRenderingChart.notApplicableBorderColor
]
const backgroundColor = [
TagRenderingChart.unkownColor,
TagRenderingChart.otherColor,
TagRenderingChart.notApplicableColor,
TagRenderingChart.notApplicableColor
]
while (borderColor.length < data.length) {
@ -276,17 +281,17 @@ export default class TagRenderingChart extends Combine {
backgroundColor,
borderColor,
borderWidth: 1,
label: undefined,
},
],
label: undefined
}
]
},
options: {
plugins: {
legend: {
display: !barchartMode,
},
},
},
display: !barchartMode
}
}
}
}
const chart = new ChartJs(config).SetClass(options?.chartclasses ?? "w-32 h-32")
@ -297,7 +302,7 @@ export default class TagRenderingChart extends Combine {
super([
options?.includeTitle ? tagRendering.question.Clone() ?? tagRendering.id : undefined,
chart,
chart
])
this.SetClass("block")
@ -386,20 +391,26 @@ export default class TagRenderingChart extends Combine {
}
}
const labels = [
"Unknown",
"Other",
"Not applicable",
const labels = []
const data: T[][] = []
if (!options.hideUnkown) {
data.push(unknownCount)
labels.push("Unknown")
}
data.push(otherGrouped)
labels.push("Other")
if (!options.hideNotApplicable) {
data.push(notApplicable)
labels.push(
"Not applicable"
)
}
data.push(...categoryCounts,
...otherData)
labels.push(
...(mappings?.map((m) => m.then.txt) ?? []),
...otherLabels,
]
const data: T[][] = [
unknownCount,
otherGrouped,
notApplicable,
...categoryCounts,
...otherData,
]
...otherLabels)
return { labels, data }
}

View file

@ -6,7 +6,6 @@
import Constants from "../../Models/Constants"
import type { LayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import Tr from "../Base/Tr.svelte"
import SubtleLink from "../Base/SubtleLink.svelte"
import Translations from "../i18n/Translations"
import { LocalStorageSource } from "../../Logic/Web/LocalStorageSource"
@ -86,8 +85,10 @@
</script>
{#if theme.id !== personal.id || $unlockedPersonal}
<SubtleLink href={$href} options={{ extraClasses: "w-full" }}>
<img slot="image" src={theme.icon} class="m-1 mr-2 block h-11 w-11 sm:m-2 sm:mr-4" alt="" />
<a
class={"w-full button text-ellipsis"}
href={$href}
> <img src={theme.icon} class="m-1 mr-2 block h-11 w-11 sm:m-2 sm:mr-4" alt="" />
<span class="flex flex-col overflow-hidden text-ellipsis">
<Tr t={title} />
@ -96,6 +97,5 @@
<Tr t={Translations.t.general.morescreen.enterToOpen} />
</span>
{/if}
</span>
</SubtleLink>
</span></a>
{/if}

View file

@ -23,18 +23,20 @@
import { GeoOperations } from "../../Logic/GeoOperations"
import { BBox } from "../../Logic/BBox"
import type { Feature, LineString, Point } from "geojson"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import SmallZoomButtons from "../Map/SmallZoomButtons.svelte"
const splitpoint_style = new LayerConfig(
<LayerConfigJson>split_point,
"(BUILTIN) SplitRoadWizard.ts",
true
) as const
true,
)
const splitroad_style = new LayerConfig(
<LayerConfigJson>split_road,
"(BUILTIN) SplitRoadWizard.ts",
true
) as const
true,
)
/**
* The way to focus on
@ -45,6 +47,7 @@
* A default is given
*/
export let layer: LayerConfig = splitroad_style
export let state: SpecialVisualizationState | undefined = undefined
/**
* Optional: use these properties to set e.g. background layer
*/
@ -58,6 +61,7 @@
adaptor.bounds.setData(BBox.get(wayGeojson).pad(2))
adaptor.maxbounds.setData(BBox.get(wayGeojson).pad(2))
state?.showCurrentLocationOn(map)
new ShowDataLayer(map, {
features: new StaticFeatureSource([wayGeojson]),
drawMarkers: false,
@ -101,6 +105,7 @@
})
</script>
<div class="h-full w-full">
<MaplibreMap {map} />
<div class="h-full w-full relative">
<MaplibreMap {map} mapProperties={adaptor} />
<SmallZoomButtons {adaptor} />
</div>

View file

@ -8,6 +8,10 @@
import { SvgToPdf } from "../../Utils/svgToPdf"
import ThemeViewState from "../../Models/ThemeViewState"
import DownloadPdf from "./DownloadPdf.svelte"
import { PngMapCreator } from "../../Utils/pngMapCreator"
import { UIEventSource } from "../../Logic/UIEventSource"
import ValidatedInput from "../InputElement/ValidatedInput.svelte"
import { LocalStorageSource } from "../../Logic/Web/LocalStorageSource"
export let state: ThemeViewState
let isLoading = state.dataIsLoading
@ -34,9 +38,21 @@
mapExtent: state.mapProperties.bounds.data,
width: maindiv.offsetWidth,
height: maindiv.offsetHeight,
noSelfIntersectingLines,
noSelfIntersectingLines
})
}
let customWidth = LocalStorageSource.Get("custom-png-width", "20")
let customHeight = LocalStorageSource.Get("custom-png-height", "20")
async function offerCustomPng(): Promise<Blob> {
console.log("Creating a custom size png with dimensions", customWidth.data + "mm *", customHeight.data + "mm")
const creator = new PngMapCreator(state, {
height: Number(customHeight.data), width: Number(customWidth.data)
})
return await creator.CreatePng("belowmap")
}
</script>
{#if $isLoading}
@ -107,5 +123,26 @@
{/each}
</div>
<div class="low-interaction p-2 mt-4">
<h3 class="m-0 mb-2">
<Tr t={t.custom.title}/></h3>
<div class="flex">
<Tr t={t.custom.width} />
<ValidatedInput {state} type="pnat" value={customWidth} />
</div>
<div class="flex">
<Tr t={t.custom.height} />
<ValidatedInput {state} type="pnat" value={customHeight} />
</div>
<DownloadButton
mainText={t.custom.download.Subs({width: $customWidth, height: $customHeight})}
helperText={t.custom.downloadHelper}
extension="png"
construct={() => offerCustomPng()}
{state}
mimetype="image/png"
/>
</div>
<Tr cls="link-underline" t={t.licenseInfo} />
{/if}

View file

@ -25,7 +25,7 @@
const templateUrls = SvgToPdf.templates[templateName].pages
const templates: string[] = await Promise.all(templateUrls.map((url) => Utils.download(url)))
console.log("Templates are", templates)
const bg = state.mapProperties.rasterLayer.data ?? AvailableRasterLayers.maptilerDefaultLayer
const bg = state.mapProperties.rasterLayer.data ?? AvailableRasterLayers.defaultBackgroundLayer
const creator = new SvgToPdf(title, templates, {
state,
freeComponentId: "belowmap",

View file

@ -10,7 +10,7 @@
*/
export let image: ProvidedImage
let license: Store<LicenseInfo> = UIEventSource.FromPromise(
image.provider?.DownloadAttribution(image.url)
image.provider?.DownloadAttribution(image)
)
let icon = image.provider?.SourceIcon(image.id)?.SetClass("block h-8 w-8 pr-2")
</script>
@ -38,26 +38,28 @@
</div>
{/if}
<div class="flex justify-between">
<div class="flex justify-between w-full gap-x-1">
{#if $license.license !== undefined || $license.licenseShortName !== undefined}
<div>
{$license?.license ?? $license?.licenseShortName}
</div>
{/if}
{#if $license.date}
<div>
{$license.date.toLocaleDateString()}
{#if $license.views}
<div class="flex justify-around self-center">
<EyeIcon class="h-4 w-4 pr-1" />
{$license.views}
</div>
{/if}
</div>
{#if $license.views}
<div class="flex justify-around self-center">
<EyeIcon class="h-4 w-4 pr-1" />
{$license.views}
{#if $license.date}
<div>
{$license.date.toLocaleDateString()}
</div>
{/if}
</div>
</div>
{/if}

View file

@ -0,0 +1,29 @@
<script lang="ts">
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
import { XCircleIcon } from "@babeard/svelte-heroicons/solid"
export let failed: number
const t = Translations.t.image
</script>
<div class="alert flex">
<div class="flex flex-col items-start">
{#if failed === 1}
<Tr t={t.upload.one.failed} />
{:else}
<Tr t={t.upload.multiple.someFailed.Subs({ count: failed })} />
{/if}
<Tr cls="text-normal" t={t.upload.failReasons} />
<Tr cls="text-xs" t={t.upload.failReasonsAdvanced} />
</div>
<button
class="mt-2 h-fit shrink-0 rounded-full border-none p-0 pointer-events-auto"
on:click
style="border: 0 !important; padding: 0 !important;"
>
<XCircleIcon aria-hidden={true} class="h-8 w-8" />
</button>
</div>

View file

@ -11,6 +11,8 @@
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
import Loading from "../Base/Loading.svelte"
import { XCircleIcon } from "@babeard/svelte-heroicons/solid"
import UploadFailedMessage from "./UploadFailedMessage.svelte"
export let state: SpecialVisualizationState
export let tags: Store<OsmTags> = undefined
@ -22,31 +24,40 @@
const { uploadStarted, uploadFinished, retried, failed } =
state.imageUploadManager.getCountsFor(featureId)
const t = Translations.t.image
const debugging = state.featureSwitches.featureSwitchIsDebugging
let dismissed = 0
</script>
{#if $uploadStarted === 1}
{#if $debugging}
<div class="low-interaction">Started {$uploadStarted} Done {$uploadFinished} Retry {$retried} Err {$failed}</div>
{/if}
{#if dismissed === $uploadStarted}
<!-- We don't show anything as we ignore this number of failed items-->
{:else if $uploadStarted === 1}
{#if $uploadFinished === 1}
{#if showThankYou}
<Tr cls="thanks" t={t.upload.one.done} />
{/if}
{:else if $failed === 1}
<div class="alert flex flex-col">
<Tr cls="self-center" t={t.upload.one.failed} />
<Tr t={t.upload.failReasons} />
<Tr t={t.upload.failReasonsAdvanced} />
</div>
<UploadFailedMessage failed={$failed} on:click={() => dismissed = $failed}/>
{:else if $retried === 1}
<Loading cls="alert">
<div class="alert">
<Loading>
<Tr t={t.upload.one.retrying} />
</Loading>
</div>
{:else}
<Loading cls="alert">
<div class="alert">
<Loading>
<Tr t={t.upload.one.uploading} />
</Loading>
</div>
{/if}
{:else if $uploadStarted > 1}
{#if $uploadFinished + $failed === $uploadStarted && $uploadFinished > 0}
{#if showThankYou}
{#if $uploadFinished + $failed === $uploadStarted}
{#if $uploadFinished === 0}
<!-- pass -->
{:else if showThankYou}
<Tr cls="thanks" t={t.upload.multiple.done.Subs({ count: $uploadFinished })} />
{/if}
{:else if $uploadFinished === 0}
@ -64,14 +75,7 @@
</Loading>
{/if}
{#if $failed > 0}
<div class="alert flex flex-col">
{#if $failed === 1}
<Tr cls="self-center" t={t.upload.one.failed} />
{:else}
<Tr cls="self-center" t={t.upload.multiple.someFailed.Subs({ count: $failed })} />
{/if}
<Tr t={t.upload.failReasons} />
<Tr t={t.upload.failReasonsAdvanced} />
</div>
<UploadFailedMessage failed={$failed} on:click={() => dismissed = $failed}/>
{/if}
{/if}

View file

@ -63,7 +63,7 @@
on:touchstart={(e) => onPosChange(e.touches[0].clientX, e.touches[0].clientY)}
>
<div class="absolute top-0 left-0 h-full w-full cursor-pointer">
<MaplibreMap attribution={false} {map} />
<MaplibreMap mapProperties={mla} {map} />
</div>
<div bind:this={directionElem} class="absolute top-0 left-0 h-full w-full">

View file

@ -13,6 +13,7 @@
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import { createEventDispatcher, onDestroy } from "svelte"
import Move_arrows from "../../../assets/svg/Move_arrows.svelte"
import SmallZoomButtons from "../../Map/SmallZoomButtons.svelte"
/**
* A visualisation to pick a location on a map background
@ -83,7 +84,7 @@
<div class="min-h-32 relative h-full cursor-pointer overflow-hidden">
<div class="absolute top-0 left-0 h-full w-full cursor-pointer">
<MaplibreMap center={{ lng: initialCoordinate.lon, lat: initialCoordinate.lat }} {map} />
<MaplibreMap center={{ lng: initialCoordinate.lon, lat: initialCoordinate.lat }} {map} mapProperties={mla}/>
</div>
<div
@ -95,4 +96,5 @@
</div>
<DragInvitation hideSignal={mla.location} />
<SmallZoomButtons adaptor={mla} />
</div>

View file

@ -3,6 +3,7 @@
import LanguageUtils from "../../../Utils/LanguageUtils"
import { createEventDispatcher, onDestroy } from "svelte"
import ValidatedInput from "../ValidatedInput.svelte"
import { del } from "idb-keyval"
export let value: UIEventSource<Record<string, string>> = new UIEventSource<
Record<string, string>
@ -18,14 +19,25 @@
const allLanguages: string[] = LanguageUtils.usedLanguagesSorted
let currentLang = new UIEventSource("en")
const currentVal = new UIEventSource<string>("")
/**
* Mostly the same as currentVal, but might be the empty string as well
*/
const currentValRaw = new UIEventSource<string>("")
let dispatch = createEventDispatcher<{ submit }>()
function update() {
const v = currentVal.data
let v = currentValRaw.data
const l = currentLang.data
console.log("Updating translation input for value", v, " and language", l)
if (<any>translations.data === "" || translations.data === undefined) {
translations.data = {}
}
if (v === "") {
delete translations.data[l]
translations.ping()
return
}
if (translations.data[l] === v) {
return
}
@ -39,35 +51,52 @@
translations.data = {}
}
translations.data[currentLang] = translations.data[currentLang] ?? ""
currentVal.setData(translations.data[currentLang])
if (translations.data[currentLang] === "") {
delete translations.data[currentLang]
}
currentVal.setData(translations.data[currentLang] ?? "")
currentValRaw.setData(translations.data[currentLang])
})
)
onDestroy(
currentVal.addCallbackAndRunD(() => {
currentValRaw.addCallbackAndRunD(() => {
update()
})
)
</script>
<div class="interactive m-1 mt-2 flex space-x-1 font-bold">
</script>
<div class="flex flex-col gap-y-1">
<div class="interactive m-1 mt-2 flex space-x-1 font-bold">
<span>
{prefix}
</span>
<select bind:value={$currentLang}>
{#each allLanguages as language}
<option value={language}>
{language}
</option>
{/each}
</select>
<ValidatedInput
type="string"
cls="w-full"
value={currentVal}
on:submit={() => dispatch("submit")}
/>
<span>
<select bind:value={$currentLang}>
{#each allLanguages as language}
<option value={language}>
{language}
{#if $translations[language] !== undefined}
*
{/if}
</option>
{/each}
</select>
<ValidatedInput
type="string"
cls="w-full"
value={currentVal}
unvalidatedText={currentValRaw}
on:submit={() => dispatch("submit")}
/>
<span>
{postfix}
</span>
</div>
You have currently set translations for
<ul>
{#each Object.keys($translations) as l}
<li><button class="small" on:click={() => currentLang.setData(l)}><b>{l}:</b> {$translations[l]}</button></li>
{/each}
</ul>
</div>

View file

@ -60,6 +60,11 @@ export default class InputHelpers {
if (!mapProperties.zoom) {
mapProperties = { ...mapProperties, zoom: new UIEventSource<number>(zoom) }
}
if (!mapProperties.rasterLayer) {
/* mapProperties = {
...mapProperties, rasterLayer: properties?.mapProperties?.rasterLayer
}*/
}
return mapProperties
}
@ -69,11 +74,10 @@ export default class InputHelpers {
) {
const inputHelperOptions = props
const args = inputHelperOptions.args ?? []
const searchKey = <string>args[0] ?? "name"
const searchKey: string = <string>args[0] ?? "name"
const searchFor = <string>(
(inputHelperOptions.feature?.properties[searchKey]?.toLowerCase() ?? "")
)
const searchFor: string = searchKey.split(";").map(k => inputHelperOptions.feature?.properties[k]?.toLowerCase())
.find(foundValue => !!foundValue) ?? ""
let searchForValue: UIEventSource<string> = new UIEventSource(searchFor)
const options: any = args[1]
@ -121,7 +125,7 @@ export default class InputHelpers {
value,
searchText: searchForValue,
instanceOf,
notInstanceOf,
notInstanceOf
})
}
}

View file

@ -40,11 +40,14 @@
{#if availableLanguages?.length > 1}
<form class={twMerge("flex max-w-full items-center pr-4", clss)}>
<label
for="pick-language"
class="neutral-label flex max-w-full"
use:ariaLabel={Translations.t.general.pickLanguage}
>
<LanguageIcon class="mr-1 h-4 w-4 shrink-0" aria-hidden="true" />
<Dropdown cls="max-w-full" value={assignTo}>
</label>
<Dropdown cls="max-w-full" value={assignTo} id="pick-language">
{#if preferredFiltered}
{#each preferredFiltered as language}
<option value={language} class="font-bold">
@ -70,6 +73,5 @@
</option>
{/each}
</Dropdown>
</label>
</form>
{/if}

View file

@ -57,8 +57,8 @@ export abstract class Validator {
*
* Returns 'undefined' if the element is valid
*/
public getFeedback(s: string, _?: () => string): Translation | undefined {
if (this.isValid(s)) {
public getFeedback(s: string, getCountry?: () => string): Translation | undefined {
if (this.isValid(s, getCountry)) {
return undefined
}
const tr = Translations.t.validation[this.name]
@ -71,7 +71,7 @@ export abstract class Validator {
return Translations.t.validation[this.name].description
}
public isValid(_: string): boolean {
public isValid(_: string, getCountry?: () => string): boolean {
return true
}

View file

@ -52,7 +52,4 @@ export default class OpeningHoursValidator extends Validator {
)
}
reformat(s: string, _?: () => string): string {
return super.reformat(s, _)
}
}

View file

@ -1,6 +1,7 @@
import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
import type { Map as MLMap } from "maplibre-gl"
import { Map as MLMap } from "maplibre-gl"
import { Map as MlMap, SourceSpecification } from "maplibre-gl"
import maplibregl from "maplibre-gl"
import { RasterLayerPolygon } from "../../Models/RasterLayers"
import { Utils } from "../../Utils"
import { BBox } from "../../Logic/BBox"
@ -11,6 +12,8 @@ import { RasterLayerProperties } from "../../Models/RasterLayerProperties"
import * as htmltoimage from "html-to-image"
import RasterLayerHandler from "./RasterLayerHandler"
import Constants from "../../Models/Constants"
import { Protocol } from "pmtiles"
import { bool } from "sharp"
/**
* The 'MapLibreAdaptor' bridges 'MapLibre' with the various properties of the `MapProperties`
@ -23,13 +26,13 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
"dragRotate",
"dragPan",
"keyboard",
"touchZoomRotate",
"touchZoomRotate"
]
private static maplibre_zoom_handlers = [
"scrollZoom",
"boxZoom",
"doubleClickZoom",
"touchZoomRotate",
"touchZoomRotate"
]
readonly location: UIEventSource<{ lon: number; lat: number }>
readonly zoom: UIEventSource<number>
@ -46,6 +49,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
readonly pitch: UIEventSource<number>
readonly useTerrain: Store<boolean>
private static pmtilesInited = false
/**
* Functions that are called when one of those actions has happened
* @private
@ -55,6 +59,12 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
private readonly _maplibreMap: Store<MLMap>
constructor(maplibreMap: Store<MLMap>, state?: Partial<MapProperties>) {
if (!MapLibreAdaptor.pmtilesInited) {
maplibregl.addProtocol("pmtiles", new Protocol().tile)
MapLibreAdaptor.pmtilesInited = true
console.log("PM-tiles protocol added" +
"")
}
this._maplibreMap = maplibreMap
this.location = state?.location ?? new UIEventSource(undefined)
@ -103,6 +113,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
}
maplibreMap.addCallbackAndRunD((map) => {
map.on("load", () => {
self.MoveMapToCurrentLoc(self.location.data)
self.SetZoom(self.zoom.data)
@ -205,14 +216,14 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
return {
map: mlmap,
ui: new SvelteUIElement(MaplibreMap, {
map: mlmap,
map: mlmap
}),
mapproperties: new MapLibreAdaptor(mlmap),
mapproperties: new MapLibreAdaptor(mlmap)
}
}
public static prepareWmsSource(layer: RasterLayerProperties): SourceSpecification {
return RasterLayerHandler.prepareWmsSource(layer)
return RasterLayerHandler.prepareSource(layer)
}
/**
@ -275,7 +286,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
) {
const event = {
date: new Date(),
key: key,
key: key
}
for (let i = 0; i < this._onKeyNavigation.length; i++) {
@ -319,22 +330,51 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
rescaleIcons: number,
pixelRatio: number
) {
{
const allimages = element.getElementsByTagName("img")
for (const img of Array.from(allimages)) {
let isLoaded: boolean = false
while (!isLoaded) {
console.log("Waiting for image", img.src, "to load", img.complete, img.naturalWidth, img)
await Utils.waitFor(250)
isLoaded = img.complete && img.width > 0
}
}
}
const style = element.style.transform
let x = element.getBoundingClientRect().x
let y = element.getBoundingClientRect().y
element.style.transform = ""
const offset = style.match(/translate\(([-0-9]+)%, ?([-0-9]+)%\)/)
let labels =<HTMLElement[]> Array.from(element.getElementsByClassName("marker-label"))
const origLabelTransforms = labels.map(l => l.style.transform)
// We save the original width (`w`) and height (`h`) in order to restore them later on
const w = element.style.width
const h = element.style.height
const h = Number(element.style.height)
const targetW = Math.max(element.getBoundingClientRect().width * 4,
...labels.map(l => l.getBoundingClientRect().width))
const targetH = element.getBoundingClientRect().height +
Math.max(...labels.map(l => l.getBoundingClientRect().height * 2 /* A bit of buffer to catch eventual 'margin-top'*/))
// Force a wider view for icon badges
element.style.width = element.getBoundingClientRect().width * 4 + "px"
element.style.height = element.getBoundingClientRect().height + "px"
element.style.width = targetW + "px"
// Force more height to include labels
element.style.height = targetH + "px"
element.classList.add("w-full", "flex", "flex-col", "items-center")
labels.forEach(l => {
l.style.transform = ""
})
await Utils.awaitAnimationFrame()
const svgSource = await htmltoimage.toSvg(element)
const img = await MapLibreAdaptor.createImage(svgSource)
element.style.width = w
element.style.height = h
for (let i = 0; i < labels.length; i++) {
labels[i].style.transform = origLabelTransforms[i]
}
element.style.width = "" + w
element.style.height = "" + h
if (offset && rescaleIcons !== 1) {
const [_, __, relYStr] = offset
@ -346,10 +386,13 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
y *= pixelRatio
try {
drawOn.drawImage(img, x, y, img.width * rescaleIcons, img.height * rescaleIcons)
const xdiff = img.width * rescaleIcons / 2
drawOn.drawImage(img, x - xdiff, y, img.width * rescaleIcons, img.height * rescaleIcons)
} catch (e) {
console.log("Could not draw image because of", e)
}
element.classList.remove("w-full", "flex", "flex-col", "items-center")
}
/**
@ -384,19 +427,12 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
const markers = Array.from(container.getElementsByClassName("marker"))
for (let i = 0; i < markers.length; i++) {
const marker = <HTMLElement>markers[i]
const labels = Array.from(marker.getElementsByClassName("marker-label"))
const style = marker.style.transform
if (isDisplayed(marker)) {
await this.drawElement(drawOn, marker, rescaleIcons, pixelRatio)
}
for (const label of labels) {
if (isDisplayed(label)) {
await this.drawElement(drawOn, <HTMLElement>label, rescaleIcons, pixelRatio)
}
}
if (progress) {
progress.setData({ current: i, total: markers.length })
}
@ -425,7 +461,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
const bounds = map.getBounds()
const bbox = new BBox([
[bounds.getEast(), bounds.getNorth()],
[bounds.getWest(), bounds.getSouth()],
[bounds.getWest(), bounds.getSouth()]
])
if (this.bounds.data === undefined || !isSetup) {
this.bounds.setData(bbox)
@ -603,14 +639,14 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
type: "raster-dem",
url:
"https://api.maptiler.com/tiles/terrain-rgb/tiles.json?key=" +
Constants.maptilerApiKey,
Constants.maptilerApiKey
})
try {
while (!map?.isStyleLoaded()) {
await Utils.waitFor(250)
}
map.setTerrain({
source: id,
source: id
})
} catch (e) {
console.error(e)

View file

@ -1,13 +1,14 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte"
import type { Map } from "maplibre-gl"
import type { Map, MapOptions } from "maplibre-gl"
import * as maplibre from "maplibre-gl"
import type { Readable, Writable } from "svelte/store"
import { get, writable } from "svelte/store"
import type { Writable } from "svelte/store"
import { AvailableRasterLayers } from "../../Models/RasterLayers"
import { Utils } from "../../Utils"
import { ariaLabel } from "../../Utils/ariaLabel"
import Translations from "../i18n/Translations"
import type { MapProperties } from "../../Models/MapProperties"
import type { RasterLayerProperties } from "../../Models/RasterLayerProperties"
/**
* The 'MaplibreMap' maps various event sources onto MapLibre.
@ -17,40 +18,43 @@
* Beware: this map will _only_ be set by this component
* It should thus be treated as a 'store' by external parties
*/
export let map: Writable<Map>
export let map: Writable<Map> = undefined
export let mapProperties: MapProperties = undefined
export let interactive: boolean = true
let container: HTMLElement
export let center: { lng: number; lat: number } | Readable<{ lng: number; lat: number }> =
writable({ lng: 0, lat: 0 })
export let zoom: Readable<number> = writable(1)
const styleUrl = AvailableRasterLayers.maptilerDefaultLayer.properties.url
let _map: Map
onMount(() => {
let _center: { lng: number; lat: number }
if (typeof center["lng"] === "number" && typeof center["lat"] === "number") {
_center = <any>center
const { lon, lat } = mapProperties?.location?.data ?? { lon: 0, lat: 0 }
const rasterLayer: RasterLayerProperties = mapProperties?.rasterLayer?.data?.properties
let styleUrl: string
if (rasterLayer?.type === "vector") {
styleUrl = rasterLayer?.style ?? rasterLayer?.url ?? AvailableRasterLayers.defaultBackgroundLayer.properties.url
} else {
_center = get(<any>center)
const defaultLayer = AvailableRasterLayers.defaultBackgroundLayer.properties
styleUrl = defaultLayer.style ?? defaultLayer.url
}
_map = new maplibre.Map({
console.log("Initing mapLIbremap with style", styleUrl)
const options: MapOptions = {
container,
style: styleUrl,
zoom: get(zoom),
center: _center,
zoom: mapProperties?.zoom?.data ?? 1,
center: { lng: lon, lat },
maxZoom: 24,
interactive: true,
attributionControl: false,
})
attributionControl: false
}
_map = new maplibre.Map(options)
window.requestAnimationFrame(() => {
_map.resize()
})
_map.on("load", function () {
_map.on("load", function() {
_map.resize()
const canvas = _map.getCanvas()
if (interactive) {

View file

@ -74,4 +74,4 @@
style="z-index: 100">
<StyleLoadingIndicator map={altmap} />
</div>
<MaplibreMap {interactive} map={altmap} />
<MaplibreMap {interactive} map={altmap} mapProperties={altproperties} />

View file

@ -1,8 +1,9 @@
import { Map as MLMap, SourceSpecification } from "maplibre-gl"
import { Map as MLMap, RasterSourceSpecification, VectorTileSource } from "maplibre-gl"
import { Store, Stores, UIEventSource } from "../../Logic/UIEventSource"
import { RasterLayerPolygon } from "../../Models/RasterLayers"
import { AvailableRasterLayers, RasterLayerPolygon } from "../../Models/RasterLayers"
import { RasterLayerProperties } from "../../Models/RasterLayerProperties"
import { Utils } from "../../Utils"
import { VectorSourceSpecification } from "@maplibre/maplibre-gl-style-spec"
class SingleBackgroundHandler {
// Value between 0 and 1.0
@ -17,6 +18,7 @@ class SingleBackgroundHandler {
*/
public static readonly DEACTIVATE_AFTER = 60
private fadeStep = 0.1
constructor(
map: Store<MLMap>,
targetLayer: RasterLayerPolygon,
@ -75,6 +77,7 @@ class SingleBackgroundHandler {
this.fadeIn()
}
}
private async awaitStyleIsLoaded(): Promise<void> {
const map = this._map.data
if (!map) {
@ -85,11 +88,11 @@ class SingleBackgroundHandler {
}
}
private async enable(){
private async enable() {
let ttl = 15
await this.awaitStyleIsLoaded()
while(!this.tryEnable() && ttl > 0){
ttl --;
while (!this.tryEnable() && ttl > 0) {
ttl--
await Utils.waitFor(250)
}
}
@ -105,14 +108,19 @@ class SingleBackgroundHandler {
}
const background = this._targetLayer.properties
console.debug("Enabling", background.id)
let addLayerBeforeId = "aeroway_fill" // this is the first non-landuse item in the stylesheet, we add the raster layer before the roads but above the landuse
let addLayerBeforeId = "transit_pier" // this is the first non-landuse item in the stylesheet, we add the raster layer before the roads but above the landuse
if(!map.getLayer(addLayerBeforeId)){
console.warn("Layer", addLayerBeforeId,"not foundhttp://127.0.0.1:1234/theme.html?layout=cyclofix&z=14.8&lat=51.05282501324558&lon=3.720591622281745&layer-range=true")
addLayerBeforeId = undefined
}
if (background.category === "osmbasedmap" || background.category === "map") {
// The background layer is already an OSM-based map or another map, so we don't want anything from the baselayer
addLayerBeforeId = undefined
}
if (!map.getSource(background.id)) {
try {
map.addSource(background.id, RasterLayerHandler.prepareWmsSource(background))
map.addSource(background.id, RasterLayerHandler.prepareSource(background))
} catch (e) {
return false
}
@ -122,21 +130,30 @@ class SingleBackgroundHandler {
.getStyle()
.layers.find((l) => l.id.startsWith("mapcomplete_"))?.id
map.addLayer(
{
id: background.id,
type: "raster",
source: background.id,
paint: {
"raster-opacity": 0,
if (background.type === "vector") {
const styleToSet = background.style ?? background.url
map.setStyle(styleToSet)
} else {
map.addLayer(
{
id: background.id,
type: "raster",
source: background.id,
paint: {
"raster-opacity": 0
}
},
},
addLayerBeforeId
)
this.opacity.addCallbackAndRun((o) => {
map.setPaintProperty(background.id, "raster-opacity", o)
})
addLayerBeforeId
)
this.opacity.addCallbackAndRun((o) => {
try{
map.setPaintProperty(background.id, "raster-opacity", o)
}catch (e) {
console.debug("Could not set raster-opacity of", background.id)
return true // This layer probably doesn't exist anymore, so we unregister
}
})
}
}
return true
}
@ -168,7 +185,14 @@ export default class RasterLayerHandler {
})
}
public static prepareWmsSource(layer: RasterLayerProperties): SourceSpecification {
public static prepareSource(layer: RasterLayerProperties): RasterSourceSpecification | VectorSourceSpecification {
if (layer.type === "vector") {
const vs: VectorSourceSpecification = {
type: "vector",
url: layer.url
}
return vs
}
return {
type: "raster",
// use the tiles option to specify a 256WMS tile source URL
@ -178,7 +202,7 @@ export default class RasterLayerHandler {
minzoom: layer["min_zoom"] ?? 1,
maxzoom: layer["max_zoom"] ?? 25,
// Bit of a hack, but seems to work
scheme: layer.url.includes("{-y}") ? "tms" : "xyz",
scheme: layer.url.includes("{-y}") ? "tms" : "xyz"
}
}
@ -192,7 +216,7 @@ export default class RasterLayerHandler {
"{width}": "" + size,
"{height}": "" + size,
"{zoom}": "{z}",
"{-y}": "{y}",
"{-y}": "{y}"
}
for (const key in toReplace) {

View file

@ -5,7 +5,6 @@
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { Map as MlMap } from "maplibre-gl"
import { createEventDispatcher, onDestroy } from "svelte"
import StyleLoadingIndicator from "./StyleLoadingIndicator.svelte"
/***
* Chooses a background-layer out of available options

View file

@ -154,7 +154,7 @@ class PointRenderingLayer {
if (this._onClick) {
const self = this
el.addEventListener("click", function (ev) {
el.addEventListener("click", function(ev) {
ev.preventDefault()
self._onClick(feature)
// Workaround to signal the MapLibreAdaptor to ignore this click
@ -200,7 +200,7 @@ class LineRenderingLayer {
"lineCap",
"offset",
"fill",
"fillColor",
"fillColor"
] as const
private static readonly lineConfigKeysColor = ["color", "fillColor"] as const
@ -249,16 +249,8 @@ class LineRenderingLayer {
imageAlongWay.map(async (img, i) => {
const imgId = img.then.replaceAll(/[/.-]/g, "_")
if (map.getImage(imgId) === undefined) {
await new Promise<void>((resolve, reject) => {
map.loadImage(img.then, (err, image) => {
if (err) {
console.error("Could not add symbol layer to line due to", err)
return
}
map.addImage(imgId, image)
resolve()
})
})
const loadedImage = await map.loadImage(img.then)
map.addImage(imgId, loadedImage.data)
}
const spec: AddLayerObject = {
@ -272,11 +264,10 @@ class LineRenderingLayer {
"icon-rotation-alignment": "map",
"icon-pitch-alignment": "map",
"icon-image": imgId,
"icon-size": 0.055,
},
"icon-size": 0.055
}
}
const filter = img.if?.asMapboxExpression()
console.log(">>>", this._layername, imgId, img.if, "-->", filter)
if (filter) {
spec.filter = filter
}
@ -347,12 +338,12 @@ class LineRenderingLayer {
type: "geojson",
data: {
type: "FeatureCollection",
features,
features
},
promoteId: "id",
promoteId: "id"
})
const linelayer = this._layername + "_line"
map.addLayer({
const layer: AddLayerObject = {
source: this._layername,
id: linelayer,
type: "line",
@ -360,12 +351,17 @@ class LineRenderingLayer {
"line-color": ["feature-state", "color"],
"line-opacity": ["feature-state", "color-opacity"],
"line-width": ["feature-state", "width"],
"line-offset": ["feature-state", "offset"],
"line-offset": ["feature-state", "offset"]
},
layout: {
"line-cap": "round",
},
})
"line-cap": "round"
}
}
if (this._config.dashArray) {
layer.paint["line-dasharray"] = this._config.dashArray?.split(" ")?.map(s => Number(s)) ?? null
}
map.addLayer(layer)
if (this._config.imageAlongWay) {
this.addSymbolLayer(this._layername, this._config.imageAlongWay)
@ -397,8 +393,8 @@ class LineRenderingLayer {
layout: {},
paint: {
"fill-color": ["feature-state", "fillColor"],
"fill-opacity": ["feature-state", "fillColor-opacity"],
},
"fill-opacity": ["feature-state", "fillColor-opacity"]
}
})
if (this._onClick) {
map.on("click", polylayer, (e) => {
@ -429,7 +425,7 @@ class LineRenderingLayer {
this.currentSourceData = features
src.setData({
type: "FeatureCollection",
features: this.currentSourceData,
features: this.currentSourceData
})
}
}
@ -513,14 +509,14 @@ export default class ShowDataLayer {
layers.filter((l) => l.source !== null).map((l) => new FilteredLayer(l)),
features,
{
constructStore: (features, layer) => new SimpleFeatureSource(layer, features),
constructStore: (features, layer) => new SimpleFeatureSource(layer, features)
}
)
perLayer.forEach((fs) => {
new ShowDataLayer(mlmap, {
layer: fs.layer.layerDef,
features: fs,
...(options ?? {}),
...(options ?? {})
})
})
}
@ -533,11 +529,12 @@ export default class ShowDataLayer {
return new ShowDataLayer(map, {
layer: ShowDataLayer.rangeLayer,
features,
doShowLayer,
doShowLayer
})
}
public destruct() {}
public destruct() {
}
private zoomToCurrentFeatures(map: MlMap) {
if (this._options.zoomToFeatures) {
@ -546,21 +543,21 @@ export default class ShowDataLayer {
map.resize()
map.fitBounds(bbox.toLngLat(), {
padding: { top: 10, bottom: 10, left: 10, right: 10 },
animate: false,
animate: false
})
}
}
private initDrawFeatures(map: MlMap) {
let { features, doShowLayer, fetchStore, selectedElement, selectedLayer } = this._options
const onClick =
this._options.onClick ??
(this._options.layer.title === undefined
let { features, doShowLayer, fetchStore, selectedElement } = this._options
let onClick = this._options.onClick
if (!onClick && selectedElement) {
onClick = (this._options.layer.title === undefined
? undefined
: (feature: Feature) => {
selectedElement?.setData(feature)
selectedLayer?.setData(this._options.layer)
})
selectedElement?.setData(feature)
})
}
if (this._options.drawLines !== false) {
for (let i = 0; i < this._options.layer.lineRendering.length; i++) {
const lineRenderingConfig = this._options.layer.lineRendering[i]

View file

@ -0,0 +1,29 @@
<script lang="ts">
import Translations from "../i18n/Translations.js";
import Min from "../../assets/svg/Min.svelte";
import MapControlButton from "../Base/MapControlButton.svelte";
import Plus from "../../assets/svg/Plus.svelte";
import type { MapProperties } from "../../Models/MapProperties"
export let adaptor: MapProperties
let canZoomIn = adaptor.maxzoom.map(mz => adaptor.zoom.data < mz, [adaptor.zoom] )
let canZoomOut = adaptor.minzoom.map(mz => adaptor.zoom.data > mz, [adaptor.zoom] )
</script>
<div class="absolute bottom-0 right-0 pointer-events-none flex flex-col">
<MapControlButton
enabled={canZoomIn}
cls="m-0.5 p-1"
arialabel={Translations.t.general.labels.zoomIn}
on:click={() => adaptor.zoom.update((z) => z + 1)}
>
<Plus class="h-5 w-5" />
</MapControlButton>
<MapControlButton
enabled={canZoomOut}
cls={"m-0.5 p-1"}
arialabel={Translations.t.general.labels.zoomOut}
on:click={() => adaptor.zoom.update((z) => z - 1)}
>
<Min class="h-5 w-5" />
</MapControlButton>
</div>

View file

@ -6,14 +6,30 @@
let isLoading = false
export let map: UIEventSource<MlMap>
/**
* Optional. Only used for the 'global' change indicator so that it won't spin on pan/zoom but only when a change _actually_ occured
*/
export let rasterLayer: UIEventSource<any> = undefined
let didChange = undefined
onDestroy(rasterLayer?.addCallback(() => {
didChange = true
}) ??( () => {}))
onDestroy(Stores.Chronic(250).addCallback(
() => {
isLoading = !map.data?.isStyleLoaded()
const mapIsLoading = !map.data?.isStyleLoaded()
isLoading = mapIsLoading && (didChange || rasterLayer === undefined)
if(didChange && !mapIsLoading){
didChange = false
}
},
))
</script>
{#if isLoading}
<Loading />
<Loading cls="h-6 w-6" />
{:else}
<slot />
{/if}

View file

@ -364,7 +364,7 @@
</div>
</div>
{:else}
<Loading>Creating point...</Loading>
<Loading><Tr t={Translations.t.general.add.creating}/> </Loading>
{/if}
</div>
</LoginToggle>

View file

@ -110,7 +110,7 @@ class ApplyButton extends UIElement {
mla.allowZooming.setData(false)
mla.allowMoving.setData(false)
const previewMap = new SvelteUIElement(MaplibreMap, { map: mlmap }).SetClass("h-48")
const previewMap = new SvelteUIElement(MaplibreMap, { mapProperties: mla, map: mlmap }).SetClass("h-48")
const features = this.target_feature_ids.map((id) =>
this.state.indexedFeatures.featuresById.data.get(id)

View file

@ -48,10 +48,10 @@
<ImportFlow {importFlow} on:confirm={() => importFlow.onConfirm()}>
<div slot="map" class="relative">
<div class="h-32">
<MaplibreMap {map} />
<MaplibreMap {map} mapProperties={mla} />
</div>
<div class="absolute bottom-0">
<OpenBackgroundSelectorButton />
<OpenBackgroundSelectorButton {state} {map} />
</div>
</div>
</ImportFlow>

View file

@ -109,7 +109,7 @@ export class MinimapViz implements SpecialVisualization {
state.layout.layers
)
return new SvelteUIElement(MaplibreMap, { interactive: false, map: mlmap })
return new SvelteUIElement(MaplibreMap, { interactive: false, map: mlmap, mapProperties: mla })
.SetClass("h-40 rounded")
.SetStyle("overflow: hidden; pointer-events: none;")
}

View file

@ -130,6 +130,16 @@ export class MoveWizardState {
this.moveDisallowedReason.setData(t.partOfRelation)
}
})
} else {
// This is a new point. Check if it was snapped to an existing way due to the '_referencing_ways'-tag
const store = this._state.featureProperties.getStore(id)
store?.addCallbackAndRunD((tags) => {
if (tags._referencing_ways !== undefined && tags._referencing_ways !== "[]") {
console.log("Got referencing ways according to the tags")
this.moveDisallowedReason.setData(t.partOfAWay)
return true
}
})
}
}
}

View file

@ -0,0 +1,112 @@
<script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource"
import type { Feature, Point } from "geojson"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import LoginToggle from "../Base/LoginToggle.svelte"
import Tr from "../Base/Tr.svelte"
import Scissors from "../../assets/svg/Scissors.svelte"
import WaySplitMap from "../BigComponents/WaySplitMap.svelte"
import BackButton from "../Base/BackButton.svelte"
import SplitAction from "../../Logic/Osm/Actions/SplitAction"
import Translations from "../i18n/Translations"
import NextButton from "../Base/NextButton.svelte"
import Loading from "../Base/Loading.svelte"
import { OsmWay } from "../../Logic/Osm/OsmObject"
import type { WayId } from "../../Models/OsmFeature"
import { Utils } from "../../Utils"
export let state: SpecialVisualizationState
export let id: WayId
const t = Translations.t.split
let step: "initial" | "loading_way" | "splitting" | "applying_split" | "has_been_split" | "deleted" = "initial"
// Contains the points on the road that are selected to split on - contains geojson points with extra properties such as 'location' with the distance along the linestring
let splitPoints = new UIEventSource<Feature<
Point,
{
id: number
index: number
dist: number
location: number
}
>[]>([])
let splitpointsNotEmpty = splitPoints.map(sp => sp.length > 0)
let osmWay: OsmWay
async function downloadWay() {
step = "loading_way"
const dloaded = await state.osmObjectDownloader.DownloadObjectAsync(id)
if (dloaded === "deleted") {
step = "deleted"
return
}
osmWay = dloaded
step = "splitting"
}
async function doSplit() {
step = "applying_split"
const splitAction = new SplitAction(
id,
splitPoints.data.map((ff) => <[number, number]>(<Point>ff.geometry).coordinates),
{
theme: state?.layout?.id,
},
5,
)
await state.changes?.applyAction(splitAction)
// We throw away the old map and splitpoints, and create a new map from scratch
splitPoints.setData([])
// Close the popup. The contributor has to select a segment again to make sure they continue editing the correct segment; see #1219
state.selectedElement?.setData(undefined)
step = "has_been_split"
}
</script>
<LoginToggle ignoreLoading={true} {state}>
<Tr slot="not-logged-in" t={t.loginToSplit} />
{#if step === "deleted"}
<!-- Empty -->
{:else if step === "initial"}
<button on:click={() => downloadWay()}>
<Scissors class="w-6 h-6 shrink-0" />
<Tr t={t.inviteToSplit} />
</button>
{:else if step === "loading_way"}
<Loading />
{:else if step === "splitting"}
<div class="flex flex-col interactive border-interactive p-2">
<div class="w-full h-80">
<WaySplitMap {state} {splitPoints} {osmWay} />
</div>
<div class="flex flex-wrap-reverse md:flex-nowrap w-full">
<BackButton clss="w-full" on:click={() => {
splitPoints.set([])
step = "initial"
}}>
<Tr t={Translations.t.general.cancel} />
</BackButton>
<NextButton clss={ ($splitpointsNotEmpty ? "": "disabled ") + "w-full primary"} on:click={() => doSplit()}>
<Tr t={t.split} />
</NextButton>
</div>
</div>
{:else if step === "has_been_split"}
<Tr cls="thanks" t={ t.hasBeenSplit.Clone().SetClass("font-bold thanks block w-full")} />
<button on:click={() => downloadWay()}>
<Scissors class="w-6 h-6" />
<Tr t={t.splitAgain} />
</button>
{/if}
</LoginToggle>

View file

@ -1,147 +0,0 @@
import Toggle from "../Input/Toggle"
import { UIEventSource } from "../../Logic/UIEventSource"
import { SubtleButton } from "../Base/SubtleButton"
import Combine from "../Base/Combine"
import { Button } from "../Base/Button"
import Translations from "../i18n/Translations"
import SplitAction from "../../Logic/Osm/Actions/SplitAction"
import Title from "../Base/Title"
import BaseUIElement from "../BaseUIElement"
import { VariableUiElement } from "../Base/VariableUIElement"
import { LoginToggle } from "./LoginButton"
import SvelteUIElement from "../Base/SvelteUIElement"
import WaySplitMap from "../BigComponents/WaySplitMap.svelte"
import { Feature, Point } from "geojson"
import { WayId } from "../../Models/OsmFeature"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { Changes } from "../../Logic/Osm/Changes"
import { IndexedFeatureSource } from "../../Logic/FeatureSource/FeatureSource"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import OsmObjectDownloader from "../../Logic/Osm/OsmObjectDownloader"
import Scissors from "../../assets/svg/Scissors.svelte"
export default class SplitRoadWizard extends Combine {
public dialogIsOpened: UIEventSource<boolean>
/**
* A UI Element used for splitting roads
*
* @param id The id of the road to remove
* @param state the state of the application
*/
constructor(
id: WayId,
state: {
layout?: LayoutConfig
osmConnection?: OsmConnection
osmObjectDownloader?: OsmObjectDownloader
changes?: Changes
indexedFeatures?: IndexedFeatureSource
selectedElement?: UIEventSource<Feature>
}
) {
const t = Translations.t.split
// Contains the points on the road that are selected to split on - contains geojson points with extra properties such as 'location' with the distance along the linestring
const splitPoints = new UIEventSource<Feature<Point>[]>([])
const hasBeenSplit = new UIEventSource(false)
// Toggle variable between show split button and map
const splitClicked = new UIEventSource<boolean>(false)
const leafletMap = new UIEventSource<BaseUIElement>(undefined)
function initMap() {
;(async function (
id: WayId,
splitPoints: UIEventSource<Feature[]>
): Promise<BaseUIElement> {
return new SvelteUIElement(WaySplitMap, {
osmWay: await state.osmObjectDownloader.DownloadObjectAsync(id),
splitPoints,
})
})(id, splitPoints).then((mapComponent) =>
leafletMap.setData(mapComponent.SetClass("w-full h-80"))
)
}
// Toggle between splitmap
const splitButton = new SubtleButton(
new SvelteUIElement(Scissors).SetClass("h-6 w-6"),
new Toggle(
t.splitAgain.Clone().SetClass("text-lg font-bold"),
t.inviteToSplit.Clone().SetClass("text-lg font-bold"),
hasBeenSplit
)
)
const splitToggle = new LoginToggle(splitButton, t.loginToSplit.Clone(), state)
// Save button
const saveButton = new Button(t.split.Clone(), async () => {
hasBeenSplit.setData(true)
splitClicked.setData(false)
const splitAction = new SplitAction(
id,
splitPoints.data.map((ff) => <[number, number]>(<Point>ff.geometry).coordinates),
{
theme: state?.layout?.id,
},
5
)
await state.changes?.applyAction(splitAction)
// We throw away the old map and splitpoints, and create a new map from scratch
splitPoints.setData([])
// Close the popup. The contributor has to select a segment again to make sure they continue editing the correct segment; see #1219
state.selectedElement?.setData(undefined)
})
saveButton.SetClass("btn btn-primary mr-3")
const disabledSaveButton = new Button(t.split.Clone(), undefined)
disabledSaveButton.SetClass("btn btn-disabled mr-3")
// Only show the save button if there are split points defined
const saveToggle = new Toggle(
disabledSaveButton,
saveButton,
splitPoints.map((data) => data.length === 0)
)
const cancelButton = Translations.t.general.cancel
.Clone() // Not using Button() element to prevent full width button
.SetClass("btn btn-secondary mr-3")
.onClick(() => {
splitPoints.setData([])
splitClicked.setData(false)
})
cancelButton.SetClass("btn btn-secondary block")
const splitTitle = new Title(t.splitTitle)
const mapView = new Combine([
splitTitle,
new VariableUiElement(leafletMap),
new Combine([cancelButton, saveToggle]).SetClass("flex flex-row"),
])
mapView.SetClass("question")
super([
Toggle.If(hasBeenSplit, () =>
t.hasBeenSplit.Clone().SetClass("font-bold thanks block w-full")
),
new Toggle(mapView, splitToggle, splitClicked),
])
splitClicked.addCallback((view) => {
if (view) {
initMap()
}
})
this.dialogIsOpened = splitClicked
const self = this
splitButton.onClick(() => {
splitClicked.setData(true)
self.ScrollIntoView()
})
}
}

View file

@ -24,7 +24,7 @@
</script>
{#if !userDetails || $userDetails.loggedIn}
<div>
<div class="break-words" style="word-break: break-word">
{#if tags === undefined}
<slot name="no-tags"><Tr cls="subtle" t={Translations.t.general.noTagsSelected} /></slot>
{:else if embedIn === undefined}

View file

@ -76,6 +76,5 @@
{value}
{state}
on:submit
{unvalidatedText}
/>
</div>

View file

@ -4,7 +4,7 @@
* The questions can either be shown all at once or one at a time (in which case they can be skipped)
*/
import TagRenderingConfig from "../../../Models/ThemeConfig/TagRenderingConfig"
import { UIEventSource } from "../../../Logic/UIEventSource"
import { Store, UIEventSource } from "../../../Logic/UIEventSource"
import type { Feature } from "geojson"
import type { SpecialVisualizationState } from "../../SpecialVisualization"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
@ -12,6 +12,7 @@
import Tr from "../../Base/Tr.svelte"
import Translations from "../../i18n/Translations.js"
import { Utils } from "../../../Utils"
import { onDestroy } from "svelte"
export let layer: LayerConfig
export let tags: UIEventSource<Record<string, string>>
@ -67,8 +68,25 @@
},
[skippedQuestions]
)
let firstQuestion = questionsToAsk.map((qta) => qta[0])
let firstQuestion: UIEventSource<TagRenderingConfig> = new UIEventSource<TagRenderingConfig>(undefined)
let allQuestionsToAsk : UIEventSource<TagRenderingConfig[]> = new UIEventSource<TagRenderingConfig[]>([])
function calculateQuestions(){
console.log("Applying questions to ask")
const qta = questionsToAsk.data
firstQuestion.setData(undefined)
firstQuestion.setData(qta[0])
allQuestionsToAsk.setData([])
allQuestionsToAsk.setData(qta)
}
onDestroy(questionsToAsk.addCallback(() =>calculateQuestions()))
onDestroy(showAllQuestionsAtOnce.addCallback(() => calculateQuestions()))
calculateQuestions()
let answered: number = 0
let skipped: number = 0
@ -92,7 +110,7 @@
class="marker-questionbox-root"
class:hidden={$questionsToAsk.length === 0 && skipped === 0 && answered === 0}
>
{#if $questionsToAsk.length === 0}
{#if $allQuestionsToAsk.length === 0}
{#if skipped + answered > 0}
<div class="thanks">
<Tr t={Translations.t.general.questionBox.done} />
@ -140,11 +158,11 @@
<div>
{#if $showAllQuestionsAtOnce}
<div class="flex flex-col gap-y-1">
{#each $questionsToAsk as question (question.id)}
{#each $allQuestionsToAsk as question (question.id)}
<TagRenderingQuestion config={question} {tags} {selectedElement} {state} {layer} />
{/each}
</div>
{:else}
{:else if $firstQuestion !== undefined}
<TagRenderingQuestion
config={$firstQuestion}
{layer}

View file

@ -30,6 +30,7 @@
import { placeholder } from "../../../Utils/placeholder"
import { TrashIcon } from "@rgossiaux/svelte-heroicons/solid"
import { Tag } from "../../../Logic/Tags/Tag"
import { get, writable } from "svelte/store"
export let config: TagRenderingConfig
export let tags: UIEventSource<Record<string, string>>
@ -46,7 +47,7 @@
// Will be bound if a freeform is available
let freeformInput = new UIEventSource<string>(tags?.[config.freeform?.key])
let freeformInputUnvalidated = new UIEventSource<string>(freeformInput.data)
let freeformInputUnvalidated = new UIEventSource<string>(get(freeformInput))
let selectedMapping: number = undefined
/**
@ -112,7 +113,7 @@
unseenFreeformValues.splice(index, 1)
}
// TODO this has _to much_ values
freeformInput.setData(unseenFreeformValues.join(";"))
freeformInput.set(unseenFreeformValues.join(";"))
if (checkedMappings.length + 1 < mappings.length) {
checkedMappings.push(unseenFreeformValues.length > 0)
}
@ -121,10 +122,10 @@
if (confg.freeform?.key) {
if (!confg.multiAnswer) {
// Somehow, setting multi-answer freeform values is broken if this is not set
freeformInput.setData(tgs[confg.freeform.key])
freeformInput.set(tgs[confg.freeform.key])
}
} else {
freeformInput.setData(undefined)
freeformInput.set(undefined)
}
feedback.setData(undefined)
}
@ -134,8 +135,8 @@
// We want to (re)-initialize whenever the 'tags' or 'config' change - but not when 'checkedConfig' changes
initialize($tags, config)
}
freeformInput.addCallbackAndRun((freeformValue) => {
onDestroy(
freeformInput.subscribe((freeformValue) => {
if (!mappings || mappings?.length == 0 || config.freeform?.key === undefined) {
return
}
@ -151,7 +152,8 @@
if (freeformValue?.length > 0) {
selectedMapping = mappings.length
}
})
}))
$: {
if (
allowDeleteOfFreeform &&
@ -202,7 +204,7 @@
theme: tags.data["_orig_theme"] ?? state.layout.id,
changeType: "answer",
})
freeformInput.setData(undefined)
freeformInput.set(undefined)
selectedMapping = undefined
selectedTags = undefined
@ -241,7 +243,7 @@
<form
class="interactive border-interactive relative flex flex-col overflow-y-auto px-2"
style="max-height: 75vh"
on:submit|preventDefault={() => onSave()}
on:submit|preventDefault={() =>{ /*onSave(); This submit is not needed and triggers to early, causing bugs: see #1808*/}}
>
<fieldset>
<legend>
@ -285,7 +287,7 @@
feature={selectedElement}
value={freeformInput}
unvalidatedText={freeformInputUnvalidated}
on:submit={onSave}
on:submit={() => onSave()}
/>
{:else if mappings !== undefined && !config.multiAnswer}
<!-- Simple radiobuttons as mapping -->
@ -329,7 +331,7 @@
value={freeformInput}
unvalidatedText={freeformInputUnvalidated}
on:selected={() => (selectedMapping = config.mappings?.length)}
on:submit={onSave}
on:submit={() => onSave()}
/>
</label>
{/if}
@ -372,7 +374,7 @@
feature={selectedElement}
value={freeformInput}
unvalidatedText={freeformInputUnvalidated}
on:submit={onSave}
on:submit={() => onSave()}
/>
</label>
{/if}
@ -397,13 +399,13 @@
<slot name="cancel" />
<slot name="save-button" {selectedTags}>
{#if allowDeleteOfFreeform && (mappings?.length ?? 0) === 0 && $freeformInput === undefined && $freeformInputUnvalidated === ""}
<button class="primary flex" on:click|stopPropagation|preventDefault={onSave}>
<button class="primary flex" on:click|stopPropagation|preventDefault={() => onSave()}>
<TrashIcon class="h-6 w-6 text-red-500" />
<Tr t={Translations.t.general.eraseValue} />
</button>
{:else}
<button
on:click={onSave}
on:click={() => onSave()}
class={twJoin(
selectedTags === undefined ? "disabled" : "button-shadow",
"primary"

View file

@ -1,31 +0,0 @@
let lang = (
(navigator.languages && navigator.languages[0]) ||
navigator.language ||
navigator["userLanguage"] ||
"en"
).substr(0, 2)
function filterLangs(maindiv) {
let foundLangs = 0
for (const child of Array.from(maindiv.children)) {
if (child.attributes.getNamedItem("lang")?.value === lang) {
foundLangs++
}
}
if (foundLangs === 0) {
lang = "en"
}
for (const child of Array.from(maindiv.children)) {
const childLang = child.attributes.getNamedItem("lang")
if (childLang === undefined) {
continue
}
if (childLang.value === lang) {
continue
}
child.parentElement.removeChild(child)
}
}
filterLangs(document.getElementById("descriptions-while-loading"))
filterLangs(document.getElementById("default-title"))

View file

@ -0,0 +1,32 @@
export {}
let lang = (
(navigator.languages && navigator.languages[0]) ||
navigator.language ||
navigator["userLanguage"] ||
"en"
).substr(0, 2)
function filterLangs(maindiv) {
let foundLangs = 0
for (const child of Array.from(maindiv.children)) {
if (child.attributes.getNamedItem("lang")?.value === lang) {
foundLangs++
}
}
if (foundLangs === 0) {
lang = "en"
}
for (const child of Array.from(maindiv.children)) {
const childLang = child.attributes.getNamedItem("lang")
if (childLang === undefined) {
continue
}
if (childLang.value === lang) {
continue
}
child.parentElement.removeChild(child)
}
}
filterLangs(document.getElementById("descriptions-while-loading"))
filterLangs(document.getElementById("default-title"))

View file

@ -61,17 +61,12 @@
opinion: opinion.data,
metadata: { nickname, is_affiliated: isAffiliated.data },
}
if (state.featureSwitchIsTesting?.data ?? true) {
console.log("Testing - not actually saving review", review)
await Utils.waitFor(1000)
} else {
try {
await reviews.createReview(review)
} catch (e) {
console.error("Could not create review due to", e)
uploadFailed = "" + e
}
}
_state = "done"
}
</script>

View file

@ -6,12 +6,15 @@
import LoginButton from "../Base/LoginButton.svelte"
import SingleReview from "./SingleReview.svelte"
import Mangrove_logo from "../../assets/svg/Mangrove_logo.svelte"
import Loading from "../Base/Loading.svelte"
/**
* A panel showing all the reviews by the logged-in user
*/
export let state: SpecialVisualizationState
let reviews = state.userRelatedState.mangroveIdentity.getAllReviews()
let allReviews = state.userRelatedState.mangroveIdentity.getAllReviews()
let reviews = state.userRelatedState.mangroveIdentity.getGeoReviews()
let kid = state.userRelatedState.mangroveIdentity.getKeyId()
const t = Translations.t.reviews
</script>
@ -22,23 +25,42 @@
</LoginButton>
</div>
{#if $reviews?.length > 0}
<div class="flex flex-col" on:keypress={(e) => console.log("Got keypress", e)}>
{#each $reviews as review (review.sub)}
<SingleReview {review} showSub={true} {state} />
{/each}
</div>
{#if $reviews === undefined}
<Loading />
{:else}
<Tr t={t.your_reviews_empty} />
{#if $reviews?.length > 0}
<div class="flex flex-col gap-y-1" on:keypress={(e) => console.log("Got keypress", e)}>
{#each $reviews as review (review.sub)}
<SingleReview {review} showSub={true} {state} />
{/each}
</div>
{:else}
<Tr t={t.your_reviews_empty} />
{/if}
{#if $allReviews?.length > $reviews?.length}
{#if $allReviews?.length - $reviews?.length === 1}
<Tr t={t.non_place_review} />
{:else}
<Tr t={t.non_place_reviews.Subs({n:$allReviews?.length - $reviews?.length })} />
{/if}
<a target="_blank"
class="link-underline"
rel="noopener nofollow"
href={`https://mangrove.reviews/list?kid=${encodeURIComponent($kid)}`}
>
<Tr t={t.see_all} />
</a>
{/if}
<a
class="link-underline"
href="https://github.com/pietervdvn/MapComplete/issues/1782"
target="_blank"
rel="noopener noreferrer"
>
<Tr t={t.reviews_bug} />
</a>
{/if}
<a
class="link-underline"
href="https://github.com/pietervdvn/MapComplete/issues/1782"
target="_blank"
rel="noopener noreferrer"
>
<Tr t={t.reviews_bug} />
</a>
<div class="flex justify-end">
<Mangrove_logo class="h-12 w-12 shrink-0 p-1" />
<Tr cls="text-sm subtle" t={t.attribution} />

View file

@ -22,6 +22,8 @@ import { ProvidedImage } from "../Logic/ImageProviders/ImageProvider"
import GeoLocationHandler from "../Logic/Actors/GeoLocationHandler"
import { SummaryTileSourceRewriter } from "../Logic/FeatureSource/TiledFeatureSource/SummaryTileSource"
import LayoutSource from "../Logic/FeatureSource/Sources/LayoutSource"
import { Map as MlMap } from "maplibre-gl"
import ShowDataLayer from "./Map/ShowDataLayer"
/**
* The state needed to render a special Visualisation.
@ -86,6 +88,8 @@ export interface SpecialVisualizationState {
readonly previewedImage: UIEventSource<ProvidedImage>
readonly geolocation: GeoLocationHandler
showCurrentLocationOn(map: Store<MlMap>): ShowDataLayer
}
export interface SpecialVisualization {

View file

@ -42,8 +42,6 @@ import AddNewPoint from "./Popup/AddNewPoint/AddNewPoint.svelte"
import UserProfile from "./BigComponents/UserProfile.svelte"
import LayerConfig from "../Models/ThemeConfig/LayerConfig"
import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig"
import { WayId } from "../Models/OsmFeature"
import SplitRoadWizard from "./Popup/SplitRoadWizard"
import { ExportAsGpxViz } from "./Popup/ExportAsGpxViz"
import WikipediaPanel from "./Wikipedia/WikipediaPanel.svelte"
import TagRenderingEditable from "./Popup/TagRendering/TagRenderingEditable.svelte"
@ -90,6 +88,7 @@ import LoginButton from "./Base/LoginButton.svelte"
import Toggle from "./Input/Toggle"
import ImportReviewIdentity from "./Reviews/ImportReviewIdentity.svelte"
import LinkedDataLoader from "../Logic/Web/LinkedDataLoader"
import SplitRoadWizard from "./Popup/SplitRoadWizard.svelte"
class NearbyImageVis implements SpecialVisualization {
// Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests
@ -429,7 +428,7 @@ export default class SpecialVisualizations {
.map((tags) => tags.id)
.map((id) => {
if (id.startsWith("way/")) {
return new SplitRoadWizard(<WayId>id, state)
return new SvelteUIElement(SplitRoadWizard, { id, state })
}
return undefined
}),
@ -666,6 +665,7 @@ export default class SpecialVisualizations {
nameKey: nameKey,
fallbackName,
},
state.featureSwitchIsTesting
)
return new SvelteUIElement(StarsBarIcon, {
score: reviews.average,
@ -699,6 +699,7 @@ export default class SpecialVisualizations {
nameKey: nameKey,
fallbackName,
},
state.featureSwitchIsTesting
)
return new SvelteUIElement(ReviewForm, { reviews, state, tags, feature, layer })
},
@ -731,6 +732,7 @@ export default class SpecialVisualizations {
nameKey: nameKey,
fallbackName,
},
state.featureSwitchIsTesting
)
return new SvelteUIElement(AllReviews, { reviews, state, tags, feature, layer })
},
@ -750,7 +752,7 @@ export default class SpecialVisualizations {
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig,
layer: LayerConfig
): BaseUIElement {
const [text] = argument
return new SvelteUIElement(ImportReviewIdentity, { state, text })
@ -1151,10 +1153,11 @@ export default class SpecialVisualizations {
constr: (state) => {
return new Combine(
state.layout.layers
.filter((l) => l.name !== null)
.filter((l) => l.name !== null && l.title && state.perLayer.get(l.id) !== undefined )
.map(
(l) => {
const fs = state.perLayer.get(l.id)
console.log(">>>", l.id, fs)
const bbox = state.mapProperties.bounds
const fsBboxed = new BBoxFeatureSourceForLayer(fs, bbox)
return new StatisticsPanel(fsBboxed)
@ -1596,6 +1599,9 @@ export default class SpecialVisualizations {
feature: Feature,
layer: LayerConfig,
): BaseUIElement {
const smallSize = 100
const bigSize = 200
const size = new UIEventSource(smallSize)
return new VariableUiElement(
tagSource
.map((tags) => tags.id)
@ -1615,11 +1621,17 @@ export default class SpecialVisualizations {
const url =
`${window.location.protocol}//${window.location.host}${window.location.pathname}?${layout}lat=${lat}&lon=${lon}&z=15` +
`#${id}`
return new Img(new Qr(url).toImageElement(75)).SetStyle(
"width: 75px",
return new Img(new Qr(url).toImageElement(size.data)).SetStyle(
`width: ${size.data}px`
)
}),
)
}, [size])
).onClick(()=> {
if(size.data !== bigSize){
size.setData(bigSize)
}else{
size.setData(smallSize)
}
})
},
},
{

View file

@ -42,6 +42,7 @@
)
let osmConnection = new OsmConnection({
oauth_token,
checkOnlineRegularly: true
})
const expertMode = UIEventSource.asBoolean(
osmConnection.GetPreference("studio-expert-mode", "false", {

View file

@ -2,4 +2,4 @@
</script>
No tests

View file

@ -118,7 +118,8 @@
let viewport: UIEventSource<HTMLDivElement> = new UIEventSource<HTMLDivElement>(undefined)
let mapproperties: MapProperties = state.mapProperties
state.mapProperties.installCustomKeyboardHandler(viewport)
let canZoomIn = mapproperties.maxzoom.map(mz => mapproperties.zoom.data < mz, [mapproperties.zoom] )
let canZoomOut = mapproperties.minzoom.map(mz => mapproperties.zoom.data > mz, [mapproperties.zoom] )
function updateViewport() {
const rect = viewport.data?.getBoundingClientRect()
if (!rect) {
@ -148,7 +149,7 @@
let currentViewLayer = layout.layers.find((l) => l.id === "current_view")
let rasterLayer: Store<RasterLayerPolygon> = state.mapProperties.rasterLayer
let rasterLayerName =
rasterLayer.data?.properties?.name ?? AvailableRasterLayers.maptilerDefaultLayer.properties.name
rasterLayer.data?.properties?.name ?? AvailableRasterLayers.defaultBackgroundLayer.properties.name
onDestroy(
rasterLayer.addCallbackAndRunD((l) => {
rasterLayerName = l.properties.name
@ -179,7 +180,7 @@
</script>
<div class="absolute top-0 left-0 h-screen w-screen overflow-hidden">
<MaplibreMap map={maplibremap} />
<MaplibreMap map={maplibremap} mapProperties={mapproperties} />
</div>
{#if $visualFeedback}
@ -256,6 +257,9 @@
<If condition={state.featureSwitchIsTesting}>
<div class="alert w-fit">Testmode</div>
</If>
<If condition={state.featureSwitches.featureSwitchFakeUser}>
<div class="alert w-fit">Faking a user (Testmode)</div>
</If>
</div>
<div class="flex w-full flex-col items-center justify-center">
<!-- Flex and w-full are needed for the positioning -->
@ -329,12 +333,14 @@
</If>
<MapControlButton
arialabel={Translations.t.general.labels.zoomIn}
enabled={canZoomIn}
on:click={() => mapproperties.zoom.update((z) => z + 1)}
on:keydown={forwardEventToMap}
>
<Plus class="h-8 w-8" />
</MapControlButton>
<MapControlButton
enabled={canZoomOut}
arialabel={Translations.t.general.labels.zoomOut}
on:click={() => mapproperties.zoom.update((z) => z - 1)}
on:keydown={forwardEventToMap}
@ -402,7 +408,7 @@
<div slot="close-button" />
<div class="normal-background absolute flex h-full w-full flex-col">
<SelectedElementTitle {state} layer={$selectedLayer} selectedElement={$selectedElement} />
<SelectedElementView {state} layer={$selectedLayer} selectedElement={$selectedElement} />
<SelectedElementView {state} selectedElement={$selectedElement} />
</div>
</ModalRight>
{/if}
@ -580,10 +586,9 @@
</div>
<SelectedElementView
highlightedRendering={state.guistate.highlightedUserSetting}
layer={UserRelatedState.usersettingsConfig}
selectedElement={{
type: "Feature",
properties: {},
properties: {id:"settings"},
geometry: { type: "Point", coordinates: [0, 0] },
}}
{state}

View file

@ -20,7 +20,7 @@ export default class WikidataSearchBox extends InputElement<string> {
new Table(
["name", "doc"],
[
["key", "the value of this tag will initialize search (default: name)"],
["key", "the value of this tag will initialize search (default: name). This can be a ';'-separated list in which case every key will be inspected. The non-null value will be used as search"],
[
"options",
new Combine([

View file

@ -1059,6 +1059,14 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
throw result["error"]
}
public static awaitAnimationFrame(): Promise<void>{
return new Promise<void>((resolve) => {
window.requestAnimationFrame(() => {
resolve()
})
})
}
public static async downloadJsonAdvanced(
url: string,
headers?: any
@ -1388,7 +1396,10 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
d.setUTCMinutes(0)
}
public static scrollIntoView(element: HTMLBaseElement | HTMLDivElement) {
public static scrollIntoView(element: HTMLBaseElement | HTMLDivElement): void {
if (!element) {
return
}
// Is the element completely in the view?
const parentRect = Utils.findParentWithScrolling(element)?.getBoundingClientRect()
if (!parentRect) {

View file

@ -65,9 +65,11 @@ export class PngMapCreator {
document.getElementById(freeComponentId).appendChild(div)
const newZoom = settings.zoom.data + Math.log2(pixelRatio) - 1
const rasterLayerProperties = settings.rasterLayer.data?.properties ?? AvailableRasterLayers.defaultBackgroundLayer.properties
const style = rasterLayerProperties?.style ?? rasterLayerProperties?.url
const mapElem = new MlMap({
container: div.id,
style: AvailableRasterLayers.maptilerDefaultLayer.properties.url,
style,
center: [l.lon, l.lat],
zoom: newZoom,
pixelRatio,

View file

@ -881,107 +881,7 @@ class SvgToPdfPage {
width,
height,
}).CreatePng(this.options.freeComponentId, this._state)
} /* else {
const match = spec.match(/\$map\(([^)]*)\)$/)
if (match === null) {
throw "Invalid mapspec:" + spec
}
const params = SvgToPdfInternals.parseCss(match[1], ",")
const layout = AllKnownLayouts.allKnownLayouts.get(params["theme"])
if (layout === undefined) {
console.error("Could not show map with parameters", params)
throw (
"Theme not found:" +
params["theme"] +
". Use theme: to define which theme to use. "
)
}
layout.widenFactor = 0
layout.overpassTimeout = 600
layout.defaultBackgroundId = params["background"] ?? layout.defaultBackgroundId
for (const paramsKey in params) {
if (paramsKey.startsWith("layer-")) {
const layerName = paramsKey.substring("layer-".length)
const key = params[paramsKey].toLowerCase().trim()
const layer = layout.layers.find((l) => l.id === layerName)
if (layer === undefined) {
throw "No layer found for " + paramsKey
}
if (key === "force") {
layer.minzoom = 0
layer.minzoomVisible = 0
}
}
}
const zoom = Number(params["zoom"] ?? params["z"] ?? 14)
const state = new ThemeViewState(layout)
state.mapProperties.location.setData({
lat: this.options?.overrideLocation?.lat ?? Number(params["lat"] ?? 51.05016),
lon: this.options?.overrideLocation?.lon ?? Number(params["lon"] ?? 3.717842),
})
state.mapProperties.zoom.setData(zoom)
const fl = Array.from(state.layerState.filteredLayers.values())
for (const filteredLayer of fl) {
if (params["layer-" + filteredLayer.layerDef.id] !== undefined) {
filteredLayer.isDisplayed.setData(
loadData &&
params["layer-" + filteredLayer.layerDef.id].trim().toLowerCase() !==
"false"
)
} else if (params["layers"] === "none") {
filteredLayer.isDisplayed.setData(false)
} else if (filteredLayer.layerDef.id.startsWith("note_import")) {
filteredLayer.isDisplayed.setData(false)
}
}
for (const paramsKey in params) {
if (paramsKey.startsWith("layer-")) {
const layerName = paramsKey.substring("layer-".length)
const key = params[paramsKey].toLowerCase().trim()
const isDisplayed = loadData && (key === "true" || key === "force")
const layer = fl.find((l) => l.layerDef.id === layerName)
if (!loadData) {
console.log(
"Not loading map data as 'loadData' is falsed, this is probably a test run"
)
} else {
console.log(
"Setting ",
layer?.layerDef?.id,
" to visibility",
isDisplayed,
"(minzoom:",
layer?.layerDef?.minzoomVisible,
layer?.layerDef?.minzoom,
")"
)
}
layer.isDisplayed.setData(loadData && isDisplayed)
if (key === "force" && loadData) {
layer.layerDef.minzoom = 0
layer.layerDef.minzoomVisible = 0
layer.isDisplayed.addCallback((isDisplayed) => {
if (!isDisplayed) {
console.warn("Forcing layer " + paramsKey + " as true")
layer.isDisplayed.setData(true)
}
})
}
}
}
const pngCreator = new PngMapCreator(state, {
width: 4 * width,
height: 4 * height,
})
png = await pngCreator.CreatePng(this.options.freeComponentId, this._state)
if (!png) {
throw "PngCreator did not output anything..."
}
}
//*/
svgImage.setAttribute("xlink:href", await SvgToPdfPage.blobToBase64(png))
svgImage.style.width = width + "mm"
svgImage.style.height = height + "mm"

File diff suppressed because one or more lines are too long

View file

@ -12,6 +12,112 @@
"url": "https://osm.org/copyright"
}
},
{
"url": "pmtiles://https://api.protomaps.com/tiles/v3.json?key=2af8b969a9e8b692",
"style": "https://api.protomaps.com/styles/v2/white.json?key=2af8b969a9e8b692",
"connect-src": [
"https://protomaps.github.io"
],
"id": "protomaps.white",
"name": "Protomaps White",
"type": "vector",
"category": "osmbasedmap",
"attribution": {
"text": "Protomaps",
"url": "https://protomaps.com/"
}
},
{
"url": "pmtiles://https://api.protomaps.com/tiles/v3.json?key=2af8b969a9e8b692",
"style": "https://api.protomaps.com/styles/v2/light.json?key=2af8b969a9e8b692",
"connect-src": [
"https://protomaps.github.io"
],
"id": "protomaps.light",
"name": "Protomaps Light",
"type": "vector",
"category": "osmbasedmap",
"attribution": {
"text": "Protomaps",
"url": "https://protomaps.com/"
}
},
{
"url": "pmtiles://https://api.protomaps.com/tiles/v3.json?key=2af8b969a9e8b692",
"connect-src": [
"https://protomaps.github.io"
],
"style": "https://api.protomaps.com/styles/v2/grayscale.json?key=2af8b969a9e8b692",
"id": "protomaps.grayscale",
"name": "Protomaps Grayscale",
"type": "vector",
"category": "osmbasedmap",
"attribution": {
"text": "Protomaps",
"url": "https://protomaps.com/"
}
},
{
"url": "pmtiles://https://api.protomaps.com/tiles/v3.json?key=2af8b969a9e8b692",
"connect-src": [
"https://protomaps.github.io"
],
"style": "https://api.protomaps.com/styles/v2/dark.json?key=2af8b969a9e8b692",
"id": "protomaps.dark",
"name": "Protomaps Dark",
"type": "vector",
"category": "osmbasedmap",
"attribution": {
"text": "Protomaps",
"url": "https://protomaps.com/"
}
},
{
"url": "pmtiles://https://api.protomaps.com/tiles/v3.json?key=2af8b969a9e8b692",
"style": "https://api.protomaps.com/styles/v2/black.json?key=2af8b969a9e8b692",
"connect-src": [
"https://protomaps.github.io"
],
"id": "protomaps.black",
"name": "Protomaps Black",
"type": "vector",
"category": "osmbasedmap",
"attribution": {
"text": "Protomaps",
"url": "https://protomaps.com/"
}
},
{
"url": "pmtiles://https://api.protomaps.com/tiles/v3.json?key=2af8b969a9e8b692",
"style": "assets/sunny.json",
"connect-src": [
"https://protomaps.github.io"
],
"id": "protomaps.sunny",
"name": "Protomaps Sunny",
"type": "vector",
"category": "osmbasedmap",
"attribution": {
"text": "Protomaps",
"url": "https://protomaps.com/"
}
},
{
"url": "pmtiles://https://api.protomaps.com/tiles/v3.json?key=2af8b969a9e8b692",
"style": "assets/sunny-unlabeled.json",
"connect-src": [
"https://protomaps.github.io"
],
"id": "protomaps.sunny_unlabeled",
"name": "Protomaps Sunny Unlabeled",
"type": "vector",
"category": "osmbasedmap",
"attribution": {
"text": "Protomaps",
"url": "https://protomaps.com/"
}
},
{
"name": "Americana",
"url": "https://zelonewolf.github.io/openstreetmap-americana/style.json",
@ -24,10 +130,11 @@
}
},
{
"name": "MapTiler Backdrop",
"url": "https://api.maptiler.com/maps/backdrop/style.json?key=GvoVAJgu46I5rZapJuAy",
"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.backdrop",
"id": "maptiler",
"type": "vector",
"attribution": {
"text": "Maptiler",
@ -88,7 +195,8 @@
"text": "Stamen/Stadiamaps",
"url": "https://stadiamaps.com/"
}
}, {
},
{
"name": "Stamen Watercolor",
"url": "https://tiles.stadiamaps.com/tiles/stamen_watercolor/{z}/{x}/{y}.jpg?key=14c5a900-7137-42f7-9cb9-fff0f4696f75",
"category": "osmbasedmap",
@ -142,8 +250,6 @@
"url": "https://carto.com/"
}
},
{
"url": "https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json?key=eyJhbGciOiJIUzI1NiJ9.eyJhIjoiYWNfdW4ybmhlbTciLCJqdGkiOiIwZGQxNjJmNyJ9.uATJpa6QcrtXhph3Bzvk2nX3QsxEw-Q8dj5khUG6hGk",
"name": "Carto Positron (no labels)",

View file

@ -20,16 +20,26 @@ function webgl_support() {
return false
}
}
async function timeout(timeMS: number): Promise<{ layers: string[] }> {
await Utils.waitFor(timeMS)
return { layers: [] }
}
async function getAvailableLayers(): Promise<Set<string>> {
try {
const host = new URL(Constants.VectorTileServer).host
const status = await Utils.downloadJson("https://" + host + "/summary/status.json")
const status: { layers: string[] } = await Promise.any([
// Utils.downloadJson("https://" + host + "/summary/status.json"),
timeout(0)
])
return new Set<string>(status.layers)
} catch (e) {
console.error("Could not get MVT available layers due to", e)
return new Set<string>()
}
}
async function main() {
// @ts-ignore
try {
@ -38,12 +48,15 @@ async function main() {
}
const [layout, availableLayers] = await Promise.all([
DetermineLayout.GetLayout(),
await getAvailableLayers(),
await getAvailableLayers()
])
console.log("The available layers on server are", Array.from(availableLayers))
const state = new ThemeViewState(layout, availableLayers)
const main = new SvelteUIElement(ThemeViewGUI, { state })
main.AttachTo("maindiv")
Array.from(document.getElementsByClassName("delete-on-load")).forEach(el => {
el.parentElement.removeChild(el)
})
} catch (err) {
console.error("Error while initializing: ", err, err.stack)
const customDefinition = DetermineLayout.getCustomDefinition()
@ -52,16 +65,17 @@ async function main() {
customDefinition?.length > 0
? new SubtleButton(new SvelteUIElement(Download), "Download the raw file").onClick(
() =>
Utils.offerContentsAsDownloadableFile(
DetermineLayout.getCustomDefinition(),
"mapcomplete-theme.json",
{ mimetype: "application/json" }
)
)
: undefined,
() =>
Utils.offerContentsAsDownloadableFile(
DetermineLayout.getCustomDefinition(),
"mapcomplete-theme.json",
{ mimetype: "application/json" }
)
)
: undefined
]).AttachTo("maindiv")
}
}
main().then((_) => {})
main().then((_) => {
})

View file

@ -18,10 +18,20 @@ function webgl_support() {
return false
}
}
async function timeout(timeMS: number): Promise<{ layers: string[] }> {
await Utils.waitFor(timeMS)
return { layers: [] }
}
async function getAvailableLayers(): Promise<Set<string>> {
try {
const host = new URL(Constants.VectorTileServer).host
const status = await Utils.downloadJson("https://" + host + "/summary/status.json")
const status = await Promise.any([
// Utils.downloadJson("https://" + host + "/summary/status.json"),
timeout(0)
])
return new Set<string>(status.layers)
} catch (e) {
console.error("Could not get MVT available layers due to", e)
@ -39,6 +49,9 @@ async function main() {
const state = new ThemeViewState(new LayoutConfig(<any> layout), availableLayers)
const main = new SvelteUIElement(ThemeViewGUI, { state })
main.AttachTo("maindiv")
Array.from(document.getElementsByClassName("delete-on-load")).forEach(el => {
el.parentElement.removeChild(el)
})
}
}
main()

View file

@ -1,7 +1,4 @@
import SvelteUIElement from "./UI/Base/SvelteUIElement"
import Test from "./UI/Test.svelte"
import MvtSource from "./Logic/FeatureSource/Sources/MvtSource"
new MvtSource("https://example.org", undefined, undefined, undefined)
new SvelteUIElement(Test, {}).AttachTo("maindiv")
new SvelteUIElement(Test).AttachTo("maindiv")