merge develop

This commit is contained in:
Pieter Vander Vennet 2025-04-09 17:18:30 +02:00
commit 3e4708b0b9
506 changed files with 7945 additions and 74587 deletions

View file

@ -8,24 +8,16 @@
import Logo from "../assets/svg/Logo.svelte"
import Tr from "./Base/Tr.svelte"
import LoginToggle from "./Base/LoginToggle.svelte"
import Pencil from "../assets/svg/Pencil.svelte"
import Constants from "../Models/Constants"
import { ImmutableStore, Store, Stores, UIEventSource } from "../Logic/UIEventSource"
import ThemesList from "./BigComponents/ThemesList.svelte"
import { MinimalThemeInformation } from "../Models/ThemeConfig/ThemeConfig"
import Eye from "../assets/svg/Eye.svelte"
import LoginButton from "./Base/LoginButton.svelte"
import Mastodon from "../assets/svg/Mastodon.svelte"
import Liberapay from "../assets/svg/Liberapay.svelte"
import Bug from "../assets/svg/Bug.svelte"
import { Utils } from "../Utils"
import { ArrowTrendingUp } from "@babeard/svelte-heroicons/solid/ArrowTrendingUp"
import Searchbar from "./Base/Searchbar.svelte"
import ThemeSearch, { ThemeSearchIndex } from "../Logic/Search/ThemeSearch"
import SearchUtils from "../Logic/Search/SearchUtils"
import ChevronDoubleRight from "@babeard/svelte-heroicons/mini/ChevronDoubleRight"
import { AndroidPolyfill } from "../Logic/Web/AndroidPolyfill"
import Forgejo from "../assets/svg/Forgejo.svelte"
import Locale from "./i18n/Locale"
import DrawerLeft from "./Base/DrawerLeft.svelte"
import MenuDrawer from "./BigComponents/MenuDrawer.svelte"

View file

@ -61,6 +61,9 @@
import Hotkeys from "../Base/Hotkeys"
import { ArrowTrendingUp } from "@babeard/svelte-heroicons/solid/ArrowTrendingUp"
import ArrowTopRightOnSquare from "@babeard/svelte-heroicons/mini/ArrowTopRightOnSquare"
import { PhotoIcon } from "@babeard/svelte-heroicons/outline"
import ImageUploadQueue from "../../Logic/ImageProviders/ImageUploadQueue"
import QueuedImagesView from "../Image/QueuedImagesView.svelte"
export let state: {
favourites: FavouritesFeatureSource
@ -97,6 +100,8 @@
}
})
let isAndroid = AndroidPolyfill.inAndroid
let nrOfFailedImages = ImageUploadQueue.singleton.imagesInQueue
let failedImagesOpen = pg.failedImages
</script>
<div
@ -156,6 +161,16 @@
/>
</Page>
{#if $nrOfFailedImages.length > 0 || $failedImagesOpen}
<Page {onlyLink} shown={pg.failedImages} bodyPadding="p-0 pb-4">
<svelte:fragment slot="header">
<PhotoIcon />
<Tr t={Translations.t.imageQueue.menu.Subs({count: $nrOfFailedImages.length})} />
</svelte:fragment>
<QueuedImagesView {state} />
</Page>
{/if}
<LoginToggle {state} silentFail>
{#if state.favourites}
<Page {onlyLink} shown={pg.favourites}>

View file

@ -59,7 +59,7 @@
{#if $isTesting || $isDebugging}
<a
class="subtle"
href="https://github.com/pietervdvn/MapComplete/blob/develop/Docs/Layers/{layer.id}.md"
href="https://source.mapcomplete.org/MapComplete/MapComplete/src/branch/develop/Docs/Layers/{layer.id}.md"
target="_blank"
rel="noreferrer noopener "
>

View file

@ -23,7 +23,7 @@
? "flex flex-wrap items-center justify-center gap-x-2"
: "theme-list my-2 gap-4 md:grid md:grid-flow-row md:grid-cols-2 lg:grid-cols-3"}
>
{#each Utils.NoNull(themes) as theme (theme.id)}
{#each Utils.DedupOnId(Utils.NoNull(themes)) as theme (theme.id)}
<ThemeButton {theme} {state} iconOnly={onlyIcons}>
{#if $search && hasSelection && themes?.[0] === theme}
<span class="thanks hidden-on-mobile" aria-hidden="true">

View file

@ -0,0 +1,65 @@
<script lang="ts">
import type { ImageUploadArguments } from "../../Logic/ImageProviders/ImageUploadQueue"
import ImageUploadQueue from "../../Logic/ImageProviders/ImageUploadQueue"
import { TrashIcon } from "@babeard/svelte-heroicons/mini"
import Popup from "../Base/Popup.svelte"
import { UIEventSource } from "../../Logic/UIEventSource"
import Page from "../Base/Page.svelte"
import BackButton from "../Base/BackButton.svelte"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
let queue = ImageUploadQueue.singleton
export let imageArguments: ImageUploadArguments
let confirmDelete = new UIEventSource(false)
function del() {
queue.delete(imageArguments)
}
const t = Translations.t
let src = undefined
try{
src = URL.createObjectURL(imageArguments.blob)
}catch (e) {
console.error("Could not create an ObjectURL for blob", imageArguments.blob)
}
</script>
<div class="low-interaction rounded border-interactive w-fit p-2 m-1 flex flex-col">
<img class="max-w-64 w-auto max-h-64 w-auto" {src} />
{imageArguments.featureId} {imageArguments.layoutId}
<button class="as-link self-end" on:click={() => {confirmDelete.set(true)}}>
<TrashIcon class="w-4" />
<Tr t={t.imageQueue.delete} />
</button>
<Popup shown={confirmDelete} dismissable={true}>
<Page shown={confirmDelete}>
<svelte:fragment slot="header">
<TrashIcon class="w-8 m-1" />
<Tr t={t.imageQueue.confirmDeleteTitle} />
</svelte:fragment>
<div class="flex flex-col ">
<div class="flex justify-center">
<img class="max-w-128 w-auto max-h-128 w-auto" src={URL.createObjectURL(imageArguments.blob)} />
</div>
<div class="flex w-full">
<BackButton clss="w-full" on:click={() => confirmDelete.set(false)}>
<Tr t={t.general.back} />
</BackButton>
<button on:click={() => del()} class="primary w-full">
<TrashIcon class="w-8 m-1" />
<Tr t={t.imageQueue.confirmDelete} />
</button>
</div>
</div>
</Page>
</Popup>
</div>

View file

@ -0,0 +1,42 @@
<script lang="ts">
import QueuedImage from "./QueuedImage.svelte"
import { ArrowPathIcon } from "@babeard/svelte-heroicons/mini"
import Loading from "../Base/Loading.svelte"
import { WithImageState } from "../../Models/ThemeViewState/WithImageState"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
import type { ImageUploadArguments } from "../../Logic/ImageProviders/ImageUploadQueue"
import { Store } from "../../Logic/UIEventSource"
import UploadingImageCounter from "./UploadingImageCounter.svelte"
export let state: WithImageState
let queued: Store<ImageUploadArguments[]> = state.imageUploadManager.queuedArgs
let isUploading = state.imageUploadManager.isUploading
const t = Translations.t
const q = t.imageQueue
</script>
<div class="m-4 flex flex-col">
{#if $queued.length === 0}
<Tr t={q.noFailedImages} />
{:else}
<div>
<Tr t={q.intro} />
</div>
<UploadingImageCounter {state}/>
{#if $isUploading}
<Loading />
{:else}
<button class="primary" on:click={() => state.imageUploadManager.uploadQueue()}>
<ArrowPathIcon class="w-8 h-8 m-1" />
<Tr t={q.retryAll} />
</button>
{/if}
<div class="flex flex-wrap">
{#each $queued as i (i.date + i.featureId)}
<QueuedImage imageArguments={i} />
{/each}
</div>
{/if}
</div>

View file

@ -14,7 +14,6 @@
import LoginButton from "../Base/LoginButton.svelte"
import { Translation } from "../i18n/Translation"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import NoteCommentElement from "../Popup/Notes/NoteCommentElement"
import type { Feature } from "geojson"
import Camera from "@babeard/svelte-heroicons/mini/Camera"
@ -22,7 +21,6 @@
export let tags: UIEventSource<OsmTags>
export let targetKey: string = undefined
export let layer: LayerConfig
export let noBlur: boolean = false
export let feature: Feature
/**
@ -38,7 +36,7 @@
let errors = new UIEventSource<Translation[]>([])
async function handleFiles(files: FileList, ignoreGps: boolean = false) {
async function handleFiles(files: FileList, ignoreGPS: boolean = false) {
const errs = []
for (let i = 0; i < files.length; i++) {
const file = files.item(i)
@ -49,29 +47,7 @@
errs.push(canBeUploaded.error)
continue
}
if (layer?.id === "note") {
const uploadResult = await state?.imageUploadManager.uploadImageWithLicense(
tags.data.id,
state.osmConnection.userDetails.data?.name ?? "Anonymous",
file,
"image",
noBlur,
feature,
ignoreGps
)
if (!uploadResult) {
return
}
const url = uploadResult.absoluteUrl
await state.osmConnection.addCommentToNote(tags.data.id, url)
NoteCommentElement.addCommentTo(url, <UIEventSource<OsmTags>>tags, {
osmConnection: state.osmConnection,
})
return
}
await state?.imageUploadManager?.uploadImageAndApply(file, tags, targetKey, noBlur, feature)
await state?.imageUploadManager?.uploadImageAndApply(file, tags, targetKey, noBlur, feature, { ignoreGPS })
} catch (e) {
console.error(e)
state.reportError(e, "Could not upload image")
@ -98,7 +74,7 @@
<Tr t={error} cls="alert" />
{/each}
<FileSelector
accept="image/*"
accept=".jpg,.jpeg,image/jpeg"
capture="environment"
cls="button border-2 flex flex-col"
multiple={true}

View file

@ -7,76 +7,69 @@
import type { SpecialVisualizationState } from "../SpecialVisualization"
import { Store } from "../../Logic/UIEventSource"
import type { OsmTags } from "../../Models/OsmFeature"
import type { NoteId, OsmTags, OsmId } from "../../Models/OsmFeature"
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
export let featureId = tags?.data?.id
export let featureId: OsmId | NoteId | "*" = tags?.data?.id ?? "*"
if (featureId === undefined) {
throw "No tags or featureID given"
}
export let showThankYou: boolean = true
const { uploadStarted, uploadFinished, retried, failed } =
state.imageUploadManager.getCountsFor(featureId)
/*
Number of images uploaded succesfully
*/
function getCount(input: Store<string[]>): Store<number> {
if (featureId == "*") {
return input.map(inp => inp.length)
}
return input.map(success => success.filter(item => item === featureId).length)
}
let successfull = getCount(state.imageUploadManager.successfull)
/* Number of failed uploads */
let failed = getCount(state.imageUploadManager.fails)
let pending = getCount(state.imageUploadManager.queued)
const t = Translations.t.image
const debugging = state.featureSwitches.featureSwitchIsDebugging
let dismissed = 0
failed.addCallbackAndRun(failed => {
dismissed = Math.min(failed, dismissed)
})
</script>
{#if $debugging}
<div class="low-interaction">
Started {$uploadStarted} Done {$uploadFinished} Retry {$retried} Err {$failed}
Pending {$pending} Done {$successfull} 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}
<UploadFailedMessage failed={$failed} on:click={() => (dismissed = $failed)} />
{:else if $retried === 1}
<div class="alert">
<Loading>
<Tr t={t.upload.one.retrying} />
</Loading>
</div>
{:else}
<div class="alert">
<Loading>
{#if $pending - $failed > 0}
<div class="alert">
<Loading>
{#if $pending - $failed === 1}
<Tr t={t.upload.one.uploading} />
</Loading>
</div>
{/if}
{:else if $uploadStarted > 1}
{#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}
<Loading cls="alert">
<Tr t={t.upload.multiple.uploading.Subs({ count: $uploadStarted })} />
{:else if $pending - $failed > 1}
<Tr t={t.upload.multiple.uploading.Subs({count: $pending})} />
{/if}
</Loading>
{:else if $uploadFinished > 0}
<Loading cls="alert">
<Tr
t={t.upload.multiple.partiallyDone.Subs({
count: $uploadStarted - $uploadFinished,
done: $uploadFinished,
})}
/>
</Loading>
{/if}
{#if $failed > 0}
<UploadFailedMessage failed={$failed} on:click={() => (dismissed = $failed)} />
</div>
{/if}
{#if $failed > dismissed}
<UploadFailedMessage failed={$failed} on:click={() => (dismissed = $failed)} />
{/if}
{#if showThankYou}
{#if $successfull === 1}
<Tr cls="thanks" t={t.upload.one.done} />
{:else if $successfull > 1}
<Tr cls="thanks" t={t.upload.multiple.done.Subs({count: $successfull})} />
{/if}
{/if}

View file

@ -145,7 +145,7 @@ export class TextField extends InputElement<string> {
self.value.setData(val)
}
// Setting the value might cause the value to be set again. We keep the distance _to the end_ stable, as phone number formatting might cause the start to change
// See https://github.com/pietervdvn/MapComplete/issues/103
// See https://source.mapcomplete.org/MapComplete/MapComplete/issues/103
// We reread the field value - it might have changed!
// @ts-ignore

View file

@ -0,0 +1,100 @@
<script lang="ts">
/**
* Used to quickly calculate a distance by dragging a map (and selecting start- and endpoints)
*/
import LocationInput from "./LocationInput.svelte"
import { UIEventSource, Store } from "../../../Logic/UIEventSource"
import type { MapProperties } from "../../../Models/MapProperties"
import ThemeViewState from "../../../Models/ThemeViewState"
import type { Feature } from "geojson"
import type { RasterLayerPolygon } from "../../../Models/RasterLayers"
import { RasterLayerUtils } from "../../../Models/RasterLayers"
import { eliCategory } from "../../../Models/RasterLayerProperties"
import { GeoOperations } from "../../../Logic/GeoOperations"
import OpenBackgroundSelectorButton from "../../BigComponents/OpenBackgroundSelectorButton.svelte"
import { Map as MlMap } from "maplibre-gl"
import StaticFeatureSource from "../../../Logic/FeatureSource/Sources/StaticFeatureSource"
import ShowDataLayer from "../../Map/ShowDataLayer"
import * as conflation from "../../../../assets/layers/conflation/conflation.json"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import Translations from "../../i18n/Translations"
import Tr from "../../Base/Tr.svelte"
export let value: UIEventSource<number>
export let feature: Feature
export let args: { background?: string, zoom?: number }
export let state: ThemeViewState = undefined
export let map: UIEventSource<MlMap> = new UIEventSource<MlMap>(undefined)
let center = GeoOperations.centerpointCoordinates(feature)
export let initialCoordinate: { lon: number, lat: number } = { lon: center[0], lat: center[1] }
let mapLocation: UIEventSource<{ lon: number, lat: number }> = new UIEventSource(initialCoordinate)
let bg = args?.background
let rasterLayer = state?.mapProperties.rasterLayer
if (bg !== undefined) {
if (eliCategory.indexOf(bg) >= 0) {
const availableLayers = state.availableLayers.store.data
const startLayer: RasterLayerPolygon = RasterLayerUtils.SelectBestLayerAccordingTo(availableLayers, bg)
rasterLayer = new UIEventSource(startLayer)
state?.mapProperties.rasterLayer.addCallbackD(layer => rasterLayer.set(layer))
}
}
let mapProperties: Partial<MapProperties> = {
rasterLayer: rasterLayer,
location: mapLocation,
zoom: new UIEventSource(args?.zoom ?? 18)
}
let start: UIEventSource<{ lon: number, lat: number }> = new UIEventSource(undefined)
function selectStart() {
start.set(mapLocation.data)
}
let lengthFeature: Store<Feature[]> = start.map(start => {
if (!start) {
return []
}
// A bit of a double task: calculate the actual value _and_ the map rendering
const end = mapLocation.data
const distance = GeoOperations.distanceBetween([start.lon, start.lat], [end.lon, end.lat])
value.set(distance.toFixed(2))
return <Feature[]>[
{
type: "Feature",
properties: {
id: "distance_line_" + distance,
distance: "" + distance
},
geometry: {
type: "LineString",
coordinates: [[start.lon, start.lat], [end.lon, end.lat]]
}
}
]
}, [mapLocation])
new ShowDataLayer(map, {
layer: new LayerConfig(conflation),
features: new StaticFeatureSource(lengthFeature)
})
</script>
<div class="relative w-full h-64">
<LocationInput value={mapLocation} {mapProperties} {map} />
<div class="absolute bottom-0 left-0 p-4">
<OpenBackgroundSelectorButton {state} {map} />
</div>
</div>
<button class="primary" on:click={() => selectStart()}>
<Tr t={Translations.t.input_helpers.distance.setFirst} />
</button>

View file

@ -164,7 +164,6 @@
/**
* Determines 'top' and 'height-attributes, returns a CSS-string'
* @param oh
*/
function rangeStyle(oh: OpeningHour, totalHeight: number): string {
const top = ((oh.startHour + oh.startMinutes / 60) * totalHeight) / 24

View file

@ -41,4 +41,5 @@
</button>
</Popup>
<button on:click={() => expanded.set(true)}>Pick opening hours</button>
<PublicHolidaySelector value={state.phSelectorValue} />

View file

@ -19,12 +19,13 @@
import SlopeInput from "./Helpers/SlopeInput.svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import WikidataInputHelper from "./WikidataInputHelper.svelte"
import DistanceInput from "./Helpers/DistanceInput.svelte"
export let type: ValidatorType
export let value: UIEventSource<string | object>
export let feature: Feature = undefined
export let args: (string | number | boolean)[] = undefined
export let args: (string | number | boolean)[] | any = undefined
export let state: SpecialVisualizationState = undefined
</script>
@ -52,6 +53,8 @@
<SlopeInput {value} {feature} {state} />
{:else if type === "wikidata"}
<WikidataInputHelper {value} {feature} {state} {args} />
{:else if type === "distance"}
<DistanceInput {value} {state} {feature} {args} />
{:else}
<slot name="fallback" />
{/if}

View file

@ -27,7 +27,6 @@ export interface InputHelperProperties {
export default class InputHelpers {
public static hideInputField: string[] = ["translation", "simple_tag", "tag"]
// noinspection JSUnusedLocalSymbols
/**
* Constructs a mapProperties-object for the given properties.
* Assumes that the first helper-args contains the desired zoom-level

View file

@ -14,7 +14,8 @@ export abstract class Validator {
* */
public readonly explanation: string
/**
* What HTML-inputmode to use
* What HTML-inputmode to use?
* Note: some inputHelpers will completely hide the default text field. This is kept in InputHelpers.hideInputField
*/
public readonly inputmode?:
| "none"
@ -81,4 +82,14 @@ export abstract class Validator {
public reformat(s: string, _?: () => string): string {
return s
}
/**
* Checks that the helper arguments are correct.
* This is called while preparing the themes.
* Returns 'undefined' if everything is fine, or feedback if an error is detected
* @param args the args for the input helper
*/
public validateArguments(args: string): undefined | string {
return undefined
}
}

View file

@ -4,7 +4,7 @@ import TextValidator from "./Validators/TextValidator"
import DateValidator from "./Validators/DateValidator"
import NatValidator from "./Validators/NatValidator"
import IntValidator from "./Validators/IntValidator"
import LengthValidator from "./Validators/LengthValidator"
import DistanceValidator from "./Validators/DistanceValidator"
import DirectionValidator from "./Validators/DirectionValidator"
import WikidataValidator from "./Validators/WikidataValidator"
import PNatValidator from "./Validators/PNatValidator"
@ -33,36 +33,35 @@ export type ValidatorType = (typeof Validators.availableTypes)[number]
export default class Validators {
public static readonly availableTypes = [
"string",
"text",
"date",
"nat",
"int",
"distance",
"direction",
"wikidata",
"pnat",
"float",
"pfloat",
"email",
"url",
"phone",
"opening_hours",
"color",
"image",
"simple_tag",
"key",
"translation",
"icon",
"fediverse",
"tag",
"fediverse",
"id",
"slope",
"velopark",
"nsi",
"currency",
"date",
"direction",
"distance",
"email",
"fediverse",
"float",
"icon",
"id",
"image",
"int",
"key",
"nat",
"nsi",
"opening_hours",
"pfloat",
"phone",
"pnat",
"regex",
"simple_tag",
"slope",
"string",
"tag",
"text",
"translation",
"url",
"velopark",
"wikidata"
] as const
public static readonly AllValidators: ReadonlyArray<Validator> = [
@ -71,7 +70,7 @@ export default class Validators {
new DateValidator(),
new NatValidator(),
new IntValidator(),
new LengthValidator(),
new DistanceValidator(),
new DirectionValidator(),
new WikidataValidator(),
new PNatValidator(),
@ -94,7 +93,7 @@ export default class Validators {
new VeloparkValidator(),
new NameSuggestionIndexValidator(),
new CurrencyValidator(),
new RegexValidator(),
new RegexValidator()
]
private static _byType = Validators._byTypeConstructor()

View file

@ -0,0 +1,55 @@
import { Validator } from "../Validator"
import { Utils } from "../../../Utils"
import { eliCategory } from "../../../Models/RasterLayerProperties"
export default class DistanceValidator extends Validator {
private readonly docs: string = [
"#### Helper-arguments",
"Options are:",
["````json",
" \"background\": \"some_background_id or category, e.g. 'map'\"",
" \"zoom\": 20 # initial zoom level of the map",
"}",
"```"].join("\n")
].join("\n\n")
constructor() {
super(
"distance",
"A geographical distance in meters (rounded at two points). Will give an extra minimap with a measurement tool. Arguments: [ zoomlevel, preferredBackgroundMapType (comma separated) ], e.g. `[\"21\", \"map,photo\"]",
"decimal"
)
}
isValid = (str) => {
const t = Number(str)
return !isNaN(t)
}
validateArguments(args: any): undefined | string {
if (args === undefined) {
return undefined
}
if (typeof args !== "object" || Array.isArray(args)) {
return "Expected an object of type `{background?: string, zoom?: number}`"
}
const optionalKeys = ["background", "zoom"]
const keys = Object.keys(args).filter(k => optionalKeys.indexOf(k) < 0)
if (keys.length > 0) {
return "Unknown key " + keys.join("; ") + "; use " + optionalKeys.join("; ") + " instead"
}
const bg = args["background"]
if (bg && eliCategory.indexOf(bg) < 0) {
return "The given background layer is not a recognized ELI-type. Perhaps you meant one of " +
Utils.sortedByLevenshteinDistance(bg, eliCategory, x => x).slice(0, 5)
}
if (typeof args["zoom"] !== "number") {
return "zoom must be a number, got a " + typeof args["zoom"]
}
if (typeof args["zoom"] !== "number" || args["zoom"] <= 1 || args["zoom"] > 25) {
return "zoom must be a number between 2 and 25, got " + args["zoom"]
}
return undefined
}
}

View file

@ -4,7 +4,7 @@ import { Validator } from "../Validator"
import { ValidatorType } from "../Validators"
export default class FloatValidator extends Validator {
inputmode: "decimal" = "decimal"
inputmode: "decimal" = "decimal" as const
constructor(name?: ValidatorType, explanation?: string) {
super(name ?? "float", explanation ?? "A decimal number", "decimal")

View file

@ -1,16 +0,0 @@
import { Validator } from "../Validator"
export default class LengthValidator extends Validator {
constructor() {
super(
"distance",
'A geographical distance in meters (rounded at two points). Will give an extra minimap with a measurement tool. Arguments: [ zoomlevel, preferredBackgroundMapType (comma separated) ], e.g. `["21", "map,photo"]',
"decimal"
)
}
isValid = (str) => {
const t = Number(str)
return !isNaN(t)
}
}

View file

@ -1,40 +1,14 @@
import Title from "../../Base/Title"
import Combine from "../../Base/Combine"
import { Validator } from "../Validator"
import Table from "../../Base/Table"
export default class NameSuggestionIndexValidator extends Validator {
constructor() {
super(
"nsi",
new Combine([
"Gives a list of possible suggestions for a brand or operator tag.",
new Title("Helper arguments"),
new Table(
["name", "doc"],
[
[
"options",
new Combine([
"A JSON-object of type `{ main: string, key: string }`. ",
new Table(
["subarg", "doc"],
[
[
"main",
"The main tag to give suggestions for, e.g. `amenity=restaurant`.",
],
[
"addExtraTags",
"Extra tags to add to the suggestions, e.g. `nobrand=yes`.",
],
]
),
]),
],
]
),
])
"Gives a list of possible suggestions for a brand or operator tag. Note: this is detected automatically; there is no need to explicitly set this"
)
}
validateArguments(args: string): string | undefined {
return "No arguments needed"
}
}

View file

@ -7,7 +7,7 @@ export default class OpeningHoursValidator extends Validator {
"opening_hours",
[
"Has extra elements to easily input when a POI is opened.",
"### Helper arguments",
"#### Helper arguments",
"Only one helper argument named `options` can be provided. It is a JSON-object of type `{ prefix: string, postfix: string }`:",
MarkdownUtils.table(
["subarg", "doc"],
@ -22,7 +22,7 @@ export default class OpeningHoursValidator extends Validator {
],
]
),
"### Example usage",
"#### Example usage",
"To add a conditional (based on time) access restriction:\n\n```\n" +
`
"freeform": {

View file

@ -1,17 +1,14 @@
import Combine from "../../Base/Combine"
import Wikidata, { WikidataResponse } from "../../../Logic/Web/Wikidata"
import { Validator } from "../Validator"
import { Translation } from "../../i18n/Translation"
import Translations from "../../i18n/Translations"
import Title from "../../Base/Title"
import Table from "../../Base/Table"
import MarkdownUtils from "../../../Utils/MarkdownUtils"
export default class WikidataValidator extends Validator {
public static readonly _searchCache = new Map<string, Promise<WikidataResponse[]>>()
public static docs = [
"### Helper arguments",
private static docs = [
"#### Helper arguments",
MarkdownUtils.table(
["name", "doc"],
[
@ -25,7 +22,7 @@ export default class WikidataValidator extends Validator {
],
]
),
"#### Suboptions",
"##### Suboptions",
MarkdownUtils.table(
["subarg", "doc"],
[
@ -50,7 +47,7 @@ export default class WikidataValidator extends Validator {
),
].join("\n\n")
private static readonly docsExampleUsage: string =
"### Example usage\n\n" +
"#### Example usage\n\n" +
`The following is the 'freeform'-part of a layer config which will trigger a search for the wikidata item corresponding with the name of the selected feature. It will also remove '-street', '-square', ... if found at the end of the name
\`\`\`json
@ -113,7 +110,7 @@ Another example is to search for species and trees:
return !str.split(";").some((str) => Wikidata.ExtractKey(str) === undefined)
}
getFeedback(s: string, _?: () => string): Translation | undefined {
getFeedback(s: string): Translation | undefined {
const t = Translations.t.validation.wikidata
if (s === "") {
return t.empty

View file

@ -352,7 +352,7 @@ class LineRenderingLayer {
// After waiting 'till the map has loaded, the data might have changed already
// As such, we only now read the features from the featureSource and compare with the previously set data
const features = featureSource.data
if (features.length === 0) {
if (!features || features.length === 0) {
// This is a very ugly workaround for https://source.mapcomplete.org/MapComplete/MapComplete/issues/2312,
// but I couldn't find the root cause
return

View file

@ -859,12 +859,23 @@ This list will be sorted
return ranges
}
/**
*
* const oh = {weekday: 0, startHour: 18, startMinutes: 0, endHour: 20, endMinutes: 30}
* OH.isSame(oh, oh) // => true
* const ohEndMidnights = {...oh, endMinutes: 0, endHour: 24}
* OH.isSame(oh, ohEndMidnights) // => false
* OH.isSame(ohEndMidnights, ohEndMidnights) // => true
* const ohEndMidnightsAlt = {...ohEndMidnights, endMinutes: 0, endHour: 0}
* OH.isSame(ohEndMidnightsAlt, ohEndMidnights) // => true
*
*/
public static isSame(a: OpeningHour, b: OpeningHour) {
return (
a.weekday === b.weekday &&
a.startHour === b.startHour &&
a.startMinutes === b.startMinutes &&
a.endHour === b.endHour &&
a.endHour % 24 === b.endHour % 24 &&
a.endMinutes === b.endMinutes
)
}

View file

@ -1,71 +0,0 @@
/**
* A single opening hours range, shown on top of the OH-picker table
*/
import { Utils } from "../../Utils"
import Combine from "../Base/Combine"
import { OH, OpeningHour } from "./OpeningHours"
import BaseUIElement from "../BaseUIElement"
import { FixedUiElement } from "../Base/FixedUiElement"
import SvelteUIElement from "../Base/SvelteUIElement"
import Delete_icon from "../../assets/svg/Delete_icon.svelte"
export default class OpeningHoursRange extends BaseUIElement {
private _oh: OpeningHour
private readonly _onDelete: () => void
constructor(oh: OpeningHour, onDelete: () => void) {
super()
this._oh = oh
this._onDelete = onDelete
this.SetClass("oh-timerange")
}
InnerConstructElement(): HTMLElement {
const height = this.getHeight()
const oh = this._oh
const startTime = new FixedUiElement(
Utils.TwoDigits(oh.startHour) + ":" + Utils.TwoDigits(oh.startMinutes)
)
const endTime = new FixedUiElement(
Utils.TwoDigits(oh.endHour) + ":" + Utils.TwoDigits(oh.endMinutes)
)
const deleteRange = new SvelteUIElement(Delete_icon)
.SetClass("rounded-full w-6 h-6 block bg-black pointer-events-auto ")
.onClick(() => {
this._onDelete()
})
let content: BaseUIElement
if (height > 3) {
content = new Combine([startTime, deleteRange, endTime]).SetClass(
"flex flex-col h-full justify-between"
)
} else {
content = new Combine([deleteRange])
.SetClass("flex flex-col h-full")
.SetStyle("flex-content: center; overflow-x: unset;")
}
const el = new Combine([content]).ConstructElement()
el.style.top = `${(100 * OH.startTime(oh)) / 24}%`
el.style.height = `${(100 * this.getHeight()) / 24}%`
return el
}
/**
* Gets the relative height, in number of hours to display
* Range: ]0 - 24]
*/
private getHeight(): number {
const oh = this._oh
let endhour = oh.endHour
if (oh.endHour == 0 && oh.endMinutes == 0) {
endhour = 24
}
return endhour - oh.startHour + (oh.endMinutes - oh.startMinutes) / 60
}
}

View file

@ -8,7 +8,6 @@ import Toggle from "../Input/Toggle"
import { VariableUiElement } from "../Base/VariableUIElement"
import Table from "../Base/Table"
import { Translation } from "../i18n/Translation"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import Loading from "../Base/Loading"
import opening_hours from "opening_hours"
import Locale from "../i18n/Locale"
@ -95,7 +94,7 @@ export default class OpeningHoursVisualization extends Toggle {
rangeStart: Date
): BaseUIElement {
const isWeekstable: boolean = oh.isWeekStable()
let [changeHours, changeHourText] = OH.allChangeMoments(ranges)
const [changeHours, changeHourText] = OH.allChangeMoments(ranges)
const today = new Date()
today.setHours(0, 0, 0, 0)
@ -299,10 +298,6 @@ export default class OpeningHoursVisualization extends Toggle {
}
return Translations.t.general.opening_hours.closed_permanently.Clone()
}
const willOpenAt = `${opensAtDate.getDate()}/${opensAtDate.getMonth() + 1} ${OH.hhmm(
opensAtDate.getHours(),
opensAtDate.getMinutes()
)}`
return Translations.t.general.opening_hours.closed_until.Subs({
date: opensAtDate.toLocaleString(),
})

View file

@ -5,7 +5,6 @@
import type { OsmTags } from "../../Models/OsmFeature"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import AccordionSingle from "../Flowbite/AccordionSingle.svelte"
import SelectedElementView from "../BigComponents/SelectedElementView.svelte"
import TagRenderingAnswer from "./TagRendering/TagRenderingAnswer.svelte"
import TagRenderingEditableDynamic from "./TagRendering/TagRenderingEditableDynamic.svelte"
import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig"
@ -18,7 +17,10 @@
export let layer: LayerConfig
let headerTr = layer.tagRenderings.find((tr) => tr.id === header)
let trgs: TagRenderingConfig[] = []
if (headerTr === undefined) {
console.error("Header tagRendering with ID", header, "was not found")
}
let tagRenderings: TagRenderingConfig[] = []
let seenIds = new Set<string>()
for (const label of labels) {
for (const tr of layer.tagRenderings) {
@ -26,18 +28,30 @@
continue
}
if (label === tr.id || tr.labels.some((l) => l === label)) {
trgs.push(tr)
tagRenderings.push(tr)
seenIds.add(tr.id)
}
}
}
</script>
<AccordionSingle>
{#if tagRenderings.length > 0}
<div class="mb-8">
<AccordionSingle>
<div slot="header">
<TagRenderingAnswer {tags} {layer} config={headerTr} {state} {selectedElement} />
{#if headerTr}
<TagRenderingAnswer {tags} {layer} config={headerTr} {state} {selectedElement} />
{:else}
{header}
{/if}
</div>
{#each trgs as config (config.id)}
<TagRenderingEditableDynamic {tags} {config} {state} {selectedElement} {layer} />
{#each tagRenderings as config (config.id)}
{#if config.IsKnown($tags) && (config.condition === undefined || config.condition.matchesProperties($tags))}
<TagRenderingEditableDynamic {tags} {config} {state} {selectedElement} {layer} />
{/if}
{/each}
</AccordionSingle>
</div>
{/if}

View file

@ -7,8 +7,6 @@
import ShowDataLayer from "../Map/ShowDataLayer"
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"
import MaplibreMap from "../Map/MaplibreMap.svelte"
import Loading from "../Base/Loading.svelte"
import { Utils } from "../../Utils"
import DelayedComponent from "../Base/DelayedComponent.svelte"
export let state: SpecialVisualizationState

View file

@ -9,7 +9,7 @@ export default class NoteCommentElement {
*/
public static addCommentTo(
txt: string,
tags: UIEventSource<any>,
tags: UIEventSource<Record<string, string>>,
state: { osmConnection: { userDetails: Store<{ name: string; uid: number }> } }
) {
const comments: any[] = JSON.parse(tags.data["comments"])

View file

@ -27,11 +27,11 @@
const _onlyForLabels = new Set(onlyForLabels)
/**
* If set, only questions _not_ having these labels will be shown.
* This is used for a partial questionbox
* This is used for a partial questionbox. If both 'onlyFor' and 'notFor' are set, questions must accept both
*/
export let notForLabels: string[] | undefined = undefined
const _notForLabels = new Set(notForLabels)
let showAllQuestionsAtOnce: Store<boolean> =
export let showAllQuestionsAtOnce: Store<boolean> =
state.userRelatedState?.showAllQuestionsAtOnce ?? new ImmutableStore(false)
function allowed(labels: string[]) {
@ -44,7 +44,7 @@
return true
}
const baseQuestions = (layer?.tagRenderings ?? [])?.filter(
let baseQuestions = (layer?.tagRenderings ?? [])?.filter(
(tr) => allowed(tr.labels) && tr.question !== undefined
)
@ -119,7 +119,6 @@
}, 50)
}
</script>
{#if $loginEnabled}
<div
bind:this={questionboxElem}

View file

@ -22,7 +22,8 @@
export let id: string = undefined
if (config === undefined) {
throw "Config is undefined in tagRenderingAnswer"
console.error("TagRenderingAnswer: Config is undefined")
throw ("Config is undefined in tagRenderingAnswer")
}
let trs: Store<{ then: Translation; icon?: string; iconClass?: string }[]> = tags.mapD((tags) =>
Utils.NoNull(config?.GetRenderValues(tags))

View file

@ -23,11 +23,11 @@
export let getCountry = () => "?"
onMount(() => {
console.log("Setting selected unit based on country", getCountry(), upstreamValue.data)
console.log("Setting selected unit based on country", getCountry(), "and upstream value:", upstreamValue.data)
if (upstreamValue.data === undefined || upstreamValue.data === "") {
// Init the selected unit
let denomination: Denomination = unit.getDefaultDenomination(getCountry)
console.log("Found denom", denomination.canonical)
console.log("Found denom", denomination.canonical, "available denominations are:", unit.denominations.map(denom => denom.canonical))
selectedUnit.setData(denomination.canonical)
}
})

View file

@ -56,7 +56,7 @@
{/if}
<a
class="link-underline"
href="https://github.com/pietervdvn/MapComplete/issues/1782"
href="https://source.mapcomplete.org/MapComplete/MapComplete/issues/1782"
target="_blank"
rel="noopener noreferrer"
>

View file

@ -11,11 +11,21 @@
import { TrashIcon } from "@babeard/svelte-heroicons/mini"
import { CogIcon } from "@rgossiaux/svelte-heroicons/solid"
import Tr from "../Base/Tr.svelte"
import { MinimalThemeInformation } from "../../Models/ThemeConfig/ThemeConfig"
export let state: SpecialVisualizationState
let searchTerm = state.searchState.searchTerm
let recentThemes = state.userRelatedState.recentlyVisitedThemes.value.map((themes) =>
themes.filter((th) => th !== state.theme.id).slice(0, 6)
let recentThemes = state.userRelatedState.recentlyVisitedThemes.value.map((themes) => {
const recent = themes.filter((th) => th !== state.theme.id).slice(0, 6)
const deduped: MinimalThemeInformation[] = []
for (const theme of recent) {
if (deduped.some(th => th.id === theme.id)) {
continue
}
deduped.push(theme)
}
return deduped
}
)
let themeResults = state.searchState.themeSuggestions

View file

@ -65,7 +65,7 @@ export class ImageVisualisations {
args: [
{
name: "image_key",
defaultValue: AllImageProviders.defaultKeys.join(","),
defaultValue: AllImageProviders.defaultKeys.join(";"),
doc: "The keys given to the images, e.g. if <span class='literal-code'>image</span> is given, the first picture URL will be added as <span class='literal-code'>image</span>, the second as <span class='literal-code'>image:0</span>, the third as <span class='literal-code'>image:1</span>, etc... Multiple values are allowed if ';'-separated ",
},
],
@ -73,7 +73,7 @@ export class ImageVisualisations {
constr: (state, tags, args) => {
let imagePrefixes: string[] = undefined
if (args.length > 0) {
imagePrefixes = [].concat(...args.map((a) => a.split(",")))
imagePrefixes = [].concat(...args.map((a) => a.split(";")))
}
const images = AllImageProviders.loadImagesFor(tags, imagePrefixes)
const estimated = tags.mapD((tags) =>
@ -89,8 +89,9 @@ export class ImageVisualisations {
needsUrls: [Imgur.apiUrl, ...Imgur.supportingUrls],
args: [
{
name: "image-key",
name: "image_key",
doc: "Image tag to add the URL to (or image-tag:0, image-tag:1 when multiple images are added)",
defaultValue: "panoramax",
required: false,
},
{

View file

@ -1,8 +1,4 @@
import {
SpecialVisualization,
SpecialVisualizationState,
SpecialVisualizationSvelte,
} from "../SpecialVisualization"
import { SpecialVisualization, SpecialVisualizationState, SpecialVisualizationSvelte } from "../SpecialVisualization"
import Constants from "../../Models/Constants"
import { UIEventSource } from "../../Logic/UIEventSource"
import { Feature } from "geojson"
@ -136,10 +132,10 @@ export class NoteVisualisations {
group: "notes",
needsUrls: [Imgur.apiUrl, ...Imgur.supportingUrls],
constr: (state, tags, args, feature, layer) => {
constr: (state, tags, args, feature) => {
const id = tags.data[args[0] ?? "id"]
tags = state.featureProperties.getStore(id)
return new SvelteUIElement(UploadImage, { state, tags, layer, feature })
return new SvelteUIElement(UploadImage, { state, tags, feature })
},
},
{

View file

@ -7,12 +7,13 @@ import LoginButton from "../Base/LoginButton.svelte"
import ThemeViewState from "../../Models/ThemeViewState"
import OrientationDebugPanel from "../Debug/OrientationDebugPanel.svelte"
import AllTagsPanel from "../Popup/AllTagsPanel/AllTagsPanel.svelte"
import { ImmutableStore, UIEventSource } from "../../Logic/UIEventSource"
import { UIEventSource } from "../../Logic/UIEventSource"
import ClearCaches from "../Popup/ClearCaches.svelte"
import Locale from "../i18n/Locale"
import LanguageUtils from "../../Utils/LanguageUtils"
import LanguagePicker from "../InputElement/LanguagePicker.svelte"
import PendingChangesIndicator from "../BigComponents/PendingChangesIndicator.svelte"
import { Utils } from "../../Utils"
export class SettingsVisualisations {
public static initList(): SpecialVisualizationSvelte[] {
@ -79,14 +80,23 @@ export class SettingsVisualisations {
group: "settings",
docs: "Shows the current state of storage",
args: [],
constr(state: SpecialVisualizationState): SvelteUIElement {
constr: function(state: SpecialVisualizationState): SvelteUIElement {
const data = {}
for (const key in localStorage) {
data[key] = localStorage[key]
}
const tags = new ImmutableStore(data)
const tags = new UIEventSource(data)
navigator.storage.estimate().then(estimate => {
data["__usage:current:bytes"] = estimate.usage
data["__usage:current:human"] = Utils.toHumanByteSize(estimate.usage)
data["__usage:quota:bytes"] = estimate.quota
data["__usage:quota:human"] = Utils.toHumanByteSize(estimate.quota)
tags.ping()
})
return new SvelteUIElement(AllTagsPanel, { state, tags })
},
}
},
{
funcName: "clear_caches",

View file

@ -1,6 +1,6 @@
import { SpecialVisualizationState, SpecialVisualizationSvelte } from "../SpecialVisualization"
import SvelteUIElement from "../Base/SvelteUIElement"
import { UIEventSource } from "../../Logic/UIEventSource"
import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
import { Feature } from "geojson"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import Questionbox from "../Popup/TagRendering/Questionbox.svelte"
@ -29,8 +29,13 @@ class QuestionViz implements SpecialVisualizationSvelte {
},
{
name: "blacklisted-labels",
doc: "One or more ';'-separated labels of questions which should _not_ be included. Default: 'hidden'",
doc: "One or more ';'-separated labels of questions which should _not_ be included. Note that the questionbox which is added by default will blacklist 'hidden'"
},
{
name: "show_all",
default: "user-preference",
doc: "Either `no`, `yes` or `user-preference`. Indicates if all questions should be shown at once"
}
]
svelteBased = true
group: "default"
@ -50,8 +55,12 @@ class QuestionViz implements SpecialVisualizationSvelte {
?.split(";")
?.map((s) => s.trim())
?.filter((s) => s !== "")
if (blacklist.length === 0) {
blacklist.push("hidden")
const showAll = args[2]
let showAllQuestionsAtOnce: Store<boolean> = state.userRelatedState?.showAllQuestionsAtOnce
if (showAll === "yes") {
showAllQuestionsAtOnce = new ImmutableStore(true)
} else if (showAll === "no") {
showAllQuestionsAtOnce = new ImmutableStore(false)
}
return new SvelteUIElement(Questionbox, {
layer,
@ -60,6 +69,7 @@ class QuestionViz implements SpecialVisualizationSvelte {
state,
onlyForLabels: labels,
notForLabels: blacklist,
showAllQuestionsAtOnce
})
}
}

View file

@ -11,12 +11,12 @@
import { AccordionItem } from "flowbite-svelte"
import ThemeViewState from "../../Models/ThemeViewState"
import DefaultIcon from "../Map/DefaultIcon.svelte"
import { Store } from "../../Logic/UIEventSource"
export let layer: LayerConfig
export let state: ThemeViewState
let bbox = state.mapProperties.bounds
let elements: Feature[] = state.perLayer.get(layer.id).GetFeaturesWithin($bbox)
$: elements = state.perLayer.get(layer.id).GetFeaturesWithin($bbox)
let elements: Store<Feature[]> = bbox.mapD(bbox => state.perLayer.get(layer.id).GetFeaturesWithin(bbox))
let trs = layer.tagRenderings.filter((tr) => tr.question)
</script>
@ -31,19 +31,19 @@
<DefaultIcon {layer} />
</div>
<Tr t={layer.name} />
({elements.length} elements in view)
({$elements.length} elements in view)
</div>
{#if elements === undefined}
{#if $elements === undefined}
<Loading />
{:else if elements.length === 0}
{:else if $elements.length === 0}
No features in view
{:else}
<div class="flex w-full flex-wrap gap-x-4 gap-y-4">
{#each trs as tr}
<ToSvelte
construct={() =>
new TagRenderingChart(elements, tr, {
new TagRenderingChart($elements, tr, {
chartclasses: "w-full self-center",
includeTitle: true,
}).SetClass(tr.multiAnswer ? "w-128" : "w-96")}

View file

@ -429,6 +429,11 @@
enableTrafficLight.addCallbackAndRunD((_) => {
setTrafficLight(all.data)
})
let now = Math.round(new Date().getTime() / 1000)
let twoDaysAgo = now - 2 * 24 * 60 * 60
let lastHour = now - 60 * 60
</script>
<h1>MapComplete status indicators</h1>
@ -450,6 +455,18 @@
<ServiceIndicator {service} />
{/each}
<h3>Panoramax & OSM.fr Blurring service</h3>
<a href="https://status.thibaultmol.link/status/panoramax">Panoramax.MapComplete.org status page</a>
<a href="https://munin.openstreetmap.fr/osm37.openstreetmap.fr/blur.vm.openstreetmap.fr/index.html#system"
target="_blank" rel="noopener">
See more statistics for the blurring service
</a>
<img
style="width: 80rem"
src={`https://munin.openstreetmap.fr/munin-cgi/munin-cgi-graph/osm37.openstreetmap.fr/blur.vm.openstreetmap.fr/nvidia_gpu_power-pinpoint=${twoDaysAgo},${now}.png?&lower_limit=&upper_limit=&size_x=800&size_y=400`} />
<img
style="width: 80rem"
src={`https://munin.openstreetmap.fr/munin-cgi/munin-cgi-graph/osm37.openstreetmap.fr/blur.vm.openstreetmap.fr/nvidia_gpu_power-pinpoint=${lastHour},${now}.png?&lower_limit=&upper_limit=&size_x=800&size_y=400`} />
<button
on:click={() => {
fetch(Constants.ErrorReportServer, {

View file

@ -131,7 +131,7 @@
</div>
<a
target="_blank"
href="https://github.com/pietervdvn/MapComplete/blob/develop/Docs/Tags_format.md"
href="https://source.mapcomplete.org/MapComplete/MapComplete/src/branch/develop/Docs/Tags_format.md"
>
<QuestionMarkCircle class="h-6 w-6" />
</a>

View file

@ -243,7 +243,7 @@
Someone might be able to help you
</li>
<li>
File <a href="https://github.com/pietervdvn/MapComplete/issues">an issue</a>
File <a href="https://source.mapcomplete.org/MapComplete/MapComplete/issues">an issue</a>
</li>
<li>
Contact the devs via <a href="mailto:info@posteo.net">email</a>