Merge develop

This commit is contained in:
Pieter Vander Vennet 2024-02-15 17:48:26 +01:00
commit f0823f4c4d
524 changed files with 18747 additions and 8546 deletions

View file

@ -19,7 +19,7 @@
import { LayoutInformation } from "../Models/ThemeConfig/LayoutConfig"
import * as themeOverview from "../assets/generated/theme_overview.json"
import UnofficialThemeList from "./BigComponents/UnofficialThemeList.svelte"
import Eye from "@babeard/svelte-heroicons/mini/Eye"
import Eye from "../assets/svg/Eye.svelte"
const featureSwitches = new OsmConnectionFeatureSwitches()
const osmConnection = new OsmConnection({
@ -56,7 +56,7 @@
.filter((key) => key.startsWith(prefix))
.map((key) => key.substring(prefix.length, key.length - "-enabled".length))
)
return hiddenThemes.filter((theme) => knownIds.has(theme.id))
return hiddenThemes.filter((theme) => knownIds.has(theme.id) || state.osmConnection.userDetails.data.name === "Pieter Vander Vennet")
})
}
</script>

View file

@ -1,26 +0,0 @@
import BaseUIElement from "../BaseUIElement"
import { VariableUiElement } from "./VariableUIElement"
import { Stores } from "../../Logic/UIEventSource"
import Loading from "./Loading"
export default class AsyncLazy extends BaseUIElement {
private readonly _f: () => Promise<BaseUIElement>
constructor(f: () => Promise<BaseUIElement>) {
super()
this._f = f
}
protected InnerConstructElement(): HTMLElement {
// The caching of the BaseUIElement will guarantee that _f will only be called once
return new VariableUiElement(
Stores.FromPromise(this._f()).map((el) => {
if (el === undefined) {
return new Loading()
}
return el
})
).ConstructElement()
}
}

View file

@ -16,6 +16,7 @@
import { ariaLabelStore } from "../../Utils/ariaLabel"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import Center from "../../assets/svg/Center.svelte"
import Tr from "./Tr.svelte"
export let state: SpecialVisualizationState
export let feature: Feature
@ -38,6 +39,13 @@
let relativeDirections = Translations.t.general.visualFeedback.directionsRelative
let absoluteDirections = Translations.t.general.visualFeedback.directionsAbsolute
function round10(n :number){
if(n < 50){
return n
}
return Math.round(n / 10) * 10
}
let closeToCurrentLocation = state.geolocation.geolocationState.currentGPSLocation.map(
(gps) => {
@ -53,7 +61,7 @@
)
let labelFromCenter: Store<string> = bearingAndDist.mapD(
({ bearing, dist }) => {
const distHuman = GeoOperations.distanceToHuman(dist)
const distHuman = GeoOperations.distanceToHuman(round10(dist))
const lang = Locale.language.data
const t = absoluteDirections[GeoOperations.bearingToHuman(bearing)]
const mainTr = Translations.t.general.visualFeedback.fromMapCenter.Subs({
@ -75,7 +83,7 @@
> = state.geolocation.geolocationState.currentGPSLocation.mapD(({ longitude, latitude }) => {
let gps = [longitude, latitude]
let bearing = Math.round(GeoOperations.bearing(gps, fcenter))
let dist = Math.round(GeoOperations.distanceBetween(fcenter, gps))
let dist = round10(Math.round(GeoOperations.distanceBetween(fcenter, gps)))
return { bearing, dist }
})
let labelFromGps: Store<string | undefined> = bearingAndDistGps.mapD(
@ -84,7 +92,6 @@
const lang = Locale.language.data
let bearingHuman: string
if (compass.data !== undefined) {
console.log("compass:", compass.data)
const bearingRelative = bearing - compass.data
const t = relativeDirections[GeoOperations.bearingToHumanRelative(bearingRelative)]
bearingHuman = t.textFor(lang)
@ -119,26 +126,36 @@
</script>
{#if $bearingAndDistGps === undefined}
<button
class={twMerge("soft relative rounded-full p-1", size)}
<!--
Important: one would expect this to be a button - it certainly behaves as one
However, this breaks the live-reading functionality (at least with Orca+FF),
so we use a 'div' and add on:click manually
-->
<div
class={twMerge("soft relative flex justify-center items-center border border-black rounded-full cursor-pointer p-1", size)}
on:click={() => focusMap()}
use:ariaLabelStore={label}
>
<Center class="h-7 w-7" />
</button>
<Center class=" h-6 w-6" />
</div>
{:else}
<button
class={twMerge("soft relative rounded-full", size)}
<div
class={twMerge("soft relative rounded-full border-black border", size)}
on:click={() => focusMap()}
use:ariaLabelStore={label}
>
<div
class={twMerge(
"absolute top-0 left-0 flex items-center justify-center break-words text-sm",
"absolute top-0 left-0 flex items-center justify-center break-words text-xs cursor-pointer",
size
)}
>
<div aria-hidden="true">
{GeoOperations.distanceToHuman($bearingAndDistGps?.dist)}
</div>
<div class="offscreen">
{$label}
</div>
</div>
{#if $bearingFromGps !== undefined}
<div class={twMerge("absolute top-0 left-0 rounded-full", size)}>
@ -148,5 +165,16 @@
/>
</div>
{/if}
</button>
</div>
{/if}
<style>
.offscreen {
clip: rect(1px, 1px, 1px, 1px);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap; /* added line */
width: 1px;
}
</style>

View file

@ -79,7 +79,7 @@
<label
class={twMerge(cls, drawAttention ? "glowing-shadow" : "")}
for={id}
on:click={() => {
on:click|preventDefault={() => {
inputElement.click()
}}
style="margin-left:0"

View file

@ -1,50 +0,0 @@
import BaseUIElement from "../BaseUIElement"
import { UIEventSource } from "../../Logic/UIEventSource"
import { VariableUiElement } from "./VariableUIElement"
import Combine from "./Combine"
import Locale from "../i18n/Locale"
import { Utils } from "../../Utils"
export default class FilteredCombine extends VariableUiElement {
/**
* Only shows item matching the search
* If predicate of an item is undefined, it will be filtered out as soon as a non-null or non-empty search term is given
* @param entries
* @param searchedValue
* @param options
*/
constructor(
entries: {
element: BaseUIElement | string
predicate?: (s: string) => boolean
}[],
searchedValue: UIEventSource<string>,
options?: {
onEmpty?: BaseUIElement | string
innerClasses: string
}
) {
entries = Utils.NoNull(entries)
super(
searchedValue.map(
(searchTerm) => {
if (searchTerm === undefined || searchTerm === "") {
return new Combine(entries.map((e) => e.element)).SetClass(
options?.innerClasses ?? ""
)
}
const kept = entries.filter(
(entry) => entry?.predicate !== undefined && entry.predicate(searchTerm)
)
if (kept.length === 0) {
return options?.onEmpty
}
return new Combine(kept.map((entry) => entry.element)).SetClass(
options?.innerClasses ?? ""
)
},
[Locale.language]
)
)
}
}

View file

@ -1,7 +1,6 @@
import { VariableUiElement } from "./VariableUIElement"
import Locale from "../i18n/Locale"
import Link from "./Link"
import Svg from "../../Svg"
import SvelteUIElement from "./SvelteUIElement"
import Translate from "../../assets/svg/Translate.svelte"

View file

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

View file

@ -2,15 +2,14 @@
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import Translations from "../i18n/Translations.js"
import Tr from "./Tr.svelte"
import ToSvelte from "./ToSvelte.svelte"
import Svg from "../../Svg"
import Login from "../../assets/svg/Login.svelte"
export let osmConnection: OsmConnection
export let clss: string | undefined = undefined
</script>
<button class={clss} on:click={() => osmConnection.AttemptLogin()} style="margin-left: 0">
<ToSvelte construct={Svg.login_svg().SetClass("w-12 m-1")} />
<Login class="w-12 m-1" />
<slot>
<Tr t={Translations.t.general.loginWithOpenStreetMap} />
</slot>

View file

@ -1,9 +1,6 @@
import BaseUIElement from "../BaseUIElement"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { Store } from "../../Logic/UIEventSource"
import { UIElement } from "../UIElement"
import { VariableUiElement } from "./VariableUIElement"
import Lazy from "./Lazy"
import Loading from "./Loading"
import SvelteUIElement from "./SvelteUIElement"
import SubtleLink from "./SubtleLink.svelte"
import Translations from "../i18n/Translations"

View file

@ -1,88 +0,0 @@
<script lang="ts">
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import type { RasterLayerPolygon } from "../../Models/RasterLayers"
import { AvailableRasterLayers } from "../../Models/RasterLayers"
import { createEventDispatcher, onDestroy } from "svelte"
import Svg from "../../Svg"
import { Map as MlMap } from "maplibre-gl"
import type { MapProperties } from "../../Models/MapProperties"
import OverlayMap from "../Map/OverlayMap.svelte"
import RasterLayerPicker from "../Map/RasterLayerPicker.svelte"
export let mapproperties: MapProperties
export let normalMap: UIEventSource<MlMap>
/**
* The current background (raster) layer of the polygon.
* This is undefined if a vector layer is used
*/
let rasterLayer: UIEventSource<RasterLayerPolygon | undefined> = mapproperties.rasterLayer
let name = rasterLayer.data?.properties?.name
let icon = Svg.satellite_svg()
onDestroy(
rasterLayer.addCallback((polygon) => {
name = polygon.properties?.name
})
)
/**
* The layers that this component can offer as a choice.
*/
export let availableRasterLayers: Store<RasterLayerPolygon[]>
let raster0 = new UIEventSource<RasterLayerPolygon>(undefined)
let raster1 = new UIEventSource<RasterLayerPolygon>(undefined)
let currentLayer: RasterLayerPolygon
function updatedAltLayer() {
const available = availableRasterLayers.data
const current = rasterLayer.data
const defaultLayer = AvailableRasterLayers.maptilerDefaultLayer
const firstOther = available.find((l) => l !== defaultLayer)
const secondOther = available.find((l) => l !== defaultLayer && l !== firstOther)
raster0.setData(firstOther === current ? defaultLayer : firstOther)
raster1.setData(secondOther === current ? defaultLayer : secondOther)
}
updatedAltLayer()
onDestroy(mapproperties.rasterLayer.addCallbackAndRunD(updatedAltLayer))
onDestroy(availableRasterLayers.addCallbackAndRunD(updatedAltLayer))
function use(rasterLayer: UIEventSource<RasterLayerPolygon>): () => void {
return () => {
currentLayer = undefined
mapproperties.rasterLayer.setData(rasterLayer.data)
}
}
const dispatch = createEventDispatcher<{ copyright_clicked }>()
</script>
<div class="flex items-end opacity-50 hover:opacity-100">
<div class="flex flex-col md:flex-row">
<button class="m-0 h-12 w-16 overflow-hidden p-0 md:h-16 md:w-16" on:click={use(raster0)}>
<OverlayMap
placedOverMap={normalMap}
placedOverMapProperties={mapproperties}
rasterLayer={raster0}
/>
</button>
<button class="m-0 h-12 w-16 overflow-hidden p-0 md:h-16 md:w-16" on:click={use(raster1)}>
<OverlayMap
placedOverMap={normalMap}
placedOverMapProperties={mapproperties}
rasterLayer={raster1}
/>
</button>
</div>
<div class="ml-1 flex h-fit flex-col gap-y-1 text-sm">
<div class="low-interaction w-64 rounded p-1">
<RasterLayerPicker
availableLayers={availableRasterLayers}
value={mapproperties.rasterLayer}
/>
</div>
<button class="small" on:click={() => dispatch("copyright_clicked")}>© OpenStreetMap</button>
</div>
</div>

View file

@ -11,11 +11,13 @@
import { placeholder } from "../../Utils/placeholder"
import { SearchIcon } from "@rgossiaux/svelte-heroicons/solid"
import { ariaLabel } from "../../Utils/ariaLabel"
import { GeoLocationState } from "../../Logic/State/GeoLocationState"
export let perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer> | undefined = undefined
export let bounds: UIEventSource<BBox>
export let selectedElement: UIEventSource<Feature> | undefined = undefined
export let geolocationState: GeoLocationState | undefined = undefined
export let clearAfterView: boolean = true
let searchContents: string = ""
export let triggerSearch: UIEventSource<any> = new UIEventSource<any>(undefined)
@ -55,6 +57,8 @@
async function performSearch() {
try {
isRunning = true
geolocationState?.allowMoving.setData(true)
geolocationState?.requestMoment.setData(undefined) // If the GPS is still searching for a fix, we say that we don't want tozoom to it anymore
searchContents = searchContents?.trim() ?? ""
if (searchContents === "") {

View file

@ -5,7 +5,9 @@ import Combine from "../Base/Combine"
import { FixedUiElement } from "../Base/FixedUiElement"
import { Utils } from "../../Utils"
import BaseUIElement from "../BaseUIElement"
import Svg from "../../Svg"
import SvelteUIElement from "../Base/SvelteUIElement"
import Up from "../../assets/svg/Up.svelte"
import Circle from "../../assets/svg/Circle.svelte"
export default class Histogram<T> extends VariableUiElement {
private static defaultPalette = [
@ -34,11 +36,11 @@ export default class Histogram<T> extends VariableUiElement {
sortMode.map((m) => {
switch (m) {
case "name":
return Svg.up_svg()
return new SvelteUIElement(Up)
case "name-rev":
return Svg.up_svg().SetStyle("transform: rotate(180deg)")
return new SvelteUIElement(Up).SetStyle("transform: rotate(180deg)")
default:
return Svg.circle_svg()
return new SvelteUIElement(Circle)
}
})
)
@ -56,11 +58,11 @@ export default class Histogram<T> extends VariableUiElement {
sortMode.map((m) => {
switch (m) {
case "count":
return Svg.up_svg()
return new SvelteUIElement(Up)
case "count-rev":
return Svg.up_svg().SetStyle("transform: rotate(180deg)")
return new SvelteUIElement(Up).SetStyle("transform: rotate(180deg)")
default:
return Svg.circle_svg()
return new SvelteUIElement(Circle)
}
})
)

View file

@ -1,11 +0,0 @@
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import { FixedUiElement } from "../Base/FixedUiElement"
export default class IndexText extends Combine {
constructor() {
super([])
this.SetClass("flex flex-row")
}
}

View file

@ -1,120 +1,120 @@
<script lang="ts">
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"
import ShowDataLayer from "../Map/ShowDataLayer"
import type {
FeatureSource,
FeatureSourceForLayer,
} from "../../Logic/FeatureSource/FeatureSource"
import SnappingFeatureSource from "../../Logic/FeatureSource/Sources/SnappingFeatureSource"
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"
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"
import ShowDataLayer from "../Map/ShowDataLayer"
import type {
FeatureSource,
FeatureSourceForLayer,
} from "../../Logic/FeatureSource/FeatureSource"
import SnappingFeatureSource from "../../Logic/FeatureSource/Sources/SnappingFeatureSource"
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"
/**
* An advanced location input, which has support to:
* - Show more layers
* - Snap to layers
*
* This one is mostly used to insert new points, including when importing
*/
export let state: SpecialVisualizationState
/**
* The start coordinate
*/
export let coordinate: { lon: number; lat: number }
/**
* An advanced location input, which has support to:
* - Show more layers
* - Snap to layers
*
* This one is mostly used to insert new points, including when importing
*/
export let state: SpecialVisualizationState
/**
* The start coordinate
*/
export let coordinate: { lon: number; lat: number }
/**
* The center of the map at all times
* If undefined at the beginning, 'coordinate' will be used
*/
export let value: UIEventSource<{ lon: number; lat: number }>
if (value.data === undefined) {
value.setData(coordinate)
}
if (coordinate === undefined) {
coordinate = value.data
}
export let snapToLayers: string[] | undefined
export let targetLayer: LayerConfig | undefined
export let maxSnapDistance: number = undefined
export let snappedTo: UIEventSource<string | undefined>
let preciseLocation: UIEventSource<{ lon: number; lat: number }> = new UIEventSource<{
lon: number
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> = {
zoom: new UIEventSource<number>(19),
maxbounds: new UIEventSource(undefined),
/*If no snapping needed: the value is simply the map location;
* If snapping is needed: the value will be set later on by the snapping feature source
* */
location:
snapToLayers?.length > 0
? new UIEventSource<{ lon: number; lat: number }>(coordinate)
: value,
bounds: new UIEventSource<BBox>(undefined),
allowMoving: new UIEventSource<boolean>(true),
allowZooming: new UIEventSource<boolean>(true),
minzoom: new UIEventSource<number>(18),
rasterLayer: UIEventSource.feedFrom(state.mapProperties.rasterLayer),
}
if (targetLayer) {
const featuresForLayer = state.perLayer.get(targetLayer.id)
if (featuresForLayer) {
new ShowDataLayer(map, {
layer: targetLayer,
features: featuresForLayer,
})
/**
* The center of the map at all times
* If undefined at the beginning, 'coordinate' will be used
*/
export let value: UIEventSource<{ lon: number; lat: number }>
if (value.data === undefined) {
value.setData(coordinate)
}
}
if (snapToLayers?.length > 0) {
const snapSources: FeatureSource[] = []
for (const layerId of snapToLayers ?? []) {
const layer: FeatureSourceForLayer = state.perLayer.get(layerId)
snapSources.push(layer)
if (layer.features === undefined) {
continue
}
new ShowDataLayer(map, {
layer: layer.layer.layerDef,
zoomToFeatures: false,
features: layer,
})
if (coordinate === undefined) {
coordinate = value.data
}
const snappedLocation = new SnappingFeatureSource(
new FeatureSourceMerger(...Utils.NoNull(snapSources)),
// We snap to the (constantly updating) map location
initialMapProperties.location,
{
maxDistance: maxSnapDistance ?? 15,
allowUnsnapped: true,
snappedTo,
snapLocation: value,
}
)
export let snapToLayers: string[] | undefined
export let targetLayer: LayerConfig | undefined
export let maxSnapDistance: number = undefined
new ShowDataLayer(map, {
layer: targetLayer,
features: snappedLocation,
})
}
export let snappedTo: UIEventSource<string | undefined>
let preciseLocation: UIEventSource<{ lon: number; lat: number }> = new UIEventSource<{
lon: number
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),
maxbounds: new UIEventSource(undefined),
/*If no snapping needed: the value is simply the map location;
* If snapping is needed: the value will be set later on by the snapping feature source
* */
location:
snapToLayers?.length > 0
? new UIEventSource<{ lon: number; lat: number }>(coordinate)
: value,
bounds: new UIEventSource<BBox>(undefined),
allowMoving: new UIEventSource<boolean>(true),
allowZooming: new UIEventSource<boolean>(true),
minzoom: new UIEventSource<number>(18),
rasterLayer: UIEventSource.feedFrom(state.mapProperties.rasterLayer),
}
if (targetLayer) {
const featuresForLayer = state.perLayer.get(targetLayer.id)
if (featuresForLayer) {
new ShowDataLayer(map, {
layer: targetLayer,
features: featuresForLayer,
})
}
}
if (snapToLayers?.length > 0) {
const snapSources: FeatureSource[] = []
for (const layerId of snapToLayers ?? []) {
const layer: FeatureSourceForLayer = state.perLayer.get(layerId)
snapSources.push(layer)
if (layer.features === undefined) {
continue
}
new ShowDataLayer(map, {
layer: layer.layer.layerDef,
zoomToFeatures: false,
features: layer,
})
}
const snappedLocation = new SnappingFeatureSource(
new FeatureSourceMerger(...Utils.NoNull(snapSources)),
// We snap to the (constantly updating) map location
initialMapProperties.location,
{
maxDistance: maxSnapDistance ?? 15,
allowUnsnapped: true,
snappedTo,
snapLocation: value,
}
)
new ShowDataLayer(map, {
layer: targetLayer,
features: snappedLocation,
})
}
</script>
<LocationInput
@ -123,7 +123,7 @@
mapProperties={initialMapProperties}
value={preciseLocation}
initialCoordinate={coordinate}
maxDistanceInMeters="50"
maxDistanceInMeters={50}
>
<slot name="image" slot="image">
<Move_arrows class="h-full max-h-24" />

View file

@ -17,6 +17,7 @@
import ToSvelte from "../Base/ToSvelte.svelte"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
import Search_disable from "../../assets/svg/Search_disable.svelte"
export let search: UIEventSource<string>
@ -27,8 +28,8 @@
<h5>{t.noMatchingThemes.toString()}</h5>
<div class="flex justify-center">
<button on:click={() => search.setData("")}>
<ToSvelte construct={Svg.search_disable_svg().SetClass("w-6 mr-2")} />
<Tr slot="message" t={t.noSearch} />
<Search_disable class="w-6 mr-2" />
<Tr t={t.noSearch} />
</button>
</div>
</div>

View file

@ -4,7 +4,6 @@
**/
import Motion from "../../Sensors/Motion"
import { Geocoding } from "../../Logic/Osm/Geocoding"
import type { MapProperties } from "../../Models/MapProperties"
import Hotkeys from "../Base/Hotkeys"
import Translations from "../i18n/Translations"
import Locale from "../i18n/Locale"
@ -21,7 +20,7 @@
let result = await Geocoding.reverse(
mapProperties.location.data,
mapProperties.zoom.data,
Locale.language.data
Locale.language.data,
)
let properties = result.features[0].properties
currentLocation = properties.display_name
@ -45,7 +44,7 @@
() => {
displayLocation()
},
[Translations.t.hotkeyDocumentation.shakePhone]
[Translations.t.hotkeyDocumentation.shakePhone],
)
Motion.singleton.startListening()

View file

@ -36,7 +36,7 @@
</h3>
<div
class="no-weblate title-icons links-as-button mr-2 flex flex-row flex-wrap items-center gap-x-0.5 p-1 pt-0.5 sm:pt-1"
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)}

View file

@ -14,6 +14,7 @@
import Svg from "../../Svg"
import ToSvelte from "../Base/ToSvelte.svelte"
import { DocumentDuplicateIcon } from "@rgossiaux/svelte-heroicons/outline"
import Share from "../../assets/svg/Share.svelte"
export let state: ThemeViewState
const tr = Translations.t.general.sharescreen
@ -73,7 +74,7 @@
<div class="flex">
{#if typeof navigator?.share === "function"}
<button class="h-8 w-8 shrink-0 p-1" on:click={shareCurrentLink}>
<ToSvelte construct={Svg.share_svg()} />
<Share/>
</button>
{/if}
{#if navigator.clipboard !== undefined}

View file

@ -4,7 +4,6 @@
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import TagRenderingAnswer from "../Popup/TagRendering/TagRenderingAnswer.svelte"
import DirectionIndicator from "../Base/DirectionIndicator.svelte"
import ThemeViewState from "../../Models/ThemeViewState"
export let state: SpecialVisualizationState
export let feature: Feature
@ -30,6 +29,6 @@
{state}
{tags}
/>
<DirectionIndicator {feature} {state} />
</a>
<DirectionIndicator {feature} {state} />
</span>

View file

@ -113,6 +113,7 @@
perLayer={state.perLayer}
{selectedElement}
{triggerSearch}
geolocationState={state.geolocation.geolocationState}
/>
</div>
<button

View file

@ -0,0 +1,69 @@
<script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource"
import type { OsmTags } from "../../Models/OsmFeature"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import type { Feature } from "geojson"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"
import { Tag } from "../../Logic/Tags/Tag"
import Loading from "../Base/Loading.svelte"
export let key: string
export let externalProperties: Record<string, string>
export let tags: UIEventSource<OsmTags>
export let state: SpecialVisualizationState
export let feature: Feature
export let layer: LayerConfig
export let readonly = false
let currentStep: "init" | "applying" | "done" = "init"
/**
* Copy the given key into OSM
* @param key
*/
async function apply(key: string) {
currentStep = "applying"
const change = new ChangeTagAction(
tags.data.id,
new Tag(key, externalProperties[key]),
tags.data,
{
theme: state.layout.id,
changeType: "import",
})
await state.changes.applyChanges(await change.CreateChangeDescriptions())
currentStep = "done"
}
</script>
<tr>
<td><b>{key}</b></td>
<td>
{#if externalProperties[key].startsWith("http")}
<a href={externalProperties[key]} target="_blank">
{externalProperties[key]}
</a>
{:else}
{externalProperties[key]}
{/if}
</td>
{#if !readonly}
<td>
{#if currentStep === "init"}
<button class="small" on:click={() => apply(key)}>
Apply
</button>
{:else if currentStep === "applying"}
<Loading />
{:else if currentStep === "done"}
<div class="thanks">Done</div>
{:else }
<div class="alert">Error</div>
{/if}
</td>
{/if}
</tr>

View file

@ -0,0 +1,144 @@
<script lang="ts">
import LinkableImage from "../Image/LinkableImage.svelte"
import { UIEventSource } from "../../Logic/UIEventSource"
import type { OsmTags } from "../../Models/OsmFeature"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import type { Feature } from "geojson"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import ComparisonAction from "./ComparisonAction.svelte"
import Party from "../../assets/svg/Party.svelte"
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"
import { Tag } from "../../Logic/Tags/Tag"
import { And } from "../../Logic/Tags/And"
import Loading from "../Base/Loading.svelte"
import AttributedImage from "../Image/AttributedImage.svelte"
export let osmProperties: Record<string, string>
export let externalProperties: Record<string, string>
export let tags: UIEventSource<OsmTags>
export let state: SpecialVisualizationState
export let feature: Feature
export let layer: LayerConfig
export let readonly = false
let externalKeys: string[] = (Object.keys(externalProperties))
.sort()
const imageKeyRegex = /image|image:[0-9]+/
console.log("Calculating knwon images")
let knownImages = new Set(Object.keys(osmProperties).filter(k => k.match(imageKeyRegex))
.map(k => osmProperties[k]))
console.log("Known images are:", knownImages)
let unknownImages = externalKeys.filter(k => k.match(imageKeyRegex))
.map(k => externalProperties[k])
.filter(i => !knownImages.has(i))
let propertyKeysExternal = externalKeys.filter(k => k.match(imageKeyRegex) === null)
let missing = propertyKeysExternal.filter(k => osmProperties[k] === undefined)
let same = propertyKeysExternal.filter(key => osmProperties[key] === externalProperties[key])
let different = propertyKeysExternal.filter(key => osmProperties[key] !== undefined && osmProperties[key] !== externalProperties[key])
let currentStep: "init" | "applying_all" | "all_applied" = "init"
async function applyAllMissing() {
currentStep = "applying_all"
const tagsToApply = missing.map(k => new Tag(k, externalProperties[k]))
const change = new ChangeTagAction(
tags.data.id,
new And(tagsToApply),
tags.data,
{
theme: state.layout.id,
changeType: "import",
})
await state.changes.applyChanges(await change.CreateChangeDescriptions())
currentStep = "all_applied"
}
</script>
{#if different.length > 0}
<h3>Conflicting items</h3>
<table>
<tr>
<th>Key</th>
<th>OSM</th>
<th>External</th>
</tr>
{#each different as key}
<tr>
<td>{key}</td>
<td>{osmProperties[key]}</td>
<td>{externalProperties[key]}</td>
</tr>
{/each}
</table>
{/if}
{#if missing.length > 0}
{#if currentStep === "init"}
<table class="w-full">
<tr>
<th>Key</th>
<th>External</th>
</tr>
{#each missing as key}
<ComparisonAction {key} {state} {tags} {externalProperties} {layer} {feature} {readonly} />
{/each}
</table>
{#if !readonly}
<button on:click={() => applyAllMissing()}>Apply all missing values</button>
{/if}
{:else if currentStep === "applying_all"}
<Loading>Applying all missing values</Loading>
{:else if currentStep === "all_applied"}
<div class="thanks">
All values are applied
</div>
{/if}
{/if}
{#if unknownImages.length === 0 && missing.length === 0 && different.length === 0}
<div class="thanks flex items-center gap-x-2 px-2 m-0">
<Party class="w-8 h-8" />
All data from Velopark is also included into OpenStreetMap
</div>
{/if}
{#if unknownImages.length > 0}
{#if readonly}
<div class="w-full overflow-x-auto">
<div class="flex w-max gap-x-2 h-32">
{#each unknownImages as image}
<AttributedImage imgClass="h-32 w-max shrink-0" image={{url:image}} previewedImage={state.previewedImage}/>
{/each}
</div>
</div>
{:else}
{#each unknownImages as image}
<LinkableImage
{tags}
{state}
image={{
pictureUrl: image,
provider: "Velopark",
thumbUrl: image,
details: undefined,
coordinates: undefined,
osmTags : {image}
} }
{feature}
{layer} />
{/each}
{/if}
{/if}

View file

@ -0,0 +1,63 @@
<script lang="ts">/**
* The comparison tool loads json-data from a speficied URL, eventually post-processes it
* and compares it with the current object
*/
import { onMount } from "svelte"
import { Utils } from "../../Utils"
import VeloparkLoader from "../../Logic/Web/VeloparkLoader"
import Loading from "../Base/Loading.svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import { UIEventSource } from "../../Logic/UIEventSource"
import ComparisonTable from "./ComparisonTable.svelte"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import type { Feature } from "geojson"
import type { OsmTags } from "../../Models/OsmFeature"
export let url: string
export let postprocessVelopark: boolean
export let state: SpecialVisualizationState
export let tags: UIEventSource<OsmTags>
export let layer: LayerConfig
export let feature: Feature
export let readonly = false
let data: any = undefined
let error: any = undefined
onMount(async () => {
const _url = tags.data[url]
if (!_url) {
error = "No URL found in attribute" + url
}
try {
console.log("Attempting to download", _url)
const downloaded = await Utils.downloadJsonAdvanced(_url)
if (downloaded["error"]) {
console.error(downloaded)
error = downloaded["error"]
return
}
if (postprocessVelopark) {
data = VeloparkLoader.convert(downloaded["content"])
return
}
data = downloaded["content"]
} catch (e) {
console.error(e)
error = "" + e
}
})
</script>
{#if error !== undefined}
<div class="alert">
Something went wrong: {error}
</div>
{:else if data === undefined}
<Loading>
Loading {$tags[url]}
</Loading>
{:else if data.properties !== undefined}
<ComparisonTable externalProperties={data.properties} osmProperties={$tags} {state} {feature} {layer} {tags} {readonly} />
{/if}

View file

@ -27,7 +27,7 @@
mapExtent: state.mapProperties.bounds.data,
width: maindiv.offsetWidth,
height: maindiv.offsetHeight,
noSelfIntersectingLines: true,
noSelfIntersectingLines,
})
}
</script>

View file

@ -4,7 +4,6 @@
import type { Feature } from "geojson"
import { UIEventSource } from "../../Logic/UIEventSource"
import { GeoOperations } from "../../Logic/GeoOperations"
import DirectionIndicator from "../Base/DirectionIndicator.svelte"
export let feature: Feature
let properties: Record<string, string> = feature.properties
@ -30,7 +29,7 @@
center()
}
const titleIconBlacklist = ["osmlink", "sharelink", "favourite_title_icon"]
let titleIconBlacklist = ["osmlink", "sharelink", "favourite_title_icon"]
</script>
{#if favLayer !== undefined}
@ -52,7 +51,7 @@
class="title-icons links-as-button flex flex-wrap items-center gap-x-0.5 self-end justify-self-end p-1 pt-0.5 sm:pt-1"
>
{#each favConfig.titleIcons as titleIconConfig}
{#if titleIconBlacklist.indexOf(titleIconConfig.id) < 0 && (titleIconConfig.condition?.matchesProperties(properties) ?? true) && (titleIconConfig.metacondition?.matchesProperties( { ...properties, ...state.userRelatedState.preferencesAsTags.data } ) ?? true) && titleIconConfig.IsKnown(properties)}
{#if titleIconBlacklist.indexOf(titleIconConfig.id) < 0 && (titleIconConfig.condition?.matchesProperties(properties) ?? true) && (titleIconConfig.metacondition?.matchesProperties({ ...properties, ...state.userRelatedState.preferencesAsTags.data }) ?? true) && titleIconConfig.IsKnown(properties)}
<div class={titleIconConfig.renderIconClass ?? "flex h-8 w-8 items-center"}>
<TagRenderingAnswer
config={titleIconConfig}

View file

@ -6,7 +6,7 @@
import { DownloadIcon } from "@rgossiaux/svelte-heroicons/solid"
import { Utils } from "../../Utils"
import { GeoOperations } from "../../Logic/GeoOperations"
import type { Feature, LineString, Point } from "geojson"
import type { Feature, Point } from "geojson"
import LoginToggle from "../Base/LoginToggle.svelte"
import LoginButton from "../Base/LoginButton.svelte"
@ -23,7 +23,7 @@
"mapcomplete-favourites-" + new Date().toISOString() + ".geojson",
{
mimetype: "application/vnd.geo+json",
}
},
)
}
@ -34,7 +34,7 @@
"mapcomplete-favourites-" + new Date().toISOString() + ".gpx",
{
mimetype: "{gpx=application/gpx+xml}",
}
},
)
}
</script>
@ -48,7 +48,7 @@
<div class="flex flex-col" on:keypress={(e) => console.log("Got keypress", e)}>
<Tr t={Translations.t.favouritePoi.intro.Subs({ length: $favourites?.length ?? 0 })} />
<Tr t={Translations.t.favouritePoi.priintroPrivacyvacy} />
<Tr t={Translations.t.favouritePoi.introPrivacy} />
{#each $favourites as feature (feature.properties.id)}
<FavouriteSummary {feature} {state} />

View file

@ -26,7 +26,7 @@
on:click={() => {
previewedImage?.setData(image)
}}
on:error={(event) => {
on:error={() => {
if (fallbackImage) {
imgEl.src = fallbackImage
}

View file

@ -21,8 +21,7 @@ export class ImageCarousel extends Toggle {
changes?: Changes
layout: LayoutConfig
previewedImage?: UIEventSource<ProvidedImage>
},
feature: Feature
}
) {
const uiElements = images.map(
(imageURLS: { key: string; url: string; provider: ImageProvider; id: string }[]) => {

View file

@ -6,15 +6,15 @@
import type { ProvidedImage } from "../../Logic/ImageProviders/ImageProvider"
import { UIEventSource } from "../../Logic/UIEventSource"
import Zoomcontrol from "../Zoomcontrol"
import { onDestroy, onMount } from "svelte"
import { onDestroy } from "svelte"
export let image: ProvidedImage
let panzoomInstance = undefined
let panzoomEl: HTMLElement
export let isLoaded: UIEventSource<boolean> = undefined
onDestroy(Zoomcontrol.createLock())
$: {
if (panzoomEl) {
panzoomInstance = panzoom(panzoomEl, {

View file

@ -15,18 +15,15 @@
import SpecialTranslation from "../Popup/TagRendering/SpecialTranslation.svelte"
export let tags: UIEventSource<OsmTags>
export let lon: number
export let lat: number
export let state: SpecialVisualizationState
export let image: P4CPicture
export let feature: Feature
export let layer: LayerConfig
export let linkable = true
let isLinked = Object.values(tags.data).some((v) => image.pictureUrl === v)
let targetValue = Object.values(image.osmTags)[0]
let isLinked = new UIEventSource(Object.values(tags.data).some((v) => targetValue === v))
const t = Translations.t.image.nearby
const c = [lon, lat]
const providedImage: ProvidedImage = {
url: image.thumbUrl ?? image.pictureUrl,
url_hd: image.pictureUrl,
@ -36,10 +33,11 @@
id: Object.values(image.osmTags)[0],
}
$: {
function applyLink(isLinked :boolean) {
console.log("Applying linked image", isLinked, targetValue)
const currentTags = tags.data
const key = Object.keys(image.osmTags)[0]
const url = image.osmTags[key]
const url = targetValue
if (isLinked) {
const action = new LinkImageAction(currentTags.id, key, url, tags, {
theme: tags.data._orig_theme ?? state.layout.id,
@ -59,6 +57,7 @@
}
}
}
isLinked.addCallback(isLinked => applyLink(isLinked))
</script>
<div class="flex w-fit shrink-0 flex-col">
@ -71,7 +70,7 @@
</div>
{#if linkable}
<label>
<input bind:checked={isLinked} type="checkbox" />
<input bind:checked={$isLinked} type="checkbox" />
<SpecialTranslation t={t.link} {tags} {state} {layer} {feature} />
</label>
{/if}

View file

@ -55,7 +55,7 @@
<div class="flex w-full space-x-1 overflow-x-auto" style="scroll-snap-type: x proximity">
{#each $images as image (image.pictureUrl)}
<span class="w-fit shrink-0" style="scroll-snap-align: start">
<LinkableImage {tags} {image} {state} {lon} {lat} {feature} {layer} {linkable} />
<LinkableImage {tags} {image} {state} {feature} {layer} {linkable} />
</span>
{/each}
</div>

View file

@ -1 +1 @@
This is the old, deprecated directory. New, SVelte-based items go into `InputElement`
This is the old, deprecated directory. New, Svelte-based items go into `InputElement`

View file

@ -1,304 +0,0 @@
import { UIElement } from "../UIElement"
import { InputElement } from "./InputElement"
import BaseUIElement from "../BaseUIElement"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Translations from "../i18n/Translations"
import Locale from "../i18n/Locale"
import Combine from "../Base/Combine"
import { TextField } from "./TextField"
import Svg from "../../Svg"
import { VariableUiElement } from "../Base/VariableUIElement"
/**
* A single 'pill' which can hide itself if the search criteria is not met
*/
class SelfHidingToggle extends UIElement implements InputElement<boolean> {
public readonly _selected: UIEventSource<boolean>
public readonly isShown: Store<boolean> = new UIEventSource<boolean>(true)
public readonly matchesSearchCriteria: Store<boolean>
public readonly forceSelected: UIEventSource<boolean>
private readonly _shown: BaseUIElement
private readonly _squared: boolean
public constructor(
shown: string | BaseUIElement,
mainTerm: Record<string, string>,
search: Store<string>,
options?: {
searchTerms?: Record<string, string[]>
selected?: UIEventSource<boolean>
forceSelected?: UIEventSource<boolean>
squared?: boolean
/* Hide, if not selected*/
hide?: Store<boolean>
}
) {
super()
this._shown = Translations.W(shown)
this._squared = options?.squared ?? false
const searchTerms: Record<string, string[]> = {}
for (const lng in options?.searchTerms ?? []) {
if (lng === "_context") {
continue
}
searchTerms[lng] = options?.searchTerms[lng]?.map(SelfHidingToggle.clean)
}
for (const lng in mainTerm) {
if (lng === "_context") {
continue
}
const main = SelfHidingToggle.clean(mainTerm[lng])
searchTerms[lng] = [main].concat(searchTerms[lng] ?? [])
}
const selected = (this._selected = options?.selected ?? new UIEventSource<boolean>(false))
const forceSelected = (this.forceSelected =
options?.forceSelected ?? new UIEventSource<boolean>(false))
this.matchesSearchCriteria = search.map((s) => {
if (s === undefined || s.length === 0) {
return true
}
s = s?.trim()?.toLowerCase()
if (searchTerms[Locale.language.data]?.some((t) => t.indexOf(s) >= 0)) {
return true
}
if (searchTerms["*"]?.some((t) => t.indexOf(s) >= 0)) {
return true
}
return false
})
this.isShown = this.matchesSearchCriteria.map(
(matchesSearch) => {
if (selected.data && !forceSelected.data) {
return true
}
if (options?.hide?.data) {
return false
}
return matchesSearch
},
[selected, Locale.language, options?.hide]
)
const self = this
this.isShown.addCallbackAndRun((shown) => {
if (shown) {
self.RemoveClass("hidden")
} else {
self.SetClass("hidden")
}
})
}
private static clean(s: string): string {
return s?.trim()?.toLowerCase()?.replace(/[-]/, "")
}
GetValue(): UIEventSource<boolean> {
return this._selected
}
IsValid(t: boolean): boolean {
return true
}
protected InnerRender(): string | BaseUIElement {
let el: BaseUIElement = this._shown
const selected = this._selected
selected.addCallbackAndRun((selected) => {
if (selected) {
el.SetClass("border-4")
el.RemoveClass("border")
el.SetStyle("margin: 0")
} else {
el.SetStyle("margin: 3px")
el.SetClass("border")
el.RemoveClass("border-4")
}
})
const forcedSelection = this.forceSelected
el.onClick(() => {
if (forcedSelection.data) {
selected.setData(true)
} else {
selected.setData(!selected.data)
}
})
if (!this._squared) {
el.SetClass("rounded-full")
}
return el.SetClass("border border-black p-1 px-4")
}
}
/**
* The searchable mappings selector is a selector which shows various pills from which one (or more) options can be chosen.
* A searchfield can be used to filter the values
*/
export class SearchablePillsSelector<T> extends Combine implements InputElement<T[]> {
public readonly someMatchFound: Store<boolean>
private readonly selectedElements: UIEventSource<T[]>
/**
*
* @param values: the values that can be selected
* @param options
*/
constructor(
values: {
show: BaseUIElement
value: T
mainTerm: Record<string, string>
searchTerms?: Record<string, string[]>
/* If there are more then 200 elements, should this element still be shown? */
hasPriority?: Store<boolean>
}[],
options?: {
/*
* If one single value can be selected (like a radio button) or if many values can be selected (like checkboxes)
*/
mode?: "select-one" | "select-many"
/**
* The values of the selected elements.
* Use this to tie input elements together
*/
selectedElements?: UIEventSource<T[]>
/**
* The search bar. Use this to seed the search value or to tie to another value
*/
searchValue?: UIEventSource<string>
/**
* What is shown if the search yielded no results.
* By default: a translated "no search results"
*/
onNoMatches?: BaseUIElement
/**
* An element that is shown if no search is entered
* Default behaviour is to show all options
*/
onNoSearchMade?: BaseUIElement
/**
* Extra element to show if there are many (>200) possible mappings and when non-priority mappings are hidden
*
*/
onManyElements?: BaseUIElement
searchAreaClass?: string
hideSearchBar?: false | boolean
}
) {
const search = new TextField({ value: options?.searchValue })
const searchBar = options?.hideSearchBar
? undefined
: new Combine([
Svg.search_svg().SetClass("w-8 normal-background"),
search.SetClass("w-full"),
]).SetClass("flex items-center border-2 border-black m-2")
const searchValue = search.GetValue().map((s) => s?.trim()?.toLowerCase())
const selectedElements = options?.selectedElements ?? new UIEventSource<T[]>([])
const mode = options?.mode ?? "select-one"
const onEmpty = options?.onNoMatches ?? Translations.t.general.noMatchingMapping
const forceHide = new UIEventSource(false)
const mappedValues: {
show: SelfHidingToggle
mainTerm: Record<string, string>
value: T
}[] = values.map((v) => {
const vIsSelected = new UIEventSource(false)
selectedElements.addCallbackAndRunD((selectedElements) => {
vIsSelected.setData(selectedElements.some((t) => t === v.value))
})
vIsSelected.addCallback((selected) => {
if (selected) {
if (mode === "select-one") {
selectedElements.setData([v.value])
} else if (!selectedElements.data.some((t) => t === v.value)) {
selectedElements.data.push(v.value)
selectedElements.ping()
}
} else {
for (let i = 0; i < selectedElements.data.length; i++) {
const t = selectedElements.data[i]
if (t == v.value) {
selectedElements.data.splice(i, 1)
selectedElements.ping()
break
}
}
}
})
const toggle = new SelfHidingToggle(v.show, v.mainTerm, searchValue, {
searchTerms: v.searchTerms,
selected: vIsSelected,
squared: mode === "select-many",
hide:
v.hasPriority === undefined
? forceHide
: forceHide.map((fh) => fh && !v.hasPriority?.data, [v.hasPriority]),
})
return {
...v,
show: toggle,
}
})
// The total number of elements that would be displayed based on the search criteria alone
let totalShown: Store<number>
totalShown = searchValue.map(
(_) => mappedValues.filter((mv) => mv.show.matchesSearchCriteria.data).length
)
const tooMuchElementsCutoff = 40
totalShown.addCallbackAndRunD((shown) => forceHide.setData(tooMuchElementsCutoff < shown))
super([
searchBar,
new VariableUiElement(
Locale.language.map(
(lng) => {
if (
options?.onNoSearchMade !== undefined &&
(searchValue.data === undefined || searchValue.data.length === 0)
) {
return options?.onNoSearchMade
}
if (totalShown.data == 0) {
return onEmpty
}
mappedValues.sort((a, b) => (a.mainTerm[lng] < b.mainTerm[lng] ? -1 : 1))
let pills = new Combine(mappedValues.map((e) => e.show))
.SetClass("flex flex-wrap w-full content-start")
.SetClass(options?.searchAreaClass ?? "")
if (totalShown.data >= tooMuchElementsCutoff) {
pills = new Combine([
options?.onManyElements ?? Translations.t.general.useSearch,
pills,
])
}
return pills
},
[totalShown, searchValue]
)
),
])
this.selectedElements = selectedElements
this.someMatchFound = totalShown.map((t) => t > 0)
}
public GetValue(): UIEventSource<T[]> {
return this.selectedElements
}
IsValid(t: T[]): boolean {
return true
}
}

View file

@ -4,8 +4,6 @@
import { Map as MlMap } from "maplibre-gl"
import { MapLibreAdaptor } from "../../Map/MapLibreAdaptor"
import MaplibreMap from "../../Map/MaplibreMap.svelte"
import ToSvelte from "../../Base/ToSvelte.svelte"
import Svg from "../../../Svg.js"
import Direction_stroke from "../../../assets/svg/Direction_stroke.svelte"
/**
@ -28,6 +26,7 @@
})
let mainElem: HTMLElement
function onPosChange(x: number, y: number) {
const rect = mainElem.getBoundingClientRect()
const dx = -(rect.left + rect.right) / 2 + x
@ -64,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 {map} attribution={false} />
<MaplibreMap attribution={false} {map} />
</div>
<div bind:this={directionElem} class="absolute top-0 left-0 h-full w-full">

View file

@ -23,7 +23,7 @@
function update() {
const v = currentVal.data
const l = currentLang.data
if (translations.data === "" || translations.data === undefined) {
if (<any> translations.data === "" || translations.data === undefined) {
translations.data = {}
}
if (translations.data[l] === v) {
@ -35,7 +35,6 @@
onDestroy(
currentLang.addCallbackAndRunD((currentLang) => {
console.log("Applying current lang:", currentLang)
if (!translations.data) {
translations.data = {}
}
@ -45,7 +44,7 @@
)
onDestroy(
currentVal.addCallbackAndRunD((v) => {
currentVal.addCallbackAndRunD(() => {
update()
})
)

View file

@ -28,8 +28,12 @@
* This is only copied to 'value' when appropriate so that no invalid values leak outside;
* Additionally, the unit is added when copying
*/
let _value = new UIEventSource(value.data ?? "")
export let unvalidatedText = new UIEventSource(value.data ?? "")
if(unvalidatedText == /*Compare by reference!*/ value){
throw "Value and unvalidatedText may not be the same store!"
}
let validator: Validator = Validators.get(type ?? "string")
if (validator === undefined) {
console.warn("Didn't find a validator for type", type)
@ -41,13 +45,13 @@
if (unit && value.data) {
const [v, denom] = unit?.findDenomination(value.data, getCountry)
if (denom) {
_value.setData(v)
unvalidatedText.setData(v)
selectedUnit.setData(denom.canonical)
} else {
_value.setData(value.data ?? "")
unvalidatedText.setData(value.data ?? "")
}
} else {
_value.setData(value.data ?? "")
unvalidatedText.setData(value.data ?? "")
}
}
@ -67,8 +71,8 @@
validator = Validators.get(type ?? "string")
_placeholder = placeholder ?? validator?.getPlaceholder() ?? type
if (_value.data?.length > 0) {
feedback?.setData(validator?.getFeedback(_value.data, getCountry))
if (unvalidatedText.data?.length > 0) {
feedback?.setData(validator?.getFeedback(unvalidatedText.data, getCountry))
} else {
feedback?.setData(undefined)
}
@ -78,7 +82,7 @@
function setValues() {
// Update the value stores
const v = _value.data
const v = unvalidatedText.data
if (v === "") {
value.setData(undefined)
feedback?.setData(undefined)
@ -103,12 +107,12 @@
}
}
onDestroy(_value.addCallbackAndRun((_) => setValues()))
onDestroy(unvalidatedText.addCallbackAndRun((_) => setValues()))
if (unit === undefined) {
onDestroy(
value.addCallbackAndRunD((fromUpstream) => {
if (_value.data !== fromUpstream && fromUpstream !== "") {
_value.setData(fromUpstream)
if (unvalidatedText.data !== fromUpstream && fromUpstream !== "") {
unvalidatedText.setData(fromUpstream)
}
})
)
@ -131,7 +135,7 @@
)
}
const isValid = _value.map((v) => validator?.isValid(v, getCountry) ?? true)
const isValid = unvalidatedText.map((v) => validator?.isValid(v, getCountry) ?? true)
let htmlElem: HTMLInputElement | HTMLTextAreaElement
@ -149,7 +153,7 @@
{#if validator?.textArea}
<textarea
class="w-full"
bind:value={$_value}
bind:value={$unvalidatedText}
inputmode={validator?.inputmode ?? "text"}
placeholder={_placeholder}
bind:this={htmlElem}
@ -159,7 +163,7 @@
<div class={twMerge("inline-flex", cls)}>
<input
bind:this={htmlElem}
bind:value={$_value}
bind:value={$unvalidatedText}
class="w-full"
inputmode={validator?.inputmode ?? "text"}
placeholder={_placeholder}
@ -170,7 +174,7 @@
{/if}
{#if unit !== undefined}
<UnitInput {unit} {selectedUnit} textValue={_value} upstreamValue={value} {getCountry} />
<UnitInput {unit} {selectedUnit} textValue={unvalidatedText} upstreamValue={value} {getCountry} />
{/if}
</div>
{/if}

View file

@ -71,7 +71,7 @@ export abstract class Validator {
return Translations.t.validation[this.name].description
}
public isValid(key: string, getCountry?: () => string): boolean {
public isValid(_: string): boolean {
return true
}

View file

@ -27,6 +27,7 @@ import IconValidator from "./Validators/IconValidator"
import TagValidator from "./Validators/TagValidator"
import IdValidator from "./Validators/IdValidator"
import SlopeValidator from "./Validators/SlopeValidator"
import VeloparkValidator from "./Validators/VeloparkValidator"
export type ValidatorType = (typeof Validators.availableTypes)[number]
@ -58,6 +59,7 @@ export default class Validators {
"fediverse",
"id",
"slope",
"velopark"
] as const
public static readonly AllValidators: ReadonlyArray<Validator> = [
@ -86,6 +88,7 @@ export default class Validators {
new FediverseValidator(),
new IdValidator(),
new SlopeValidator(),
new VeloparkValidator()
]
private static _byType = Validators._byTypeConstructor()

View file

@ -3,7 +3,7 @@ import { Translation } from "../../i18n/Translation"
import Translations from "../../i18n/Translations"
export default class FediverseValidator extends Validator {
public static readonly usernameAtServer: RegExp = /^@?(\w+)@((\w|\.)+)$/
public static readonly usernameAtServer: RegExp = /^@?(\w+)@((\w|-|\.)+)$/
constructor() {
super(

View file

@ -35,6 +35,10 @@ export default class PhoneValidator extends Validator {
if (country !== undefined) {
countryCode = country()?.toUpperCase()
}
if (this.isShortCode(str, countryCode)) {
return true
}
return parsePhoneNumberFromString(str, countryCode)?.isValid() ?? false
}
@ -46,9 +50,28 @@ export default class PhoneValidator extends Validator {
if (country) {
countryCode = country()
}
if (this.isShortCode(str, countryCode?.toUpperCase())) {
return str
}
return parsePhoneNumberFromString(
str,
countryCode?.toUpperCase() as any
)?.formatInternational()
}
/**
* Indicates if the given string is a special 'short code' valid in the given country
* see https://nl.wikipedia.org/wiki/Short_code
* @param str a possible phone number
* @param country the upper case, two-letter code for a country
* @private
*/
private isShortCode(str: string, country: string) {
if (country == "BE" && str.length === 4 && str.match(/[0-9]{4}/)) {
return true
}
if (country == "NL" && str.length === 4 && str.match(/14[0-9]{3}/)) {
return true
}
}
}

View file

@ -1,14 +1,12 @@
import { Validator } from "../Validator"
import { Translation } from "../../i18n/Translation"
import Translations from "../../i18n/Translations"
import TagKeyValidator from "./TagKeyValidator"
import SimpleTagValidator from "./SimpleTagValidator"
/**
* Checks that the input conforms a JSON-encoded tag expression or a simpleTag`key=value`,
*/
export default class TagValidator extends Validator {
public readonly isMeta = true
constructor() {
super("tag", "A simple tag of the format `key=value` OR a tagExpression")
}

View file

@ -6,7 +6,7 @@ export default class TranslationValidator extends Validator {
super("translation", "Makes sure the the string is of format `Record<string, string>` ")
}
isValid(value: string, getCountry?: () => string): boolean {
isValid(value: string): boolean {
try {
JSON.parse(value)
return true

View file

@ -0,0 +1,37 @@
import { Translation } from "../../i18n/Translation"
import UrlValidator from "./UrlValidator"
export default class VeloparkValidator extends UrlValidator {
constructor() {
super("velopark", "A custom element to allow copy-pasting velopark-pages")
}
getFeedback(s: string): Translation {
const superF = super.getFeedback(s)
if (superF) {
return superF
}
const url = new URL(s)
if (url.hostname !== "velopark.be" && url.hostname !== "www.velopark.be" && url.hostname !== "data.velopark.be") {
return new Translation({ "*": "Invalid hostname, expected velopark.be" })
}
if(!s.startsWith("https://data.velopark.be/data/") && !s.startsWith("https://www.velopark.be/static/data/")){
return new Translation({"*":"A valid URL should either start with https://data.velopark.be/data/ or https://www.velopark.be/static/data/"})
}
}
public isValid(str: string) {
return super.isValid(str)
}
reformat(str: string): string {
const url = new URL(super.reformat(str))
if(url.pathname.startsWith("/static/data/")){
const id = str.substring(str.lastIndexOf("/")+1)
return "https://data.velopark.be/data/"+id
}
return super.reformat(str)
}
}

View file

@ -0,0 +1,89 @@
import { RasterLayerPolygon } from "../../Models/RasterLayers"
import { Polygon } from "geojson"
import { RasterLayerProperties } from "../../Models/RasterLayerProperties"
import { BBox } from "../../Logic/BBox"
import { Utils } from "../../Utils"
export class BingRasterLayerProperties implements Partial<RasterLayerProperties> {
private static singleton: BingRasterLayerProperties | "error"
name = "Bing Maps Aerial"
id = "Bing"
type = "bing"
category = "photo"
min_zoom = 1
max_zoom = 22
best = false
attribution = {
url: "https://www.bing.com/maps",
}
url: string
private constructor(url: string) {
this.url = url
}
public static async get(): Promise<BingRasterLayerProperties | "error"> {
if (BingRasterLayerProperties.singleton === undefined) {
try {
const url = await this.getEndpoint()
BingRasterLayerProperties.singleton = new BingRasterLayerProperties(url)
} catch (e) {
BingRasterLayerProperties.singleton = "error"
console.error(e)
}
}
return BingRasterLayerProperties.singleton
}
private static async getEndpoint() {
console.log("Getting bing endpoint")
// Key by 'pietervdvn@outlook.com' from https://www.bingmapsportal.com/Application
// Inspired by https://github.com/zlant/parking-lanes/pull/159
const key = "An0vKZ4r_PZx820sn3seuPKjd1Vyc5WE3s1b-qN4HCgI-Nr6QR83aLOQ-3fbFl08"
const url = `https://dev.virtualearth.net/REST/v1/Imagery/Metadata/AerialOSM?include=ImageryProviders&uriScheme=https&key=${key}`
// Get the image tiles template:
const metadata = await Utils.downloadJson(url)
// FYI:
// "imageHeight": 256, "imageWidth": 256,
// "imageUrlSubdomains": ["t0","t1","t2","t3"],
// "zoomMax": 21,
const imageryResource = metadata.resourceSets[0].resources[0]
const template = new URL(imageryResource.imageUrl)
// Add tile image strictness param (n=)
// • n=f -> (Fail) returns a 404
// • n=z -> (Empty) returns a 200 with 0 bytes (no content)
// • n=t -> (Transparent) returns a 200 with a transparent (png) tile
if (!template.searchParams.has("n")) template.searchParams.append("n", "f")
// FYI: `template` looks like this but partly encoded
// https://ecn.{subdomain}.tiles.virtualearth.net/tiles/a{quadkey}.jpeg?g=14107&pr=odbl&n=z
const subdomains = ["t0", "t1", "t2", "t3"]
const index = Math.floor(Math.random() * subdomains.length)
return template.toString().replace("{subdomain}", subdomains[index])
}
}
export class BingRasterLayer implements RasterLayerPolygon {
private static singleton: RasterLayerPolygon | "error"
readonly type: "Feature" = "Feature"
readonly geometry: Polygon = BBox.global.asGeometry()
readonly id = "bing"
readonly properties: RasterLayerProperties
constructor(properties: RasterLayerProperties) {
this.properties = properties
}
public static async get(): Promise<RasterLayerPolygon | "error"> {
if (BingRasterLayer.singleton === undefined) {
const properties = await BingRasterLayerProperties.get()
if (properties === "error") {
BingRasterLayer.singleton = "error"
} else {
BingRasterLayer.singleton = new BingRasterLayer(properties)
}
}
return BingRasterLayer.singleton
}
}

View file

@ -1,46 +1,49 @@
<script lang="ts">
import Pin from "../../assets/svg/Pin.svelte"
import Square from "../../assets/svg/Square.svelte"
import Circle from "../../assets/svg/Circle.svelte"
import Checkmark from "../../assets/svg/Checkmark.svelte"
import Clock from "../../assets/svg/Clock.svelte"
import Close from "../../assets/svg/Close.svelte"
import Crosshair from "../../assets/svg/Crosshair.svelte"
import Help from "../../assets/svg/Help.svelte"
import Home from "../../assets/svg/Home.svelte"
import Invalid from "../../assets/svg/Invalid.svelte"
import Location from "../../assets/svg/Location.svelte"
import Location_empty from "../../assets/svg/Location_empty.svelte"
import Location_locked from "../../assets/svg/Location_locked.svelte"
import Note from "../../assets/svg/Note.svelte"
import Resolved from "../../assets/svg/Resolved.svelte"
import Ring from "../../assets/svg/Ring.svelte"
import Scissors from "../../assets/svg/Scissors.svelte"
import Teardrop from "../../assets/svg/Teardrop.svelte"
import Teardrop_with_hole_green from "../../assets/svg/Teardrop_with_hole_green.svelte"
import Triangle from "../../assets/svg/Triangle.svelte"
import Brick_wall_square from "../../assets/svg/Brick_wall_square.svelte"
import Brick_wall_round from "../../assets/svg/Brick_wall_round.svelte"
import Gps_arrow from "../../assets/svg/Gps_arrow.svelte"
import { HeartIcon } from "@babeard/svelte-heroicons/solid"
import { HeartIcon as HeartOutlineIcon } from "@babeard/svelte-heroicons/outline"
import Confirm from "../../assets/svg/Confirm.svelte"
import Not_found from "../../assets/svg/Not_found.svelte"
import { twMerge } from "tailwind-merge"
import Direction_gradient from "../../assets/svg/Direction_gradient.svelte"
import Mastodon from "../../assets/svg/Mastodon.svelte"
import Party from "../../assets/svg/Party.svelte"
import AddSmall from "../../assets/svg/AddSmall.svelte"
import Pin from "../../assets/svg/Pin.svelte"
import Square from "../../assets/svg/Square.svelte"
import Circle from "../../assets/svg/Circle.svelte"
import Checkmark from "../../assets/svg/Checkmark.svelte"
import Clock from "../../assets/svg/Clock.svelte"
import Close from "../../assets/svg/Close.svelte"
import Crosshair from "../../assets/svg/Crosshair.svelte"
import Help from "../../assets/svg/Help.svelte"
import Home from "../../assets/svg/Home.svelte"
import Invalid from "../../assets/svg/Invalid.svelte"
import Location from "../../assets/svg/Location.svelte"
import Location_empty from "../../assets/svg/Location_empty.svelte"
import Location_locked from "../../assets/svg/Location_locked.svelte"
import Note from "../../assets/svg/Note.svelte"
import Resolved from "../../assets/svg/Resolved.svelte"
import Ring from "../../assets/svg/Ring.svelte"
import Scissors from "../../assets/svg/Scissors.svelte"
import Teardrop from "../../assets/svg/Teardrop.svelte"
import Teardrop_with_hole_green from "../../assets/svg/Teardrop_with_hole_green.svelte"
import Triangle from "../../assets/svg/Triangle.svelte"
import Brick_wall_square from "../../assets/svg/Brick_wall_square.svelte"
import Brick_wall_round from "../../assets/svg/Brick_wall_round.svelte"
import Gps_arrow from "../../assets/svg/Gps_arrow.svelte"
import { HeartIcon } from "@babeard/svelte-heroicons/solid"
import { HeartIcon as HeartOutlineIcon } from "@babeard/svelte-heroicons/outline"
import Confirm from "../../assets/svg/Confirm.svelte"
import Not_found from "../../assets/svg/Not_found.svelte"
import { twMerge } from "tailwind-merge"
import Direction_gradient from "../../assets/svg/Direction_gradient.svelte"
import Mastodon from "../../assets/svg/Mastodon.svelte"
import Party from "../../assets/svg/Party.svelte"
import AddSmall from "../../assets/svg/AddSmall.svelte"
import { LinkIcon } from "@babeard/svelte-heroicons/mini"
import Square_rounded from "../../assets/svg/Square_rounded.svelte"
import Bug from "../../assets/svg/Bug.svelte"
/**
* Renders a single icon.
*
* Icons -placed on top of each other- form a 'Marker' together
*/
/**
* Renders a single icon.
*
* Icons -placed on top of each other- form a 'Marker' together
*/
export let icon: string | undefined
export let color: string | undefined = undefined
export let clss: string | undefined = undefined
export let icon: string | undefined
export let color: string | undefined = undefined
export let clss: string | undefined = undefined
</script>
@ -49,6 +52,11 @@
<Pin {color} class={clss} />
{:else if icon === "square"}
<Square {color} class={clss} />
{:else if icon === "square_rounded"}
<Square_rounded {color} class={clss} />
{:else if icon === "bug"}
<Bug {color} class={clss} />
{:else if icon === "circle"}
<Circle {color} class={clss} />
{:else if icon === "checkmark"}
@ -115,6 +123,8 @@
<Party {color} class={clss} />
{:else if icon === "addSmall"}
<AddSmall {color} class={clss} />
{:else if icon === "link"}
<LinkIcon style="--svg-color: {color}" class={twMerge(clss, "apply-fill")} />
{:else}
<img class={clss ?? "h-full w-full"} src={icon} aria-hidden="true" alt="" />
{/if}

View file

@ -1,7 +1,7 @@
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
import type { Map as MLMap } from "maplibre-gl"
import { Map as MlMap, SourceSpecification } from "maplibre-gl"
import { AvailableRasterLayers, RasterLayerPolygon } from "../../Models/RasterLayers"
import { RasterLayerPolygon } from "../../Models/RasterLayers"
import { Utils } from "../../Utils"
import { BBox } from "../../Logic/BBox"
import { ExportableMap, KeyNavigationEvent, MapProperties } from "../../Models/MapProperties"
@ -9,6 +9,8 @@ import SvelteUIElement from "../Base/SvelteUIElement"
import MaplibreMap from "./MaplibreMap.svelte"
import { RasterLayerProperties } from "../../Models/RasterLayerProperties"
import * as htmltoimage from "html-to-image"
import RasterLayerHandler from "./RasterLayerHandler"
import Constants from "../../Models/Constants"
/**
* The 'MapLibreAdaptor' bridges 'MapLibre' with the various properties of the `MapProperties`
@ -41,7 +43,8 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
readonly minzoom: UIEventSource<number>
readonly maxzoom: UIEventSource<number>
readonly rotation: UIEventSource<number>
readonly animationRunning = new UIEventSource(false)
readonly pitch: UIEventSource<number>
readonly useTerrain: Store<boolean>
/**
* Functions that are called when one of those actions has happened
@ -50,11 +53,6 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
private _onKeyNavigation: ((event: KeyNavigationEvent) => void | boolean)[] = []
private readonly _maplibreMap: Store<MLMap>
/**
* Used for internal bookkeeping (to remove a rasterLayer when done loading)
* @private
*/
private _currentRasterLayer: string
constructor(maplibreMap: Store<MLMap>, state?: Partial<MapProperties>) {
this._maplibreMap = maplibreMap
@ -83,6 +81,8 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
this.allowZooming = state?.allowZooming ?? new UIEventSource(true)
this.bounds = state?.bounds ?? new UIEventSource(undefined)
this.rotation = state?.rotation ?? new UIEventSource<number>(0)
this.pitch = state?.pitch ?? new UIEventSource<number>(0)
this.useTerrain = state?.useTerrain ?? new ImmutableStore<boolean>(false)
this.rasterLayer =
state?.rasterLayer ?? new UIEventSource<RasterLayerPolygon | undefined>(undefined)
@ -90,6 +90,8 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
this.lastClickLocation = lastClickLocation
const self = this
const rasterLayerHandler = new RasterLayerHandler(this._maplibreMap, this.rasterLayer)
function handleClick(e) {
if (e.originalEvent["consumed"]) {
// Workaround, 'ShowPointLayer' sets this flag
@ -111,7 +113,8 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
self.setMinzoom(self.minzoom.data)
self.setMaxzoom(self.maxzoom.data)
self.setBounds(self.bounds.data)
self.setBackground()
self.setTerrain(self.useTerrain.data)
rasterLayerHandler.setBackground()
this.updateStores(true)
})
self.MoveMapToCurrentLoc(self.location.data)
@ -124,7 +127,8 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
self.setMaxzoom(self.maxzoom.data)
self.setBounds(self.bounds.data)
self.SetRotation(self.rotation.data)
self.setBackground()
self.setTerrain(self.useTerrain.data)
rasterLayerHandler.setBackground()
this.updateStores(true)
map.on("moveend", () => this.updateStores())
map.on("click", (e) => {
@ -136,7 +140,10 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
map.on("dblclick", (e) => {
handleClick(e)
})
map.on("rotateend", (e) => {
map.on("rotateend", (_) => {
this.updateStores()
})
map.on("pitchend", () => {
this.updateStores()
})
map.getContainer().addEventListener("keydown", (event) => {
@ -170,11 +177,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
})
})
this.rasterLayer.addCallbackAndRun((_) =>
self.setBackground().catch((_) => {
console.error("Could not set background")
})
)
this.rasterLayer.addCallbackAndRun((_) => rasterLayerHandler.setBackground())
this.location.addCallbackAndRunD((loc) => {
self.MoveMapToCurrentLoc(loc)
})
@ -190,6 +193,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
)
this.allowZooming.addCallbackAndRun((allowZooming) => self.setAllowZooming(allowZooming))
this.bounds.addCallbackAndRunD((bounds) => self.setBounds(bounds))
this.useTerrain?.addCallbackAndRun(useTerrain => self.setTerrain(useTerrain))
}
/**
@ -211,48 +215,12 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
}
public static prepareWmsSource(layer: RasterLayerProperties): SourceSpecification {
return {
type: "raster",
// use the tiles option to specify a 256WMS tile source URL
// https://maplibre.org/maplibre-gl-js-docs/style-spec/sources/
tiles: [MapLibreAdaptor.prepareWmsURL(layer.url, layer["tile-size"] ?? 256)],
tileSize: layer["tile-size"] ?? 256,
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",
}
return RasterLayerHandler.prepareWmsSource(layer)
}
/**
* Prepares an ELI-URL to be compatible with mapbox
*/
private static prepareWmsURL(url: string, size: number = 256): string {
// ELI: LAYERS=OGWRGB13_15VL&STYLES=&FORMAT=image/jpeg&CRS={proj}&WIDTH={width}&HEIGHT={height}&BBOX={bbox}&VERSION=1.3.0&SERVICE=WMS&REQUEST=GetMap
// PROD: SERVICE=WMS&REQUEST=GetMap&LAYERS=OGWRGB13_15VL&STYLES=&FORMAT=image/jpeg&TRANSPARENT=false&VERSION=1.3.0&WIDTH=256&HEIGHT=256&CRS=EPSG:3857&BBOX=488585.4847988467,6590094.830634755,489196.9810251281,6590706.32686104
const toReplace = {
"{bbox}": "{bbox-epsg-3857}",
"{proj}": "EPSG:3857",
"{width}": "" + size,
"{height}": "" + size,
"{zoom}": "{z}",
"{-y}": "{y}",
}
for (const key in toReplace) {
url = url.replace(new RegExp(key), toReplace[key])
}
const subdomains = url.match(/\{switch:([a-zA-Z0-9,]*)}/)
if (subdomains !== null) {
const options = subdomains[1].split(",")
const option = options[Math.floor(Math.random() * options.length)]
url = url.replace(subdomains[0], option)
}
return url
}
private static async toBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return await new Promise<Blob>((resolve) => canvas.toBlob((blob) => resolve(blob)))
@ -291,7 +259,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
const w = map.getContainer().getBoundingClientRect().width
const h = map.getContainer().getBoundingClientRect().height
let dpi = map.getPixelRatio()
const dpi = map.getPixelRatio()
// The 'css'-size stays constant...
drawOn.style.width = w + "px"
drawOn.style.height = h + "px"
@ -466,6 +434,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
this.bounds.setData(bbox)
}
this.rotation.setData(map.getBearing())
this.pitch.setData(map.getPitch())
}
private SetZoom(z: number): void {
@ -502,15 +471,6 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
}
}
private async awaitStyleIsLoaded(): Promise<void> {
const map = this._maplibreMap.data
if (!map) {
return
}
while (!map?.isStyleLoaded()) {
await Utils.waitFor(250)
}
}
public installCustomKeyboardHandler(viewportStore: UIEventSource<HTMLDivElement>) {
viewportStore.mapD(
(viewport) => {
@ -534,111 +494,6 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
[this._maplibreMap]
)
}
private removeCurrentLayer(map: MLMap): void {
if (this._currentRasterLayer) {
// hide the previous layer
try {
if (map.getLayer(this._currentRasterLayer)) {
map.removeLayer(this._currentRasterLayer)
}
if (map.getSource(this._currentRasterLayer)) {
map.removeSource(this._currentRasterLayer)
}
this._currentRasterLayer = undefined
} catch (e) {
console.warn("Could not remove the previous layer")
}
}
}
private async setBackground(retryAttempts: number = 3): Promise<void> {
const map = this._maplibreMap.data
if (!map) {
return
}
const background: RasterLayerProperties = this.rasterLayer?.data?.properties
if (!background) {
return
}
if (this._currentRasterLayer === background.id) {
// already the correct background layer, nothing to do
return
}
if (!background?.url) {
// no background to set
this.removeCurrentLayer(map)
return
}
if (background.type === "vector") {
this.removeCurrentLayer(map)
map.setStyle(background.url)
return
}
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
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
this.removeCurrentLayer(map)
} else {
// Make sure that the default maptiler style is loaded as it gives an overlay with roads
const maptiler = AvailableRasterLayers.maptilerDefaultLayer.properties
try {
await this.awaitStyleIsLoaded()
if (!map.getSource(maptiler.id)) {
this.removeCurrentLayer(map)
map.addSource(maptiler.id, MapLibreAdaptor.prepareWmsSource(maptiler))
map.setStyle(maptiler.url)
await this.awaitStyleIsLoaded()
}
}catch (e) {
if(retryAttempts > 0){
window.requestAnimationFrame(() => {
console.log("Retrying to set the background ("+retryAttempts+" attempts remaining)... Failed because",e)
this.setBackground(retryAttempts-1)
})
}
}
}
if (!map.getLayer(addLayerBeforeId)) {
addLayerBeforeId = undefined
}
await this.awaitStyleIsLoaded()
if (!map.getSource(background.id)) {
map.addSource(background.id, MapLibreAdaptor.prepareWmsSource(background))
}
if (!map.getLayer(background.id)) {
addLayerBeforeId ??= map
.getStyle()
.layers.find((l) => l.id.startsWith("mapcomplete_"))?.id
console.log(
"Adding background layer",
background.id,
"beforeId",
addLayerBeforeId,
"; all layers are",
map.getStyle().layers.map((l) => l.id)
)
map.addLayer(
{
id: background.id,
type: "raster",
source: background.id,
paint: {},
},
addLayerBeforeId
)
}
await this.awaitStyleIsLoaded()
if (this._currentRasterLayer !== background?.id) {
this.removeCurrentLayer(map)
}
this._currentRasterLayer = background?.id
}
private setMaxBounds(bbox: undefined | BBox) {
const map = this._maplibreMap.data
@ -736,4 +591,32 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
}
map.fitBounds(bounds.toLngLat())
}
private async setTerrain(useTerrain: boolean) {
const map = this._maplibreMap.data
if (!map) {
return
}
const id = "maptiler-terrain-data"
if (useTerrain) {
if(map.getTerrain()){
return
}
map.addSource(id, {
"type": "raster-dem",
"url": "https://api.maptiler.com/tiles/terrain-rgb/tiles.json?key=" + Constants.maptilerApiKey
})
try{
while (!map?.isStyleLoaded()) {
await Utils.waitFor(250)
}
map.setTerrain({
source: id
})
}catch (e) {
console.error(e)
}
}
}
}

View file

@ -24,6 +24,8 @@
let altproperties = new MapLibreAdaptor(altmap, {
rasterLayer,
zoom: UIEventSource.feedFrom(placedOverMapProperties.zoom),
rotation: UIEventSource.feedFrom(placedOverMapProperties.rotation),
pitch: UIEventSource.feedFrom(placedOverMapProperties.pitch)
})
altproperties.allowMoving.setData(false)
altproperties.allowZooming.setData(false)

View file

@ -0,0 +1,211 @@
import { Map as MLMap, SourceSpecification } from "maplibre-gl"
import { Store, Stores, UIEventSource } from "../../Logic/UIEventSource"
import { RasterLayerPolygon } from "../../Models/RasterLayers"
import { RasterLayerProperties } from "../../Models/RasterLayerProperties"
import { Utils } from "../../Utils"
class SingleBackgroundHandler {
// Value between 0 and 1.0
public opacity = new UIEventSource<number>(0.0)
private _map: Store<MLMap>
private _background: UIEventSource<RasterLayerPolygon | undefined>
private readonly _targetLayer: RasterLayerPolygon
private _deactivationTime: Date = undefined
/**
* Deactivate a layer after 60 seconds
*/
public static readonly DEACTIVATE_AFTER = 60
private fadeStep = 0.1
constructor(
map: Store<MLMap>,
targetLayer: RasterLayerPolygon,
background: UIEventSource<RasterLayerPolygon | undefined>
) {
this._targetLayer = targetLayer
this._map = map
this._background = background
background.addCallback(async () => {
await this.update()
})
map.addCallbackAndRunD(async (map) => {
map.on("load", async () => {
await this.update()
})
await this.update()
map.on("moveend", () => this.onMove(map))
map.on("zoomend", () => this.onMove(map))
})
}
private onMove(map: MLMap) {
if (!this._deactivationTime) {
return
}
// in seconds
const timeSinceDeactivation =
(new Date().getTime() - this._deactivationTime.getTime()) / 1000
if (timeSinceDeactivation < SingleBackgroundHandler.DEACTIVATE_AFTER) {
return
}
console.debug(
"Removing raster layer",
this._targetLayer.properties.id,
"map moved and not been used for",
SingleBackgroundHandler.DEACTIVATE_AFTER
)
if (map.getLayer(<string>this._targetLayer.properties.id)) {
map.removeLayer(<string>this._targetLayer.properties.id)
}
}
private async update() {
const newTarget: RasterLayerPolygon | undefined = this._background.data
const targetLayer = this._targetLayer
if (newTarget?.properties?.id !== targetLayer.properties.id) {
this._deactivationTime = new Date()
await this.awaitStyleIsLoaded()
this.fadeOut()
} else {
this._deactivationTime = undefined
this.enable()
this.fadeIn()
}
}
private async awaitStyleIsLoaded(): Promise<void> {
const map = this._map.data
if (!map) {
return
}
while (!map?.isStyleLoaded()) {
await Utils.waitFor(250)
}
}
private enable() {
const map: MLMap = this._map.data
if (!map) {
return
}
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
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))
} catch (e) {
console.error("Could not add source", e)
return
}
}
if (!map.getLayer(background.id)) {
addLayerBeforeId ??= map
.getStyle()
.layers.find((l) => l.id.startsWith("mapcomplete_"))?.id
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)
})
}
}
private fadeOut() {
Stores.Chronic(
8,
() => this.opacity.data > 0 && this._deactivationTime !== undefined
).addCallback((_) => this.opacity.setData(Math.max(0, this.opacity.data - this.fadeStep)))
}
private fadeIn() {
Stores.Chronic(
8,
() => this.opacity.data < 1.0 && this._deactivationTime === undefined
).addCallback((_) => this.opacity.setData(Math.min(1.0, this.opacity.data + this.fadeStep)))
}
}
export default class RasterLayerHandler {
private _map: Store<MLMap>
private _background: UIEventSource<RasterLayerPolygon | undefined>
private _singleLayerHandlers: Record<string, SingleBackgroundHandler> = {}
constructor(map: Store<MLMap>, background: UIEventSource<RasterLayerPolygon | undefined>) {
this._map = map
this._background = background
background.addCallbackAndRunD((l) => {
const key = l.properties.id
if (!this._singleLayerHandlers[key]) {
this._singleLayerHandlers[key] = new SingleBackgroundHandler(map, l, background)
}
})
map.addCallback((map) => {
map.on("load", () => this.setBackground())
this.setBackground()
})
}
public static prepareWmsSource(layer: RasterLayerProperties): SourceSpecification {
return {
type: "raster",
// use the tiles option to specify a 256WMS tile source URL
// https://maplibre.org/maplibre-gl-js-docs/style-spec/sources/
tiles: [RasterLayerHandler.prepareWmsURL(layer.url, layer["tile-size"] ?? 256)],
tileSize: layer["tile-size"] ?? 256,
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",
}
}
private static prepareWmsURL(url: string, size: number = 256): string {
// ELI: LAYERS=OGWRGB13_15VL&STYLES=&FORMAT=image/jpeg&CRS={proj}&WIDTH={width}&HEIGHT={height}&BBOX={bbox}&VERSION=1.3.0&SERVICE=WMS&REQUEST=GetMap
// PROD: SERVICE=WMS&REQUEST=GetMap&LAYERS=OGWRGB13_15VL&STYLES=&FORMAT=image/jpeg&TRANSPARENT=false&VERSION=1.3.0&WIDTH=256&HEIGHT=256&CRS=EPSG:3857&BBOX=488585.4847988467,6590094.830634755,489196.9810251281,6590706.32686104
const toReplace = {
"{bbox}": "{bbox-epsg-3857}",
"{proj}": "EPSG:3857",
"{width}": "" + size,
"{height}": "" + size,
"{zoom}": "{z}",
"{-y}": "{y}",
}
for (const key in toReplace) {
url = url.replace(new RegExp(key), toReplace[key])
}
const subdomains = url.match(/\{switch:([a-zA-Z0-9,]*)}/)
if (subdomains !== null) {
const options = subdomains[1].split(",")
const option = options[Math.floor(Math.random() * options.length)]
url = url.replace(subdomains[0], option)
}
return url
}
/**
* Performs all necessary updates
*/
public setBackground() {}
}

View file

@ -1,5 +1,5 @@
import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
import type { Map as MlMap } from "maplibre-gl"
import type { AddLayerObject, Map as MlMap } from "maplibre-gl"
import { GeoJSONSource, Marker } from "maplibre-gl"
import { ShowDataLayerOptions } from "./ShowDataLayerOptions"
import { GeoOperations } from "../../Logic/GeoOperations"
@ -15,6 +15,7 @@ import * as range_layer from "../../../assets/layers/range/range.json"
import PerLayerFeatureSourceSplitter from "../../Logic/FeatureSource/PerLayerFeatureSourceSplitter"
import FilteredLayer from "../../Models/FilteredLayer"
import SimpleFeatureSource from "../../Logic/FeatureSource/Sources/SimpleFeatureSource"
import { TagsFilter } from "../../Logic/Tags/TagsFilter"
class PointRenderingLayer {
private readonly _config: PointRenderingConfig
@ -36,7 +37,7 @@ class PointRenderingLayer {
visibility?: Store<boolean>,
fetchStore?: (id: string) => Store<Record<string, string>>,
onClick?: (feature: Feature) => void,
selectedElement?: Store<{ properties: { id?: string } }>
selectedElement?: Store<{ properties: { id?: string } }>,
) {
this._visibility = visibility
this._config = config
@ -89,7 +90,7 @@ class PointRenderingLayer {
" while rendering",
location,
"of",
this._config
this._config,
)
}
const id = feature.properties.id + "-" + location
@ -97,7 +98,7 @@ class PointRenderingLayer {
const loc = GeoOperations.featureToCoordinateWithRenderingType(
<any>feature,
location
location,
)
if (loc === undefined) {
continue
@ -153,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
@ -221,7 +222,7 @@ class LineRenderingLayer {
config: LineRenderingConfig,
visibility?: Store<boolean>,
fetchStore?: (id: string) => Store<Record<string, string>>,
onClick?: (feature: Feature) => void
onClick?: (feature: Feature) => void,
) {
this._layername = layername
this._map = map
@ -239,13 +240,56 @@ class LineRenderingLayer {
this._map.removeLayer(this._layername + "_polygon")
}
private async addSymbolLayer(sourceId: string, imageAlongWay: { if?: TagsFilter, then: string }[]) {
const map = this._map
await Promise.allSettled(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 spec: AddLayerObject = {
"id": "symbol-layer_" + this._layername + "-" + i,
"type": "symbol",
"source": sourceId,
"layout": {
"symbol-placement": "line",
"symbol-spacing": 10,
"icon-allow-overlap": true,
"icon-rotation-alignment": "map",
"icon-pitch-alignment": "map",
"icon-image": imgId,
"icon-size": 0.055,
},
}
const filter = img.if?.asMapboxExpression()
console.log(">>>", this._layername, imgId, img.if, "-->", filter)
if (filter) {
spec.filter = filter
}
map.addLayer(spec)
}))
}
/**
* Calculate the feature-state for maplibre
* @param properties
* @private
*/
private calculatePropsFor(
properties: Record<string, string>
properties: Record<string, string>,
): Partial<Record<(typeof LineRenderingLayer.lineConfigKeys)[number], string>> {
const config = this._config
@ -321,6 +365,11 @@ class LineRenderingLayer {
},
})
if (this._config.imageAlongWay) {
this.addSymbolLayer(this._layername, this._config.imageAlongWay)
}
for (const feature of features) {
if (!feature.properties.id) {
console.warn("Feature without id:", feature)
@ -328,7 +377,7 @@ class LineRenderingLayer {
}
map.setFeatureState(
{ source: this._layername, id: feature.properties.id },
this.calculatePropsFor(feature.properties)
this.calculatePropsFor(feature.properties),
)
}
@ -371,7 +420,7 @@ class LineRenderingLayer {
"Error while setting visibility of layers ",
linelayer,
polylayer,
e
e,
)
}
})
@ -392,7 +441,7 @@ class LineRenderingLayer {
console.trace(
"Got a feature without ID; this causes rendering bugs:",
feature,
"from"
"from",
)
LineRenderingLayer.missingIdTriggered = true
}
@ -404,7 +453,7 @@ class LineRenderingLayer {
if (this._fetchStore === undefined) {
map.setFeatureState(
{ source: this._layername, id },
this.calculatePropsFor(feature.properties)
this.calculatePropsFor(feature.properties),
)
} else {
const tags = this._fetchStore(id)
@ -421,7 +470,7 @@ class LineRenderingLayer {
}
map.setFeatureState(
{ source: this._layername, id },
this.calculatePropsFor(properties)
this.calculatePropsFor(properties),
)
})
}
@ -445,7 +494,7 @@ export default class ShowDataLayer {
layer: LayerConfig
drawMarkers?: true | boolean
drawLines?: true | boolean
}
},
) {
this._options = options
const self = this
@ -456,7 +505,7 @@ export default class ShowDataLayer {
mlmap: UIEventSource<MlMap>,
features: FeatureSource,
layers: LayerConfig[],
options?: Partial<ShowDataLayerOptions>
options?: Partial<ShowDataLayerOptions>,
) {
const perLayer: PerLayerFeatureSourceSplitter<FeatureSourceForLayer> =
new PerLayerFeatureSourceSplitter(
@ -464,7 +513,7 @@ export default class ShowDataLayer {
features,
{
constructStore: (features, layer) => new SimpleFeatureSource(layer, features),
}
},
)
perLayer.forEach((fs) => {
new ShowDataLayer(mlmap, {
@ -478,7 +527,7 @@ export default class ShowDataLayer {
public static showRange(
map: Store<MlMap>,
features: FeatureSource,
doShowLayer?: Store<boolean>
doShowLayer?: Store<boolean>,
): ShowDataLayer {
return new ShowDataLayer(map, {
layer: ShowDataLayer.rangeLayer,
@ -487,7 +536,8 @@ export default class ShowDataLayer {
})
}
public destruct() {}
public destruct() {
}
private zoomToCurrentFeatures(map: MlMap) {
if (this._options.zoomToFeatures) {
@ -508,9 +558,9 @@ export default class ShowDataLayer {
(this._options.layer.title === undefined
? undefined
: (feature: Feature) => {
selectedElement?.setData(feature)
selectedLayer?.setData(this._options.layer)
})
selectedElement?.setData(feature)
selectedLayer?.setData(this._options.layer)
})
if (this._options.drawLines !== false) {
for (let i = 0; i < this._options.layer.lineRendering.length; i++) {
const lineRenderingConfig = this._options.layer.lineRendering[i]
@ -521,7 +571,7 @@ export default class ShowDataLayer {
lineRenderingConfig,
doShowLayer,
fetchStore,
onClick
onClick,
)
this.onDestroy.push(l.destruct)
}
@ -536,7 +586,7 @@ export default class ShowDataLayer {
doShowLayer,
fetchStore,
onClick,
selectedElement
selectedElement,
)
}
}

View file

@ -1,63 +1,82 @@
<script lang="ts">
import type { SpecialVisualizationState } from "../SpecialVisualization"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Loading from "../../assets/svg/Loading.svelte"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
import Icon from "../Map/Icon.svelte"
import Maproulette from "../../Logic/Maproulette"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Loading from "../../assets/svg/Loading.svelte"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
import Icon from "../Map/Icon.svelte"
import Maproulette from "../../Logic/Maproulette"
import LoginToggle from "../Base/LoginToggle.svelte"
/**
* A UI-element to change the status of a maproulette-task
*/
export let state: SpecialVisualizationState
export let tags: UIEventSource<Record<string, string>>
export let message: string
export let image: string
export let message_closed: string
export let statusToSet: string
export let maproulette_id_key: string
/**
* A UI-element to change the status of a maproulette-task
*/
export let state: SpecialVisualizationState
export let tags: UIEventSource<Record<string, string>>
export let message: string
export let image: string
export let message_closed: string
export let statusToSet: string
export let maproulette_id_key: string
let applying = false
let failed = false
export let askFeedback: string = ""
/** Current status of the task*/
let status: Store<number> = tags
.map((tgs) => {
if (tgs["status"]) {
return tgs["status"]
}
return Maproulette.codeToIndex(tgs["mr_taskStatus"])
})
.map(Number)
let applying = false
let failed = false
let feedback: string = ""
async function apply() {
const maproulette_id = tags.data[maproulette_id_key] ?? tags.data.mr_taskId ?? tags.data.id
try {
await Maproulette.singleton.closeTask(Number(maproulette_id), Number(statusToSet), {
tags: `MapComplete MapComplete:${state.layout.id}`,
})
tags.data["mr_taskStatus"] = Maproulette.STATUS_MEANING[Number(statusToSet)]
tags.data.status = statusToSet
tags.ping()
} catch (e) {
console.error(e)
failed = true
/** Current status of the task*/
let status: Store<number> = tags
.map((tgs) => {
if (tgs["status"]) {
return tgs["status"]
}
return Maproulette.codeToIndex(tgs["mr_taskStatus"])
})
.map(Number)
async function apply() {
const maproulette_id = tags.data[maproulette_id_key] ?? tags.data.mr_taskId ?? tags.data.id
try {
await Maproulette.singleton.closeTask(Number(maproulette_id), Number(statusToSet), {
tags: `MapComplete MapComplete:${state.layout.id}`,
comment: feedback
})
tags.data["mr_taskStatus"] = Maproulette.STATUS_MEANING[Number(statusToSet)]
tags.data.status = statusToSet
tags.ping()
} catch (e) {
console.error(e)
failed = true
}
}
}
</script>
{#if failed}
<div class="alert">ERROR - could not close the MapRoulette task</div>
{:else if applying}
<Loading>
<Tr t={Translations.t.general.loading} />
</Loading>
{:else if $status === Maproulette.STATUS_OPEN}
<button class="no-image-background w-full p-4" on:click={() => apply()}>
<Icon clss="w-8 h-8 mr-2" icon={image} />
{message}
</button>
{:else}
{message_closed}
{/if}
<LoginToggle ignoreLoading={true} {state}>
{#if failed}
<div class="alert">ERROR - could not close the MapRoulette task</div>
{:else if applying}
<Loading>
<Tr t={Translations.t.general.loading} />
</Loading>
{:else if $status === Maproulette.STATUS_OPEN}
{#if askFeedback !== "" && askFeedback !== undefined}
<div class="flex flex-col p-1 gap-y-1 interactive border border-gray-500 border-dashed">
<h3>{askFeedback}</h3>
<textarea bind:value={feedback}></textarea>
<button class="no-image-background w-full p-4 m-0" class:disabled={feedback===""} on:click={() => apply()}>
<Icon clss="w-8 h-8 mr-2 shrink-0" icon={image} />
{message}
</button>
{feedback}
</div>
{:else}
<button class="no-image-background w-full p-4 m-0" on:click={() => apply()}>
<Icon clss="w-8 h-8 mr-2 shrink-0" icon={image} />
{message}
</button>
{/if}
{:else}
{message_closed}
{/if}
</LoginToggle>

View file

@ -10,7 +10,6 @@
import { Utils } from "../../Utils"
import Circle from "../../assets/svg/Circle.svelte"
import Ring from "../../assets/svg/Ring.svelte"
import { twMerge } from "tailwind-merge"
export let state: SpecialVisualizationState
export let tags: Store<Record<string, string>>
@ -21,7 +20,7 @@
tags,
keyToUse,
prefix,
postfix
postfix,
)
let currentState = oh.mapD((oh) => (typeof oh === "string" ? undefined : oh.getState()))
@ -30,12 +29,12 @@
let nextChange = oh
.mapD(
(oh) => (typeof oh === "string" ? undefined : oh.getNextChange(new Date(), tomorrow)),
[Stores.Chronic(5 * 60 * 1000)]
[Stores.Chronic(5 * 60 * 1000)],
)
.mapD((date) => Utils.TwoDigits(date.getHours()) + ":" + Utils.TwoDigits(date.getMinutes()))
let size = nextChange.map((change) =>
change === undefined ? "absolute h-7 w-7" : "absolute h-5 w-5 top-0 left-1/4"
change === undefined ? "absolute h-7 w-7" : "absolute h-5 w-5 top-0 left-1/4",
)
</script>

View file

@ -127,6 +127,11 @@ export class OH {
* const oh1: OpeningHour = { weekday: 0, startHour: 10, startMinutes: 0, endHour: 11, endMinutes: 0 };
* const oh0: OpeningHour = { weekday: 0, startHour: 11, startMinutes: 0, endHour: 12, endMinutes: 0 };
* OH.MergeTimes([oh0, oh1]) // => [{ weekday: 0, startHour: 10, startMinutes: 0, endHour: 12, endMinutes: 0 }]
*
* // should merge touching opening hours spanning days
* const oh0: OpeningHour = { weekday: 0, startHour: 10, startMinutes: 0, endHour: 24, endMinutes: 0 };
* const oh1: OpeningHour = { weekday: 1, startHour: 0, startMinutes: 0, endHour: 12, endMinutes: 0 };
* OH.MergeTimes([oh0, oh1]) // => [{ weekday: 0, startHour: 10, startMinutes: 0, endHour: 24, endMinutes: 0 }, { weekday: 1, startHour: 0, startMinutes: 0, endHour: 12, endMinutes: 0 }]
*/
public static MergeTimes(ohs: OpeningHour[]): OpeningHour[] {
const queue = ohs.map((oh) => {
@ -216,6 +221,7 @@ export class OH {
// This means that the list is changed only if the lengths are different.
// If the lengths are the same, we might just as well return the old list and be a bit more stable
if (newList.length !== ohs.length) {
newList.sort((a, b) => b.weekday - a.weekday)
return newList
} else {
return ohs
@ -308,6 +314,12 @@ export class OH {
* rules[1].weekday // => 1
* rules[1].startHour // => 0
* rules[1].endHour // => 2
*
* const rules = OH.ParseRule("Mo 00:00-24:00")
* rules.length // => 1
* rules[0].weekday // => 0
* rules[0].startHour // => 0
* rules[0].endHour // => 24
*/
public static ParseRule(rule: string): OpeningHour[] {
try {
@ -781,6 +793,7 @@ This list will be sorted
/**
* OH.ParseHhmmRanges("20:00-22:15") // => [{startHour: 20, startMinutes: 0, endHour: 22, endMinutes: 15}]
* OH.ParseHhmmRanges("20:00-02:15") // => [{startHour: 20, startMinutes: 0, endHour: 2, endMinutes: 15}]
* OH.ParseHhmmRanges("00:00-24:00") // => [{startHour: 0, startMinutes: 0, endHour: 24, endMinutes: 0}]
*/
private static ParseHhmmRanges(hhmms: string): {
startHour: number
@ -897,6 +910,19 @@ This list will be sorted
}
export class ToTextualDescription {
/**
* const oh = new opening_hours("mon 12:00-16:00")
* const ranges = OH.createRangesForApplicableWeek(oh)
* const tr = ToTextualDescription.createTextualDescriptionFor(oh, ranges.ranges)
* tr.textFor("en") // => "On monday from 12:00 till 16:00"
* tr.textFor("nl") // => "Op maandag van 12:00 tot 16:00"
*
* const oh = new opening_hours("mon 12:00-16:00; tu 13:00-14:00")
* const ranges = OH.createRangesForApplicableWeek(oh)
* const tr = ToTextualDescription.createTextualDescriptionFor(oh, ranges.ranges)
* tr.textFor("en") // => "On monday from 12:00 till 16:00. On tuesday from 13:00 till 14:00"
* tr.textFor("nl") // => "Op maandag van 12:00 tot 16:00. Op dinsdag van 13:00 tot 14:00"
*/
public static createTextualDescriptionFor(
oh: opening_hours,
ranges: OpeningRange[][]
@ -972,10 +998,16 @@ export class ToTextualDescription {
}
private static chain(trs: Translation[]): Translation {
let chainer = new TypedTranslation<{ a; b }>({ "*": "{a}. {b}" })
const languages: Record<string, string> = {}
for (const tr1 of trs) {
for (const supportedLanguage of tr1.SupportedLanguages()) {
languages[supportedLanguage] = "{a}. {b}"
}
}
let chainer = new TypedTranslation<{ a; b }>(languages)
let tr = trs[0]
for (let i = 1; i < trs.length; i++) {
tr = chainer.Subs({ a: tr, b: trs[i] })
tr = chainer.PartialSubsTr("a", tr).PartialSubsTr("b", trs[i])
}
return tr
}

View file

@ -145,7 +145,7 @@ export default class OpeningHoursInput extends InputElement<string> {
return this._value
}
IsValid(t: string): boolean {
IsValid(_: string): boolean {
return true
}

View file

@ -23,7 +23,7 @@ export default class OpeningHoursPicker extends InputElement<OpeningHour[]> {
return this._ohs
}
IsValid(t: OpeningHour[]): boolean {
IsValid(_: OpeningHour[]): boolean {
return true
}

View file

@ -37,7 +37,7 @@ export default class OpeningHoursPickerTable extends InputElement<OpeningHour[]>
this.SetClass("w-full block")
}
IsValid(t: OpeningHour[]): boolean {
IsValid(_: OpeningHour[]): boolean {
return true
}

View file

@ -11,6 +11,7 @@ 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"
export default class OpeningHoursVisualization extends Toggle {
private static readonly weekdays: Translation[] = [
@ -53,8 +54,9 @@ export default class OpeningHoursVisualization extends Toggle {
applicableWeek.ranges,
applicableWeek.startingMonday
)
textual.current.addCallbackAndRunD((descr) => {
vis.ConstructElement().ariaLabel = descr
Locale.language.mapD((lng) => {
console.log("Setting OH description to", lng, textual)
vis.ConstructElement().ariaLabel = textual.textFor(lng)
})
return vis
})

View file

@ -21,7 +21,7 @@ export default class PublicHolidayInput extends InputElement<string> {
return this._value
}
IsValid(t: string): boolean {
IsValid(_: string): boolean {
return true
}

View file

@ -10,8 +10,6 @@
import NextButton from "../Base/NextButton.svelte"
import WikipediaPanel from "../Wikipedia/WikipediaPanel.svelte"
import { createEventDispatcher } from "svelte"
import ToSvelte from "../Base/ToSvelte.svelte"
import Svg from "../../Svg"
import Plantnet_logo from "../../assets/svg/Plantnet_logo.svelte"
/**
@ -28,7 +26,7 @@
const dispatch = createEventDispatcher<{ selected: string }>()
let collapsedMode = true
let options: UIEventSource<PlantNetSpeciesMatch[]> = new UIEventSource<PlantNetSpeciesMatch[]>(
undefined
undefined,
)
let error: string = undefined

View file

@ -28,12 +28,14 @@
import { onDestroy } from "svelte"
import NextButton from "../../Base/NextButton.svelte"
import BackButton from "../../Base/BackButton.svelte"
import ToSvelte from "../../Base/ToSvelte.svelte"
import Svg from "../../../Svg"
import OpenBackgroundSelectorButton from "../../BigComponents/OpenBackgroundSelectorButton.svelte"
import { twJoin } from "tailwind-merge"
import Confirm from "../../../assets/svg/Confirm.svelte"
import Close from "../../../assets/svg/Close.svelte"
import Layers from "../../../assets/svg/Layers.svelte"
import { Translation } from "../../i18n/Translation"
import ToSvelte from "../../Base/ToSvelte.svelte"
import BaseUIElement from "../../BaseUIElement"
export let coordinate: { lon: number; lat: number }
export let state: SpecialVisualizationState
@ -41,8 +43,9 @@
let selectedPreset: {
preset: PresetConfig
layer: LayerConfig
icon: string
tags: Record<string, string>
icon: BaseUIElement
tags: Record<string, string>,
text: Translation
} = undefined
let checkedOfGlobalFilters: number = 0
let confirmedCategory = false
@ -142,7 +145,6 @@
const feature = state.indexedFeatures.featuresById.data.get(newId)
console.log("Selecting feature", feature, "and opening their popup")
abort()
state.selectedLayer.setData(selectedPreset.layer)
state.selectedElement.setData(feature)
tagsStore.ping()
}
@ -198,7 +200,7 @@
state.guistate.openFilterView(selectedPreset.layer)
}}
>
<ToSvelte construct={Svg.layers_svg().SetClass("w-12")} />
<Layers class="w-12"/>
<Tr t={Translations.t.general.add.openLayerControl} />
</button>
@ -239,7 +241,7 @@
state.guistate.openFilterView(selectedPreset.layer)
}}
>
<ToSvelte construct={Svg.layers_svg().SetClass("w-12")} />
<Layers class="w-12"/>
<Tr t={Translations.t.general.add.openLayerControl} />
</button>
</div>
@ -251,8 +253,6 @@
/>
</h2>
<Tr t={Translations.t.general.add.confirmIntro} />
{#if selectedPreset.preset.description}
<Tr t={selectedPreset.preset.description} />
{/if}
@ -284,7 +284,7 @@
<NextButton on:click={() => (confirmedCategory = true)} clss="primary w-full">
<div slot="image" class="relative">
<ToSvelte construct={selectedPreset.icon} />
<ToSvelte construct={selectedPreset.icon}/>
<Confirm class="absolute bottom-0 right-0 h-4 w-4" />
</div>
<div class="w-full">
@ -303,7 +303,7 @@
<Tr
slot="message"
t={_globalFilter[checkedOfGlobalFilters].onNewPoint?.confirmAddNew.Subs({
preset: selectedPreset.preset,
preset: selectedPreset.text
})}
/>
</SubtleButton>

View file

@ -9,11 +9,10 @@
import { ImmutableStore } from "../../../Logic/UIEventSource"
import { TagUtils } from "../../../Logic/Tags/TagUtils"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import FromHtml from "../../Base/FromHtml.svelte"
import NextButton from "../../Base/NextButton.svelte"
import { UIElement } from "../../UIElement"
import ToSvelte from "../../Base/ToSvelte.svelte"
import BaseUIElement from "../../BaseUIElement"
import Combine from "../../Base/Combine"
/**
* This component lists all the presets and allows the user to select one
@ -24,6 +23,10 @@
preset: PresetConfig
layer: LayerConfig
text: Translation
/**
* Same as `this.preset.description.firstSentence()`
*/
description: Translation,
icon: BaseUIElement
tags: Record<string, string>
}[] = []
@ -37,7 +40,7 @@
"Not showing presets for layer",
flayer.layerDef.id,
"as not displayed and featureSwitchFilter.data is set",
state.featureSwitches.featureSwitchFilter.data
state.featureSwitches.featureSwitchFilter.data,
)
// ...and we cannot enable the layer control -> we skip, as these presets can never be shown anyway
continue
@ -52,9 +55,9 @@
for (const preset of layer.presets) {
const tags = TagUtils.KVtoProperties(preset.tags ?? [])
const icon: BaseUIElement = layer.mapRendering[0]
.RenderIcon(new ImmutableStore<any>(tags))
.html.SetClass("w-12 h-12 block relative mr-4")
const markers = layer.mapRendering.map((mr, i) => mr.RenderIcon(new ImmutableStore<any>(tags), {noSize: i == 0})
.html.SetClass(i == 0 ? "w-full h-full" : ""))
const icon: BaseUIElement = new Combine(markers.map(m => new Combine([m]).SetClass("absolute top-0 left-0 w-full h-full flex justify-around items-center"))).SetClass("w-12 h-12 block relative mr-4")
const description = preset.description?.FirstSentence()
@ -66,7 +69,7 @@
tags,
text: Translations.t.general.add.addNew.Subs(
{ category: preset.title },
preset.title["context"]
preset.title["context"],
),
}
presets.push(simplified)
@ -74,7 +77,13 @@
}
const dispatch = createEventDispatcher<{
select: { preset: PresetConfig; layer: LayerConfig; icon: string; tags: Record<string, string> }
select: {
preset: PresetConfig;
layer: LayerConfig;
icon: BaseUIElement;
tags: Record<string, string>,
text: Translation
}
}>()
</script>

View file

@ -1,46 +0,0 @@
import { SearchablePillsSelector } from "../Input/SearchableMappingsSelector"
import { Store } from "../../Logic/UIEventSource"
import BaseUIElement from "../BaseUIElement"
import all_languages from "../../assets/language_translations.json"
import { Translation } from "../i18n/Translation"
export class AllLanguagesSelector extends SearchablePillsSelector<string> {
constructor(options?: {
mode?: "select-many" | "select-one"
currentCountry?: Store<string>
supportedLanguages?: Record<string, string> & { _meta?: { countries?: string[] } }
}) {
const possibleValues: {
show: BaseUIElement
value: string
mainTerm: Record<string, string>
searchTerms?: Record<string, string[]>
hasPriority?: Store<boolean>
}[] = []
const langs = options?.supportedLanguages ?? all_languages
for (const ln in langs) {
let languageInfo: Record<string, string> & { _meta?: { countries: string[] } } =
all_languages[ln]
const countries = languageInfo._meta?.countries?.map((c) => c.toLowerCase())
languageInfo = { ...languageInfo }
delete languageInfo._meta
const term = {
show: new Translation(languageInfo),
value: ln,
mainTerm: languageInfo,
searchTerms: { "*": [ln] },
hasPriority:
countries === undefined
? undefined
: options?.currentCountry?.map(
(country) => countries?.indexOf(country.toLowerCase()) >= 0
),
}
possibleValues.push(term)
}
super(possibleValues, {
mode: options?.mode ?? "select-many",
})
}
}

View file

@ -1,67 +1,100 @@
<script lang="ts">
import ToSvelte from "../Base/ToSvelte.svelte"
import Table from "../Base/Table"
import { UIEventSource } from "../../Logic/UIEventSource"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import SimpleMetaTaggers from "../../Logic/SimpleMetaTagger"
import { FixedUiElement } from "../Base/FixedUiElement"
import { onDestroy } from "svelte"
import Toggle from "../Input/Toggle"
import Lazy from "../Base/Lazy"
import BaseUIElement from "../BaseUIElement"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { VariableUiElement } from "../Base/VariableUIElement"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
//Svelte props
export let tags: UIEventSource<any>
export let state: { layoutToUse: LayoutConfig } = undefined
export let tags: UIEventSource<Record<string, any>>
export let tagKeys = tags.map(tgs => Object.keys(tgs))
const calculatedTags = [].concat(
...(state?.layoutToUse?.layers?.map((l) => l.calculatedTags?.map((c) => c[0]) ?? []) ?? [])
)
export let layer: LayerConfig | undefined = undefined
const allTags = tags.mapD((tags) => {
const parts: (string | BaseUIElement)[][] = []
for (const key in tags) {
let v = tags[key]
if (v === "") {
v = "<b>empty string</b>"
}
parts.push([key, v ?? "<b>undefined</b>"])
/**
* The names (keys) of the calculated tags. Each will normally start with an underscore (but in rare cases not)
*/
let calculatedTags: string[] = []
for (const calculated of layer?.calculatedTags ?? []) {
if(calculated){
continue
}
const name = calculated[0]
calculatedTags.push(name)
}
let knownValues: Store<string[]> = tags.map(tags => Object.keys(tags))
for (const key of calculatedTags) {
const value = tags[key]
if (value === undefined) {
continue
}
let type = ""
if (typeof value !== "string") {
type = " <i>" + typeof value + "</i>"
}
parts.push(["<i>" + key + "</i>", value])
}
const metaKeys: string[] = [].concat(...SimpleMetaTaggers.metatags.map(k => k.keys))
let allCalculatedTags = new Set<string>([...calculatedTags, ...metaKeys])
for (const metatag of SimpleMetaTaggers.metatags.filter((mt) => mt.isLazy)) {
const title = "<i>" + metatag.keys.join(";") + "</i> (lazy)"
const toggleState = new UIEventSource(false)
const toggle: BaseUIElement = new Toggle(
new Lazy(() => new FixedUiElement(metatag.keys.map((key) => tags[key]).join(";"))),
new FixedUiElement("Evaluate").onClick(() => toggleState.setData(true)),
toggleState
)
parts.push([title, toggle])
}
return parts
})
const tagsTable = new VariableUiElement(
allTags.mapD((_allTags) =>
new Table(["Key", "Value"], _allTags).SetClass("zebra-table break-all")
)
)
</script>
<section>
<ToSvelte construct={tagsTable} />
<table class="zebra-table break-all">
<tr>
<th>Key</th>
<th>Value</th>
</tr>
<tr>
<th colspan="2">Normal tags</th>
</tr>
{#each $tagKeys as key}
{#if !allCalculatedTags.has(key)}
<tr>
<td>{key}</td>
<td>
{#if $tags[key] === undefined}
<i>undefined</i>
{:else if $tags[key] === ""}
<i>Empty string</i>
{:else}
{$tags[key]}
{/if}
</td>
</tr>
{/if}
{/each}
<tr>
<th colspan="2">Calculated tags</th>
</tr>
{#if calculatedTags.length === 0}
<tr>
<td colspan="2"><i>This layer does not use calculated tags</i></td>
</tr>
{/if}
{#each calculatedTags as key}
<tr>
<td>{key}</td>
<td>
{#if $tags[key] === undefined}
<i>undefined</i>
{:else if $tags[key] === ""}
<i>Empty string</i>
{:else if $tags[key] !== "string"}
<span class="literal-code">{$tags[key]}</span>
<i>{typeof $tags[key]}</i>
{:else}
{$tags[key]}
{/if}
</td>
</tr>
{/each}
<tr>
<th colspan="2">Metatags tags</th>
</tr>
{#each metaKeys as key}
<tr>
<td>{key}</td>
<td>
{#if $knownValues.indexOf(key) < 0 }
<button class="small" on:click={_ => {console.log($tags[key])}}>Evaluate</button>
{:else if !$tags[key] === undefined}
<i>Undefined</i>
{:else if $tags[key] === ""}
<i>Empty string</i>
{:else}
{$tags[key]}
{/if}
</td>
</tr>
{/each}
</table>
</section>

View file

@ -10,9 +10,8 @@
import type { Feature } from "geojson"
import { UIEventSource } from "../../../Logic/UIEventSource"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import { TagsFilter } from "../../../Logic/Tags/TagsFilter"
import { XCircleIcon } from "@rgossiaux/svelte-heroicons/solid"
import { TagUtils } from "../../../Logic/Tags/TagUtils"
import { TagUtils } from "../../../Logic/Tags/TagUtils"
import type { UploadableTag } from "../../../Logic/Tags/TagUtils"
import OsmChangeAction from "../../../Logic/Osm/Actions/OsmChangeAction"
import DeleteAction from "../../../Logic/Osm/Actions/DeleteAction"
import ChangeTagAction from "../../../Logic/Osm/Actions/ChangeTagAction"
@ -43,7 +42,7 @@
const t = Translations.t.delete
let selectedTags: TagsFilter
let selectedTags: UploadableTag
let changedProperties = undefined
$: changedProperties = TagUtils.changeAsProperties(selectedTags?.asChange(tags?.data ?? {}) ?? [])
let isHardDelete = undefined
@ -66,7 +65,7 @@
theme: state?.layout?.id ?? "unknown",
specialMotivation: deleteReason,
},
canBeDeleted.data
canBeDeleted.data,
)
} else {
// no _delete_reason is given, which implies that this is _not_ a deletion but merely a retagging via a nonDeleteMapping

View file

@ -2,7 +2,6 @@ import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { SpecialVisualization, SpecialVisualizationState } from "../SpecialVisualization"
import Histogram from "../BigComponents/Histogram"
import { Feature } from "geojson"
import Constants from "../../Models/Constants"
export class HistogramViz implements SpecialVisualization {
funcName = "histogram"

View file

@ -86,8 +86,7 @@ export default class ConflateImportButtonViz implements SpecialVisualization, Au
state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig
feature: Feature
): BaseUIElement {
const canBeImported =
feature.geometry.type === "LineString" ||

View file

@ -1,42 +1,58 @@
<script lang="ts">
/**
* The 'importflow' does some basic setup, e.g. validate that imports are allowed, that the user is logged-in, ...
* They show some default components
*/
import ImportFlow from "./ImportFlow"
import LoginToggle from "../../Base/LoginToggle.svelte"
import BackButton from "../../Base/BackButton.svelte"
import Translations from "../../i18n/Translations"
import Tr from "../../Base/Tr.svelte"
import NextButton from "../../Base/NextButton.svelte"
import { createEventDispatcher } from "svelte"
import Loading from "../../Base/Loading.svelte"
import { And } from "../../../Logic/Tags/And"
import TagHint from "../TagHint.svelte"
import { TagsFilter } from "../../../Logic/Tags/TagsFilter"
import { Store } from "../../../Logic/UIEventSource"
import Svg from "../../../Svg"
import ToSvelte from "../../Base/ToSvelte.svelte"
import { EyeIcon, EyeOffIcon } from "@rgossiaux/svelte-heroicons/solid"
import Confirm from "../../../assets/svg/Confirm.svelte"
import type { ImportFlowArguments } from "./ImportFlow"
/**
* The 'importflow' does some basic setup, e.g. validate that imports are allowed, that the user is logged-in, ...
* They show some default components
*/
import ImportFlow from "./ImportFlow"
import LoginToggle from "../../Base/LoginToggle.svelte"
import BackButton from "../../Base/BackButton.svelte"
import Translations from "../../i18n/Translations"
import Tr from "../../Base/Tr.svelte"
import NextButton from "../../Base/NextButton.svelte"
import { createEventDispatcher, onDestroy } from "svelte"
import Loading from "../../Base/Loading.svelte"
import { And } from "../../../Logic/Tags/And"
import TagHint from "../TagHint.svelte"
import { TagsFilter } from "../../../Logic/Tags/TagsFilter"
import { Store } from "../../../Logic/UIEventSource"
import Svg from "../../../Svg"
import ToSvelte from "../../Base/ToSvelte.svelte"
import { EyeIcon, EyeOffIcon } from "@rgossiaux/svelte-heroicons/solid"
import FilteredLayer from "../../../Models/FilteredLayer"
import Confirm from "../../../assets/svg/Confirm.svelte"
export let importFlow: ImportFlow
let state = importFlow.state
export let importFlow: ImportFlow<ImportFlowArguments>
let state = importFlow.state
export let currentFlowStep: "start" | "confirm" | "importing" | "imported" = "start"
export let currentFlowStep: "start" | "confirm" | "importing" | "imported" = "start"
const isLoading = state.dataIsLoading
const dispatch = createEventDispatcher<{ confirm }>()
const canBeImported = importFlow.canBeImported()
const tags: Store<TagsFilter> = importFlow.tagsToApply.map((tags) => new And(tags))
const isLoading = state.dataIsLoading
let dispatch = createEventDispatcher<{ confirm }>()
let canBeImported = importFlow.canBeImported()
let tags: Store<TagsFilter> = importFlow.tagsToApply.map((tags) => new And(tags))
const isDisplayed = importFlow.targetLayer.isDisplayed
const hasFilter = importFlow.targetLayer.hasFilter
function abort() {
state.selectedElement.setData(undefined)
state.selectedLayer.setData(undefined)
}
let targetLayers = importFlow.targetLayer
let filteredLayer: FilteredLayer
let undisplayedLayer: FilteredLayer
function updateIsDisplayed() {
filteredLayer = targetLayers.find(tl => tl.hasFilter.data)
undisplayedLayer = targetLayers.find(tl => !tl.isDisplayed.data)
}
updateIsDisplayed()
for (const tl of targetLayers) {
onDestroy(
tl.isDisplayed.addCallback(updateIsDisplayed),
)
}
function abort() {
state.selectedElement.setData(undefined)
}
</script>
<LoginToggle {state}>
@ -45,13 +61,13 @@
{#if $canBeImported.extraHelp}
<Tr t={$canBeImported.extraHelp} />
{/if}
{:else if !$isDisplayed}
{:else if undisplayedLayer !== undefined}
<!-- Check that the layer is enabled, so that we don't add a duplicate -->
<div class="alert flex items-center justify-center">
<EyeOffIcon class="w-8" />
<Tr
t={Translations.t.general.add.layerNotEnabled.Subs({
layer: importFlow.targetLayer.layerDef.name,
layer: undisplayedLayer.layerDef.name,
})}
/>
</div>
@ -61,7 +77,7 @@
class="flex w-full gap-x-1"
on:click={() => {
abort()
state.guistate.openFilterView(importFlow.targetLayer.layerDef)
state.guistate.openFilterView(filteredLayer.layerDef)
}}
>
<ToSvelte construct={Svg.layers_svg().SetClass("w-12")} />
@ -71,19 +87,19 @@
<button
class="primary flex w-full gap-x-1"
on:click={() => {
isDisplayed.setData(true)
undisplayedLayer.isDisplayed.setData(true)
abort()
}}
>
<EyeIcon class="w-12" />
<Tr
t={Translations.t.general.add.enableLayer.Subs({
name: importFlow.targetLayer.layerDef.name,
name: undisplayedLayer.layerDef.name,
})}
/>
</button>
</div>
{:else if $hasFilter}
{:else if filteredLayer !== undefined}
<!-- Some filters are enabled. The feature to add might already be mapped, but hidden -->
<div class="alert flex items-center justify-center">
<EyeOffIcon class="w-8" />
@ -94,7 +110,7 @@
class="primary flex w-full gap-x-1"
on:click={() => {
abort()
importFlow.targetLayer.disableAllFilters()
filteredLayer.disableAllFilters()
}}
>
<EyeOffIcon class="w-12" />
@ -104,7 +120,7 @@
class="flex w-full gap-x-1"
on:click={() => {
abort()
state.guistate.openFilterView(importFlow.targetLayer.layerDef)
state.guistate.openFilterView(filteredLayer.layerDef)
}}
>
<ToSvelte construct={Svg.layers_svg().SetClass("w-12")} />

View file

@ -6,7 +6,6 @@ import TagApplyButton from "../TagApplyButton"
import { PointImportFlowArguments } from "./PointImportFlowState"
import { Translation } from "../../i18n/Translation"
import Translations from "../../i18n/Translations"
import { OsmConnection } from "../../../Logic/Osm/OsmConnection"
import FilteredLayer from "../../../Models/FilteredLayer"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import { LayerConfigJson } from "../../../Models/ThemeConfig/Json/LayerConfigJson"
@ -25,7 +24,7 @@ export class ImportFlowUtils {
public static readonly conflationLayer = new LayerConfig(
<LayerConfigJson>conflation_json,
"all_known_layers",
true
true,
)
public static readonly documentationGeneral = `\n\n\nNote that the contributor must zoom to at least zoomlevel 18 to be able to use this functionality.
@ -67,7 +66,7 @@ ${Utils.special_visualizations_importRequirementDocs}
*/
public static getTagsToApply(
originalFeatureTags: UIEventSource<any>,
args: { tags: string }
args: { tags: string },
): Store<Tag[]> {
if (originalFeatureTags === undefined) {
return undefined
@ -83,9 +82,9 @@ ${Utils.special_visualizations_importRequirementDocs}
const items: string = originalFeatureTags.data[tags]
console.debug(
"The import button is using tags from properties[" +
tags +
"] of this object, namely ",
items
tags +
"] of this object, namely ",
items,
)
if (items.startsWith("{")) {
@ -108,13 +107,12 @@ ${Utils.special_visualizations_importRequirementDocs}
* - targetLayer
*
* Others (e.g.: snapOnto-layers) are not to be handled here
* @param argsRaw
*/
public static getLayerDependencies(argsRaw: string[], argSpec?) {
public static getLayerDependencies(argsRaw: string[], argSpec?): string[] {
const args: ImportFlowArguments = <any>(
Utils.ParseVisArgs(argSpec ?? ImportFlowUtils.generalArguments, argsRaw)
)
return [args.targetLayer]
return args.targetLayer.split(" ")
}
public static getLayerDependenciesWithSnapOnto(
@ -122,7 +120,7 @@ ${Utils.special_visualizations_importRequirementDocs}
name: string
defaultValue?: string
}[],
argsRaw: string[]
argsRaw: string[],
): string[] {
const deps = ImportFlowUtils.getLayerDependencies(argsRaw, argSpec)
const argsParsed: PointImportFlowArguments = <any>Utils.ParseVisArgs(argSpec, argsRaw)
@ -130,30 +128,6 @@ ${Utils.special_visualizations_importRequirementDocs}
deps.push(...snapOntoLayers)
return deps
}
public static buildTagSpec(
args: ImportFlowArguments,
tagSource: Store<Record<string, string>>
): Store<string> {
let tagSpec = args.tags
return tagSource.mapD((tags) => {
if (
tagSpec.indexOf(" ") < 0 &&
tagSpec.indexOf(";") < 0 &&
tags[args.tags] !== undefined
) {
// This is probably a key
tagSpec = tags[args.tags]
console.debug(
"The import button is using tags from properties[" +
args.tags +
"] of this object, namely ",
tagSpec
)
}
return tagSpec
})
}
}
/**
@ -164,7 +138,7 @@ ${Utils.special_visualizations_importRequirementDocs}
export default abstract class ImportFlow<ArgT extends ImportFlowArguments> {
public readonly state: SpecialVisualizationState
public readonly args: ArgT
public readonly targetLayer: FilteredLayer
public readonly targetLayer: FilteredLayer[]
public readonly tagsToApply: Store<Tag[]>
protected readonly _originalFeatureTags: UIEventSource<Record<string, string>>
@ -172,13 +146,19 @@ export default abstract class ImportFlow<ArgT extends ImportFlowArguments> {
state: SpecialVisualizationState,
args: ArgT,
tagsToApply: Store<Tag[]>,
originalTags: UIEventSource<Record<string, string>>
originalTags: UIEventSource<Record<string, string>>,
) {
this.state = state
this.args = args
this.tagsToApply = tagsToApply
this._originalFeatureTags = originalTags
this.targetLayer = state.layerState.filteredLayers.get(args.targetLayer)
this.targetLayer = args.targetLayer.split(" ").map(tl => {
let found = state.layerState.filteredLayers.get(tl)
if (!found) {
throw "Layer " + tl + " not found"
}
return found
})
}
/**
@ -218,7 +198,7 @@ export default abstract class ImportFlow<ArgT extends ImportFlowArguments> {
return undefined
},
[state.mapProperties.zoom, state.dataIsLoading, this._originalFeatureTags]
[state.mapProperties.zoom, state.dataIsLoading, this._originalFeatureTags],
)
}
}

View file

@ -17,7 +17,7 @@ export class PointImportButtonViz implements SpecialVisualization {
public readonly funcName: string
public readonly docs: string | BaseUIElement
public readonly example?: string
public readonly args: { name: string; defaultValue?: string; doc: string }[]
public readonly args: { name: string; defaultValue?: string; doc: string, split?: boolean }[]
public needsUrls = []
constructor() {
@ -51,8 +51,7 @@ export class PointImportButtonViz implements SpecialVisualization {
state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig
feature: Feature
): BaseUIElement {
if (feature.geometry.type !== "Point") {
return Translations.t.general.add.import.wrongType.SetClass("alert")

View file

@ -13,7 +13,7 @@
const args = importFlow.args
// The following variables are used for the map
const targetLayer: LayerConfig = state.layout.layers.find((l) => l.id === args.targetLayer)
const targetLayers: LayerConfig[] = args.targetLayer.split(" ").map(tl => state.layout.layers.find((l) => l.id === tl))
const snapToLayers: string[] | undefined =
args.snap_onto_layers?.split(",")?.map((l) => l.trim()) ?? []
const maxSnapDistance: number = Number(args.max_snap_distance ?? 25) ?? 25
@ -33,21 +33,20 @@
async function onConfirm(): Promise<void> {
const importedId = await importFlow.onConfirm(value.data, snappedTo.data)
state.selectedLayer.setData(targetLayer)
state.selectedElement.setData(state.indexedFeatures.featuresById.data.get(importedId))
}
</script>
<ImportFlow {importFlow} on:confirm={onConfirm}>
<div class="relative" slot="map">
<div class="h-32">
<div class="h-64">
<NewPointLocationInput
coordinate={startCoordinate}
{maxSnapDistance}
{snapToLayers}
{snappedTo}
{state}
{targetLayer}
targetLayer={targetLayers}
{value}
/>
</div>

View file

@ -1,254 +0,0 @@
import { SpecialVisualization, SpecialVisualizationState } from "../SpecialVisualization"
import BaseUIElement from "../BaseUIElement"
import { UIEventSource } from "../../Logic/UIEventSource"
import { VariableUiElement } from "../Base/VariableUIElement"
import all_languages from "../../assets/language_translations.json"
import { Translation } from "../i18n/Translation"
import Combine from "../Base/Combine"
import Title from "../Base/Title"
import Lazy from "../Base/Lazy"
import { SubstitutedTranslation } from "../SubstitutedTranslation"
import List from "../Base/List"
import { AllLanguagesSelector } from "./AllLanguagesSelector"
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"
import { And } from "../../Logic/Tags/And"
import { Tag } from "../../Logic/Tags/Tag"
import { EditButton, SaveButton } from "./SaveButton"
import Translations from "../i18n/Translations"
import Toggle from "../Input/Toggle"
import { Feature } from "geojson"
export class LanguageElement implements SpecialVisualization {
funcName: string = "language_chooser"
needsUrls = []
docs: string | BaseUIElement =
"The language element allows to show and pick all known (modern) languages. The key can be set"
args: { name: string; defaultValue?: string; doc: string; required?: boolean }[] = [
{
name: "key",
required: true,
doc: "What key to use, e.g. `language`, `tactile_writing:braille:language`, ... If a language is supported, the language code will be appended to this key, resulting in `language:nl=yes` if nl is picked ",
},
{
name: "question",
required: true,
doc: "What to ask if no questions are known",
},
{
name: "render_list_item",
doc: "How a single language will be shown in the list of languages. Use `{language}` to indicate the language (which it must contain).",
defaultValue: "{language()}",
},
{
name: "render_single_language",
doc: "What will be shown if the feature only supports a single language",
required: true,
},
{
name: "render_all",
doc: "The full rendering. Use `{list}` to show where the list of languages must come. Optional if mode=single",
defaultValue: "{list()}",
},
{
name: "no_known_languages",
doc: "The text that is shown if no languages are known for this key. If this text is omitted, the languages will be prompted instead",
},
{
name: "mode",
doc: "If one or many languages can be selected. Should be 'multi' or 'single'",
defaultValue: "multi",
},
]
example: `
\`\`\`json
{"special":
"type": "language_chooser",
"key": "school:language",
"question": {"en": "What are the main (and administrative) languages spoken in this school?"},
"render_single_language": {"en": "{language()} is spoken on this school"},
"render_list_item": {"en": "{language()}"},
"render_all": {"en": "The following languages are spoken here:{list()}"}
"mode":"multi"
}
\`\`\`
`
constr(
state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature
): BaseUIElement {
let [key, question, item_render, single_render, all_render, on_no_known_languages, mode] =
argument
if (mode === undefined || mode.length == 0) {
mode = "multi"
}
if (item_render === undefined || item_render.trim() === "") {
item_render = "{language()}"
}
if (all_render === undefined || all_render.length == 0) {
all_render = "{list()}"
}
if (mode !== "single" && mode !== "multi") {
throw (
"Error while calling language_chooser: mode must be either 'single' or 'multi' but it is " +
mode
)
}
if (single_render.indexOf("{language()") < 0) {
throw (
"Error while calling language_chooser: render_single_language must contain '{language()}' but it is " +
single_render
)
}
if (item_render.indexOf("{language()") < 0) {
throw (
"Error while calling language_chooser: render_list_item must contain '{language()}' but it is " +
item_render
)
}
if (all_render.indexOf("{list()") < 0) {
throw "Error while calling language_chooser: render_all must contain '{list()}'"
}
const prefix = key + ":"
const foundLanguages = tagSource.map((tags) => {
const foundLanguages: string[] = []
for (const k in tags) {
const v = tags[k]
if (v !== "yes") {
continue
}
if (k.startsWith(prefix)) {
foundLanguages.push(k.substring(prefix.length))
}
}
return foundLanguages
})
const forceInputMode = new UIEventSource(false)
const inputEl = new Lazy(() => {
const selector = new AllLanguagesSelector({
mode: mode === "single" ? "select-one" : "select-many",
currentCountry: tagSource.map((tgs) => tgs["_country"]),
})
const cancelButton = Toggle.If(forceInputMode, () =>
Translations.t.general.cancel
.Clone()
.SetClass("btn btn-secondary")
.onClick(() => forceInputMode.setData(false))
)
const saveButton = new SaveButton(
selector.GetValue().map((lngs) => (lngs.length > 0 ? "true" : undefined)),
state
).onClick(() => {
const selectedLanguages = selector.GetValue().data
const currentLanguages = foundLanguages.data
const selection: Tag[] = selectedLanguages.map((ln) => new Tag(prefix + ln, "yes"))
for (const currentLanguage of currentLanguages) {
if (selectedLanguages.indexOf(currentLanguage) >= 0) {
continue
}
// Erase language that is not spoken anymore
selection.push(new Tag(prefix + currentLanguage, ""))
}
if (state.featureSwitchIsTesting.data) {
for (const tag of selection) {
tagSource.data[tag.key] = tag.value
}
tagSource.ping()
} else {
;(state?.changes)
.applyAction(
new ChangeTagAction(
tagSource.data.id,
new And(selection),
tagSource.data,
{
theme: state?.layout?.id ?? "unkown",
changeType: "answer",
}
)
)
.then((_) => {
console.log("Tagchanges applied")
})
}
forceInputMode.setData(false)
})
return new Combine([
new Title(question),
selector,
new Combine([cancelButton, saveButton]).SetClass("flex justify-end"),
]).SetClass("flex flex-col question disable-links")
})
const editButton = new EditButton(state.osmConnection, () => forceInputMode.setData(true))
return new VariableUiElement(
foundLanguages.map(
(foundLanguages) => {
if (forceInputMode.data) {
return inputEl
}
if (foundLanguages.length === 0) {
// No languages found - we show the question and the input element
if (
on_no_known_languages !== undefined &&
on_no_known_languages.length > 0
) {
return new Combine([on_no_known_languages, editButton]).SetClass(
"flex justify-end"
)
}
return inputEl
}
let rendered: BaseUIElement
if (foundLanguages.length === 1) {
const ln = foundLanguages[0]
let mapping = new Map<string, BaseUIElement>()
mapping.set("language", new Translation(all_languages[ln]))
rendered = new SubstitutedTranslation(
new Translation({ "*": single_render }, undefined),
tagSource,
state,
mapping
)
} else {
let mapping = new Map<string, BaseUIElement>()
const languagesList = new List(
foundLanguages.map((ln) => {
let mappingLn = new Map<string, BaseUIElement>()
mappingLn.set("language", new Translation(all_languages[ln]))
return new SubstitutedTranslation(
new Translation({ "*": item_render }, undefined),
tagSource,
state,
mappingLn
)
})
)
mapping.set("list", languagesList)
rendered = new SubstitutedTranslation(
new Translation({ "*": all_render }, undefined),
tagSource,
state,
mapping
)
}
return new Combine([rendered, editButton]).SetClass("flex justify-between")
},
[forceInputMode]
)
)
}
}

View file

@ -0,0 +1,46 @@
<script lang="ts">
import { Store, UIEventSource } from "../../../Logic/UIEventSource"
import SpecialTranslation from "../TagRendering/SpecialTranslation.svelte"
import { Translation, TypedTranslation } from "../../i18n/Translation"
import type { SpecialVisualizationState } from "../../SpecialVisualization"
import type { Feature } from "geojson"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import * as all_languages from "../../../assets/language_translations.json"
/**
* Visualizes a list of the known languages
*/
export let languages: Store<string[]>
export let single_render: string
export let item_render: string
export let render_all: string // Should contain one `{list()}`
export let tags: UIEventSource<Record<string, string>>
export let state: SpecialVisualizationState
export let feature: Feature
export let layer: LayerConfig | undefined
let [beforeListing, afterListing] = (render_all ?? "{list()}").split("{list()}")
</script>
{#if $languages.length === 1}
<SpecialTranslation {state} {tags} {feature} {layer}
t={new TypedTranslation({"*": single_render}).PartialSubsTr(
"language()",
new Translation(all_languages[$languages[0]], undefined)
)}/>
{:else}
{beforeListing}
<ul>
{#each $languages as language}
<li>
<SpecialTranslation {state} {tags} {feature} {layer} t={
new TypedTranslation({"*": item_render}).PartialSubsTr("language()",
new Translation(all_languages[language], undefined) )}
/>
</li>
{/each}
</ul>
{afterListing}
{/if}

View file

@ -0,0 +1,69 @@
<script lang="ts">
import { UIEventSource } from "../../../Logic/UIEventSource"
import LanguageQuestion from "./LanguageQuestion.svelte"
import LanguageAnswer from "./LanguageAnswer.svelte"
import Tr from "../../Base/Tr.svelte"
import Translations from "../../i18n/Translations"
import type { SpecialVisualizationState } from "../../SpecialVisualization"
import type { Feature } from "geojson"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import EditButton from "../TagRendering/EditButton.svelte"
export let key: string
export let tags: UIEventSource<Record<string, string>>
export let state: SpecialVisualizationState
export let feature: Feature
export let layer: LayerConfig | undefined
export let question: string
export let on_no_known_languages: string = undefined
export let single_render: string
export let item_render: string
export let render_all: string // Should contain one `{list()}`
let prefix = key + ":"
let foundLanguages = tags.map((tags) => {
const foundLanguages: string[] = []
for (const k in tags) {
const v = tags[k]
if (v !== "yes") {
continue
}
if (k.startsWith(prefix)) {
foundLanguages.push(k.substring(prefix.length))
}
}
return foundLanguages
})
const forceInputMode = new UIEventSource(false)
</script>
{#if $foundLanguages.length === 0 && on_no_known_languages && !$forceInputMode}
<div class="p-1 flex items-center justify-between low-interaction rounded">
<div>
{on_no_known_languages}
</div>
<EditButton on:click={_ => forceInputMode.setData(true)} />
</div>
{:else if $forceInputMode || $foundLanguages.length === 0}
<LanguageQuestion {question} {foundLanguages} {prefix} {state} {tags} {feature} {layer}
on:save={_ => forceInputMode.setData(false)}>
<span slot="cancel-button">
{#if $forceInputMode}
<button on:click={_ => forceInputMode.setData(false)}>
<Tr t={Translations.t.general.cancel} />
</button>
{/if}
</span>
</LanguageQuestion>
{:else}
<div class="p-2 flex items-center justify-between low-interaction rounded">
<div>
<LanguageAnswer {single_render} {item_render} {render_all} languages={foundLanguages} {state} {tags} { feature}
{layer} />
</div>
<EditButton on:click={_ => forceInputMode.setData(true)} />
</div>
{/if}

View file

@ -0,0 +1,107 @@
import { SpecialVisualization, SpecialVisualizationState } from "../../SpecialVisualization"
import BaseUIElement from "../../BaseUIElement"
import { UIEventSource } from "../../../Logic/UIEventSource"
import SvelteUIElement from "../../Base/SvelteUIElement"
import { Feature } from "geojson"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import { default as LanguageElementSvelte } from "./LanguageElement.svelte"
export class LanguageElement implements SpecialVisualization {
funcName: string = "language_chooser"
needsUrls = []
docs: string | BaseUIElement =
"The language element allows to show and pick all known (modern) languages. The key can be set"
args: { name: string; defaultValue?: string; doc: string; required?: boolean }[] = [
{
name: "key",
required: true,
doc: "What key to use, e.g. `language`, `tactile_writing:braille:language`, ... If a language is supported, the language code will be appended to this key, resulting in `language:nl=yes` if nl is picked ",
},
{
name: "question",
required: true,
doc: "What to ask if no questions are known",
},
{
name: "render_list_item",
doc: "How a single language will be shown in the list of languages. Use `{language}` to indicate the language (which it must contain).",
defaultValue: "{language()}",
},
{
name: "render_single_language",
doc: "What will be shown if the feature only supports a single language",
required: true,
},
{
name: "render_all",
doc: "The full rendering. Use `{list}` to show where the list of languages must come. Optional if mode=single",
defaultValue: "{list()}",
},
{
name: "no_known_languages",
doc: "The text that is shown if no languages are known for this key. If this text is omitted, the languages will be prompted instead",
},
]
example: `
\`\`\`json
{"special":
"type": "language_chooser",
"key": "school:language",
"question": {"en": "What are the main (and administrative) languages spoken in this school?"},
"render_single_language": {"en": "{language()} is spoken on this school"},
"render_list_item": {"en": "{language()}"},
"render_all": {"en": "The following languages are spoken here:{list()}"}
}
\`\`\`
`
constr(
state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig
): BaseUIElement {
let [key, question, item_render, single_render, all_render, on_no_known_languages] =
argument
if (item_render === undefined || item_render.trim() === "") {
item_render = "{language()}"
}
if (all_render === undefined || all_render.length == 0) {
all_render = "{list()}"
}
if (single_render.indexOf("{language()") < 0) {
throw (
"Error while calling language_chooser: render_single_language must contain '{language()}' but it is " +
single_render
)
}
if (item_render.indexOf("{language()") < 0) {
throw (
"Error while calling language_chooser: render_list_item must contain '{language()}' but it is " +
item_render
)
}
if (all_render.indexOf("{list()") < 0) {
throw "Error while calling language_chooser: render_all must contain '{list()}'"
}
if (on_no_known_languages === "") {
on_no_known_languages = undefined
}
return new SvelteUIElement(LanguageElementSvelte, {
key,
tags: tagSource,
state,
feature,
layer,
question,
on_no_known_languages,
single_render,
item_render,
})
}
}

View file

@ -0,0 +1,133 @@
<script lang="ts">/**
* An input element which allows to select one or more langauges
*/
import { UIEventSource } from "../../../Logic/UIEventSource"
import all_languages from "../../../assets/language_translations.json"
import { Translation } from "../../i18n/Translation"
import Tr from "../../Base/Tr.svelte"
import Translations from "../../i18n/Translations.js"
import { SearchIcon } from "@rgossiaux/svelte-heroicons/solid"
import Locale from "../../i18n/Locale"
/**
* Will contain one or more ISO-language codes
*/
export let selectedLanguages: UIEventSource<string[]>
/**
* The country (countries) that the point lies in.
* Note that a single place might be claimed by multiple countries
*/
export let countries: Set<string>
let searchValue: UIEventSource<string> = new UIEventSource<string>("")
let searchLC = searchValue.mapD(search => search.toLowerCase())
const knownLanguagecodes = Object.keys(all_languages)
let probableLanguages = []
let isChecked = {}
for (const lng of knownLanguagecodes) {
const lngInfo = all_languages[lng]
if (lngInfo._meta?.countries?.some(l => countries.has(l))) {
probableLanguages.push(lng)
}
isChecked[lng] = false
}
let newlyChecked: UIEventSource<string[]> = new UIEventSource<string[]>([])
function update(isChecked: Record<string, boolean>) {
const currentlyChecked = new Set<string>(selectedLanguages.data)
const languages: string[] = []
for (const lng in isChecked) {
if (isChecked[lng]) {
languages.push(lng)
if (!currentlyChecked.has(lng)) {
newlyChecked.data.push(lng)
newlyChecked.ping()
}
}
}
selectedLanguages.setData(languages)
}
function matchesSearch(lng: string, searchLc: string | undefined): boolean {
if(!searchLc){
return
}
if(lng.indexOf(searchLc) >= 0){
return true
}
const languageInfo = all_languages[lng]
const native : string = languageInfo[lng]?.toLowerCase()
if(native?.indexOf(searchLc) >= 0){
return true
}
const current : string = languageInfo[Locale.language.data]?.toLowerCase()
if(current?.indexOf(searchLc) >= 0){
return true
}
return false
}
function onEnter(){
// we select the first match which is not yet checked
for (const lng of knownLanguagecodes) {
if(lng === searchLC.data){
isChecked[lng] = true
return
}
}
for (const lng of knownLanguagecodes) {
if(matchesSearch(lng, searchLC.data)){
isChecked[lng] = true
return
}
}
}
$: {
update(isChecked)
}
searchValue.addCallback(_ => {
newlyChecked.setData([])
})
</script>
<form on:submit|preventDefault={() => onEnter()}>
{#each probableLanguages as lng}
<label class="no-image-background flex items-center gap-1">
<input bind:checked={isChecked[lng]} type="checkbox" />
<Tr t={new Translation(all_languages[lng])} />
<span class="subtle">({lng})</span>
</label>
{/each}
<label class="block relative neutral-label m-4 mx-16">
<SearchIcon class="w-6 h-6 absolute right-0" />
<input bind:value={$searchValue} type="text" />
<Tr t={Translations.t.general.useSearch} />
</label>
<div class="overflow-auto" style="max-height: 25vh">
{#each knownLanguagecodes as lng}
{#if (isChecked[lng]) && $newlyChecked.indexOf(lng) < 0 && probableLanguages.indexOf(lng) < 0}
<label class="no-image-background flex items-center gap-1">
<input bind:checked={isChecked[lng]} type="checkbox" />
<Tr t={new Translation(all_languages[lng])} />
<span class="subtle">({lng})</span>
</label>
{/if}
{/each}
{#each knownLanguagecodes as lng}
{#if $searchLC.length > 0 && matchesSearch(lng, $searchLC) && (!isChecked[lng] || $newlyChecked.indexOf(lng) >= 0) && probableLanguages.indexOf(lng) < 0}
<label class="no-image-background flex items-center gap-1">
<input bind:checked={isChecked[lng]} type="checkbox" />
<Tr t={new Translation(all_languages[lng])} />
<span class="subtle">({lng})</span>
</label>
{/if}
{/each}
</div>
</form>

View file

@ -0,0 +1,87 @@
<script lang="ts">/**
* The 'languageQuestion' is a special element which asks about the (possible) languages of a feature
* (e.g. which speech output an ATM has, in what language(s) the braille writing is or what languages are spoken at a school)
*
* This is written into a `key`.
*
*/
import { Translation } from "../../i18n/Translation"
import SpecialTranslation from "../TagRendering/SpecialTranslation.svelte"
import type { SpecialVisualizationState } from "../../SpecialVisualization"
import type { Store } from "../../../Logic/UIEventSource"
import { UIEventSource } from "../../../Logic/UIEventSource"
import type { Feature } from "geojson"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import LanguageOptions from "./LanguageOptions.svelte"
import Translations from "../../i18n/Translations"
import Tr from "../../Base/Tr.svelte"
import { createEventDispatcher } from "svelte"
import { Tag } from "../../../Logic/Tags/Tag"
import ChangeTagAction from "../../../Logic/Osm/Actions/ChangeTagAction"
import { And } from "../../../Logic/Tags/And"
export let question: string
export let prefix: string
export let foundLanguages: Store<string[]>
export let state: SpecialVisualizationState
export let tags: UIEventSource<Record<string, string>>
export let feature: Feature
export let layer: LayerConfig | undefined
let dispatch = createEventDispatcher<{ save }>()
let selectedLanguages: UIEventSource<string[]> = new UIEventSource<string[]>([])
let countries: Store<Set<string>> = tags.mapD(tags => new Set<string>(tags["_country"]?.toUpperCase()?.split(";") ?? []))
async function applySelectedLanguages() {
const selectedLngs = selectedLanguages.data
const selection: Tag[] = selectedLanguages.data.map((ln) => new Tag(prefix + ln, "yes"))
if (selection.length === 0) {
return
}
const currentLanguages = foundLanguages.data
for (const currentLanguage of currentLanguages) {
if (selectedLngs.indexOf(currentLanguage) >= 0) {
continue
}
// Erase languages that are not spoken anymore
selection.push(new Tag(prefix + currentLanguage, ""))
}
if (state === undefined || state?.featureSwitchIsTesting?.data) {
for (const tag of selection) {
tags.data[tag.key] = tag.value
}
tags.ping()
} else if (state.changes) {
await state.changes
.applyAction(
new ChangeTagAction(
tags.data.id,
new And(selection),
tags.data,
{
theme: state?.layout?.id ?? "unkown",
changeType: "answer",
},
),
)
}
dispatch("save")
}
</script>
<div class="flex flex-col disable-links interactive border-interactive p-2">
<div class="interactive justify-between pt-1 font-bold">
<SpecialTranslation {feature} {layer} {state} t={new Translation({"*":question})} {tags} />
</div>
<LanguageOptions {selectedLanguages} countries={$countries}/>
<div class="flex justify-end flex-wrap-reverse w-full">
<slot name="cancel-button"></slot>
<button class="primary" class:disabled={$selectedLanguages.length === 0} on:click={_ => applySelectedLanguages()}>
<Tr t={Translations.t.general.save} />
</button>
</div>
</div>

View file

@ -6,7 +6,6 @@ import { MapLibreAdaptor } from "../Map/MapLibreAdaptor"
import SvelteUIElement from "../Base/SvelteUIElement"
import MaplibreMap from "../Map/MaplibreMap.svelte"
import ShowDataLayer from "../Map/ShowDataLayer"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { GeoOperations } from "../../Logic/GeoOperations"
import { BBox } from "../../Logic/BBox"
@ -32,7 +31,7 @@ export class MinimapViz implements SpecialVisualization {
state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>,
args: string[],
feature: Feature,
feature: Feature
) {
if (state === undefined || feature === undefined) {
return undefined

View file

@ -18,6 +18,7 @@
import Geosearch from "../BigComponents/Geosearch.svelte"
import If from "../Base/If.svelte"
import Constants from "../../Models/Constants"
import LoginToggle from "../Base/LoginToggle.svelte"
export let state: SpecialVisualizationState
@ -49,7 +50,7 @@
let notAllowed = moveWizardState.moveDisallowedReason
let currentMapProperties: MapProperties = undefined
</script>
<LoginToggle {state}>
{#if moveWizardState.reasons.length > 0}
{#if $notAllowed}
<div class="m-2 flex rounded-lg bg-gray-200 p-2">
@ -165,3 +166,4 @@
</div>
{/if}
{/if}
</LoginToggle>

View file

@ -10,8 +10,8 @@ import { Tag } from "../../Logic/Tags/Tag"
import { SpecialVisualizationState } from "../SpecialVisualization"
import { Feature, Point } from "geojson"
import SvelteUIElement from "../Base/SvelteUIElement"
import Confirm from "../../assets/svg/Confirm.svelte"
import Relocation from "../../assets/svg/Relocation.svelte"
import Location from "../../assets/svg/Location.svelte"
export interface MoveReason {
text: Translation | string
@ -62,7 +62,7 @@ export class MoveWizardState {
reasons.push({
text: t.reasons.reasonInaccurate,
invitingText: t.inviteToMove.reasonInaccurate,
icon: new SvelteUIElement(Confirm),
icon: new SvelteUIElement(Location),
changesetCommentValue: "improve_accuracy",
lockBounds: true,
includeSearch: false,

View file

@ -18,8 +18,7 @@ export class AddNoteCommentViz implements SpecialVisualization {
public constr(
state: SpecialVisualizationState,
tags: UIEventSource<Record<string, string>>,
args: string[]
tags: UIEventSource<Record<string, string>>
) {
return new SvelteUIElement(AddNoteComment, { state, tags })
}

View file

@ -1,7 +1,6 @@
import BaseUIElement from "../../BaseUIElement"
import Translations from "../../i18n/Translations"
import { Utils } from "../../../Utils"
import Svg from "../../../Svg"
import Img from "../../Base/Img"
import { SubtleButton } from "../../Base/SubtleButton"
import Toggle from "../../Input/Toggle"

View file

@ -1,6 +1,5 @@
import Combine from "../../Base/Combine"
import BaseUIElement from "../../BaseUIElement"
import Svg from "../../../Svg"
import Link from "../../Base/Link"
import { FixedUiElement } from "../../Base/FixedUiElement"
import Translations from "../../i18n/Translations"
@ -11,6 +10,10 @@ import { Stores, UIEventSource } from "../../../Logic/UIEventSource"
import { OsmConnection } from "../../../Logic/Osm/OsmConnection"
import { VariableUiElement } from "../../Base/VariableUIElement"
import { SpecialVisualizationState } from "../../SpecialVisualization"
import SvelteUIElement from "../../Base/SvelteUIElement"
import Note from "../../../assets/svg/Note.svelte"
import Resolved from "../../../assets/svg/Resolved.svelte"
import Speech_bubble from "../../../assets/svg/Speech_bubble.svelte"
export default class NoteCommentElement extends Combine {
constructor(
@ -32,11 +35,11 @@ export default class NoteCommentElement extends Combine {
let actionIcon: BaseUIElement
if (comment.action === "opened" || comment.action === "reopened") {
actionIcon = Svg.note_svg()
actionIcon = new SvelteUIElement(Note)
} else if (comment.action === "closed") {
actionIcon = Svg.resolved_svg()
actionIcon = new SvelteUIElement(Resolved)
} else {
actionIcon = Svg.speech_bubble_svg()
actionIcon = new SvelteUIElement(Speech_bubble)
}
let user: BaseUIElement
@ -72,6 +75,7 @@ export default class NoteCommentElement extends Combine {
const extension = link.substring(lastDotIndex + 1, link.length)
return Utils.imageExtensions.has(extension)
})
.filter(link => !link.startsWith("https://wiki.openstreetmap.org/wiki/File:"))
let imagesEl: BaseUIElement = undefined
if (images.length > 0) {
const imageEls = images.map((i) =>

View file

@ -1,47 +0,0 @@
import { Store } from "../../Logic/UIEventSource"
import Translations from "../i18n/Translations"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import Toggle from "../Input/Toggle"
import BaseUIElement from "../BaseUIElement"
import Combine from "../Base/Combine"
import Svg from "../../Svg"
import { LoginToggle } from "./LoginButton"
export class EditButton extends Toggle {
constructor(osmConnection: OsmConnection, onClick: () => void) {
super(
new Combine([Svg.pencil_svg()])
.SetClass("block relative h-10 w-10 p-2 float-right")
.SetStyle("border: 1px solid black; border-radius: 0.7em")
.onClick(onClick),
undefined,
osmConnection.isLoggedIn
)
}
}
export class SaveButton extends LoginToggle {
constructor(
value: Store<any>,
state: {
readonly osmConnection?: OsmConnection
readonly featureSwitchUserbadge?: Store<boolean>
},
textEnabled?: BaseUIElement,
textDisabled?: BaseUIElement
) {
if (value === undefined) {
throw "No event source for savebutton, something is wrong"
}
const isSaveable = value.map((v) => v !== false && (v ?? "") !== "")
const saveEnabled = (textEnabled ?? Translations.t.general.save.Clone()).SetClass(`btn`)
const saveDisabled = (textDisabled ?? Translations.t.general.save.Clone()).SetClass(
`btn btn-disabled`
)
const save = new Toggle(saveEnabled, saveDisabled, isSaveable)
super(save, Translations.t.general.loginToStart, state)
}
}

View file

@ -4,6 +4,7 @@
import ToSvelte from "../Base/ToSvelte.svelte"
import { Utils } from "../../Utils"
import { Store } from "../../Logic/UIEventSource"
import Envelope from "../../assets/svg/Envelope.svelte"
export let tags: Store<OsmTags>
export let args: string[]
@ -14,6 +15,6 @@
</script>
<a class="button flex w-full items-center" href={url} style="margin-left: 0">
<ToSvelte construct={Svg.envelope_svg().SetClass("w-8 h-8 mr-4 shrink-0")} />
<Envelope class="w-8 h-8 mr-4 shrink-0"/>
{button_text}
</a>

View file

@ -1,5 +1,4 @@
import Toggle from "../Input/Toggle"
import Svg from "../../Svg"
import { UIEventSource } from "../../Logic/UIEventSource"
import { SubtleButton } from "../Base/SubtleButton"
import Combine from "../Base/Combine"
@ -19,6 +18,7 @@ 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>
@ -26,8 +26,8 @@ export default class SplitRoadWizard extends Combine {
/**
* A UI Element used for splitting roads
*
* @param id: The id of the road to remove
* @param state: the state of the application
* @param id The id of the road to remove
* @param state the state of the application
*/
constructor(
id: WayId,
@ -68,7 +68,7 @@ export default class SplitRoadWizard extends Combine {
// Toggle between splitmap
const splitButton = new SubtleButton(
Svg.scissors_svg().SetStyle("height: 1.5rem; width: auto"),
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"),

View file

@ -18,6 +18,9 @@ import { IndexedFeatureSource } from "../../Logic/FeatureSource/FeatureSource"
import { Feature } from "geojson"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import Maproulette from "../../Logic/Maproulette"
import SvelteUIElement from "../Base/SvelteUIElement"
import Icon from "../Map/Icon.svelte"
import { Map } from "maplibre-gl"
export default class TagApplyButton implements AutoAction, SpecialVisualization {
public readonly funcName = "tag_apply"
@ -45,9 +48,9 @@ export default class TagApplyButton implements AutoAction, SpecialVisualization
doc: "If specified, applies the the tags onto _another_ object. The id will be read from properties[id_of_object_to_apply_this_one] of the selected object. The tags are still calculated based on the tags of the _selected_ element",
},
{
name: "maproulette_task_id",
name: "maproulette_id",
defaultValue: undefined,
doc: "If specified, this maproulette-challenge will be closed when the tags are applied",
doc: "If specified, this maproulette-challenge will be closed when the tags are applied. This should be the ID of the task, _not_ the task_id.",
},
]
public readonly example =
@ -81,7 +84,7 @@ export default class TagApplyButton implements AutoAction, SpecialVisualization
for (const [key, value] of tgsSpec) {
if (value.indexOf("$") >= 0) {
let parts = value.split("$")
// THe first of the split won't start with a '$', so no substitution needed
// The first item of the split won't start with a '$', so no substitution needed
let actualValue = parts[0]
parts.shift()
@ -111,7 +114,6 @@ export default class TagApplyButton implements AutoAction, SpecialVisualization
while (spec.length > 0) {
const [part] = spec.match(/((\\;)|[^;])*/)
console.log("Spec is", part, spec)
spec = spec.substring(part.length + 1) // +1 to remove the pending ';' as well
const kv = part.split("=").map((s) => s.trim().replace("\\;", ";"))
if (kv.length == 2) {
@ -133,12 +135,8 @@ export default class TagApplyButton implements AutoAction, SpecialVisualization
}
public async applyActionOn(
feature: Feature,
state: {
layout: LayoutConfig
changes: Changes
indexedFeatures: IndexedFeatureSource
},
_: Feature,
state: SpecialVisualizationState,
tags: UIEventSource<any>,
args: string[]
): Promise<void> {
@ -156,14 +154,22 @@ export default class TagApplyButton implements AutoAction, SpecialVisualization
}
)
await state.changes.applyAction(changeAction)
try {
state.selectedElement.setData(state.indexedFeatures.featuresById.data.get(targetId))
}catch (e) {
console.error(e)
}
const maproulette_id_key = args[4]
if (maproulette_id_key) {
const maproulette_id = Number(tags.data[maproulette_id_key])
await Maproulette.singleton.closeTask(maproulette_id, Maproulette.STATUS_FIXED, {
const maproulette_id = tags.data[ maproulette_id_key]
const maproulette_feature= state.indexedFeatures.featuresById.data.get(
maproulette_id)
const maproulette_task_id = Number(maproulette_feature.properties.mr_taskId)
await Maproulette.singleton.closeTask(maproulette_task_id, Maproulette.STATUS_FIXED, {
comment: "Tags are copied onto " + targetId + " with MapComplete",
})
tags.data["mr_taskStatus"] = "Fixed"
tags.ping()
maproulette_feature.properties["mr_taskStatus"] = "Fixed"
state.featureProperties.getStore(maproulette_id).ping()
}
}
@ -180,6 +186,7 @@ export default class TagApplyButton implements AutoAction, SpecialVisualization
if (image === "" || image === "undefined") {
image = undefined
}
const targetIdKey = args[3]
const t = Translations.t.general.apply_button
@ -195,9 +202,9 @@ export default class TagApplyButton implements AutoAction, SpecialVisualization
})
).SetClass("subtle")
const self = this
const applied = new UIEventSource(tags?.data?.["mr_taskStatus"] !== "Created") // This will default to 'false' for non-maproulette challenges
const applied = new UIEventSource(tags?.data?.["mr_taskStatus"] !== undefined && tags?.data?.["mr_taskStatus"] !== "Created") // This will default to 'false' for non-maproulette challenges
const applyButton = new SubtleButton(
image,
new SvelteUIElement(Icon, {icon: image}),
new Combine([msg, tagsExplanation]).SetClass("flex flex-col")
).onClick(async () => {
applied.setData(true)

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { PencilAltIcon } from "@rgossiaux/svelte-heroicons/solid";
import { ariaLabel } from "../../../Utils/ariaLabel.js";
import { Translation } from "../../i18n/Translation"
/**
* A small, round button with an edit-icon (and aria-labels etc)
*/
/**
* What arialabel to apply onto this button?
*/
export let arialabel : Translation = undefined;
export let ariaLabelledBy: string = undefined
</script>
<button
on:click
class="secondary h-8 w-8 shrink-0 self-start rounded-full p-1"
aria-labelledby={arialabel === undefined ? ariaLabelledBy : undefined}
use:ariaLabel={arialabel}
>
<PencilAltIcon />
</button>

View file

@ -12,6 +12,7 @@
import type { SpecialVisualizationState } from "../../SpecialVisualization"
export let value: UIEventSource<string>
export let unvalidatedText: UIEventSource<string> = new UIEventSource<string>(value.data)
export let config: TagRenderingConfig
export let tags: UIEventSource<Record<string, string>>
@ -64,6 +65,7 @@
type={config.freeform.type}
{placeholder}
{value}
{unvalidatedText}
/>
{/if}
@ -74,5 +76,6 @@
{value}
{state}
on:submit
{unvalidatedText}
/>
</div>

View file

@ -6,7 +6,6 @@
import type { SpecialVisualizationState } from "../../SpecialVisualization"
import type { Feature } from "geojson"
import { Store, UIEventSource } from "../../../Logic/UIEventSource"
import { onDestroy } from "svelte"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import { twMerge } from "tailwind-merge"
@ -24,7 +23,7 @@
throw "Config is undefined in tagRenderingAnswer"
}
let trs: Store<{ then: Translation; icon?: string; iconClass?: string }[]> = tags.mapD((tags) =>
Utils.NoNull(config?.GetRenderValues(tags))
Utils.NoNull(config?.GetRenderValues(tags)),
)
</script>

View file

@ -13,6 +13,8 @@
import { Utils } from "../../../Utils"
import { twMerge } from "tailwind-merge"
import { ariaLabel } from "../../../Utils/ariaLabel"
import EditButton from "./EditButton.svelte"
import EditItemButton from "../../Studio/EditItemButton.svelte"
export let config: TagRenderingConfig
export let tags: UIEventSource<Record<string, string>>
@ -63,7 +65,6 @@
if (config.id === highlighted) {
htmlElem.classList.add("glowing-shadow")
htmlElem.tabIndex = -1
console.log("Scrolling to", htmlElem)
htmlElem.scrollIntoView({ behavior: "smooth" })
Utils.focusOnFocusableChild(htmlElem)
} else {
@ -88,6 +89,7 @@
{state}
{layer}
on:saved={() => (editMode = false)}
allowDeleteOfFreeform={true}
>
<button
slot="cancel"
@ -102,16 +104,12 @@
{:else}
<div class="low-interaction flex items-center justify-between overflow-hidden rounded px-2">
<TagRenderingAnswer id={answerId} {config} {tags} {selectedElement} {state} {layer} />
<button
<EditButton
arialabel={config.editButtonAriaLabel}
ariaLabelledBy={answerId}
on:click={() => {
editMode = true
}}
class="secondary h-8 w-8 shrink-0 self-start rounded-full p-1"
aria-labelledby={config.editButtonAriaLabel === undefined ? answerId : undefined}
use:ariaLabel={config.editButtonAriaLabel}
>
<PencilAltIcon />
</button>
editMode = true
}}/>
</div>
{/if}
{:else}

View file

@ -29,7 +29,7 @@
{#if mapping.icon !== undefined}
<div class="inline-flex items-center">
<Icon icon={mapping.icon} clss={twJoin(`mapping-icon-${mapping.iconClass}`, "mr-2")} />
<Icon icon={mapping.icon} clss={twJoin(`mapping-icon-${mapping.iconClass ?? "small"}`, "mr-2")} />
<SpecialTranslation t={mapping.then} {tags} {state} {layer} feature={selectedElement} />
</div>
{:else if mapping.then !== undefined}

View file

@ -22,17 +22,23 @@
import { Unit } from "../../../Models/Unit"
import UserRelatedState from "../../../Logic/State/UserRelatedState"
import { twJoin } from "tailwind-merge"
import type { UploadableTag } from "../../../Logic/Tags/TagUtils"
import { TagUtils } from "../../../Logic/Tags/TagUtils"
import Search from "../../../assets/svg/Search.svelte"
import Login from "../../../assets/svg/Login.svelte"
import { placeholder } from "../../../Utils/placeholder"
import { TrashIcon } from "@rgossiaux/svelte-heroicons/solid"
import { Tag } from "../../../Logic/Tags/Tag"
export let config: TagRenderingConfig
export let tags: UIEventSource<Record<string, string>>
export let selectedElement: Feature
export let state: SpecialVisualizationState
export let layer: LayerConfig | undefined
export let selectedTags: TagsFilter = undefined
export let selectedTags: UploadableTag = undefined
export let allowDeleteOfFreeform: boolean = false
let feedback: UIEventSource<Translation> = new UIEventSource<Translation>(undefined)
@ -40,13 +46,15 @@
// 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 selectedMapping: number = undefined
/**
* A list of booleans, used if multiAnswer is set
*/
let checkedMappings: boolean[]
let mappings: Mapping[] = config?.mappings
let mappings: Mapping[] = config?.mappings ?? []
let searchTerm: UIEventSource<string> = new UIEventSource("")
let dispatch = createEventDispatcher<{
@ -128,7 +136,6 @@
}
freeformInput.addCallbackAndRun((freeformValue) => {
console.log("FreeformValue:", freeformValue)
if (!mappings || mappings?.length == 0 || config.freeform?.key === undefined) {
return
}
@ -146,20 +153,25 @@
}
})
$: {
try {
selectedTags = config?.constructChangeSpecification(
$freeformInput,
selectedMapping,
checkedMappings,
tags.data,
)
} catch (e) {
console.error("Could not calculate changeSpecification:", e)
selectedTags = undefined
if (allowDeleteOfFreeform && $freeformInput === undefined && $freeformInputUnvalidated === "" && (mappings?.length ?? 0) === 0) {
selectedTags = new Tag(config.freeform.key, "")
} else {
try {
selectedTags = config?.constructChangeSpecification(
$freeformInput,
selectedMapping,
checkedMappings,
tags.data,
)
} catch (e) {
console.error("Could not calculate changeSpecification:", e)
selectedTags = undefined
}
}
}
function onSave(e) {
function onSave(_ = undefined) {
if (selectedTags === undefined) {
return
}
@ -198,9 +210,9 @@
function onInputKeypress(e: KeyboardEvent) {
if (e.key === "Enter") {
e.preventDefault()
e.stopPropagation()
onSave(e)
e.preventDefault()
e.stopPropagation()
onSave()
}
}
@ -231,136 +243,139 @@
<fieldset>
<legend>
<div class="interactive sticky top-0 justify-between pt-1 font-bold" style="z-index: 11">
<SpecialTranslation t={question} {tags} {state} {layer} feature={selectedElement} />
</div>
<div class="interactive sticky top-0 justify-between pt-1 font-bold" style="z-index: 11">
<SpecialTranslation t={question} {tags} {state} {layer} feature={selectedElement} />
</div>
{#if config.questionhint}
<div class="max-h-60 overflow-y-auto">
<SpecialTranslation
t={config.questionhint}
{tags}
{state}
{layer}
feature={selectedElement}
/>
</div>
{/if}
{#if config.questionhint}
<div class="max-h-60 overflow-y-auto">
<SpecialTranslation
t={config.questionhint}
{tags}
{state}
{layer}
feature={selectedElement}
/>
</div>
{/if}
</legend>
{#if config.mappings?.length >= 8}
<div class="sticky flex w-full" aria-hidden="true">
<Search class="h-6 w-6" />
<input
type="text"
bind:value={$searchTerm}
class="w-full"
use:placeholder={Translations.t.general.searchAnswer}
/>
</div>
{/if}
{#if config.mappings?.length >= 8}
<div class="sticky flex w-full" aria-hidden="true">
<Search class="h-6 w-6" />
<input
type="text"
bind:value={$searchTerm}
class="w-full"
use:placeholder={Translations.t.general.searchAnswer}
/>
</div>
{/if}
{#if config.freeform?.key && !(mappings?.length > 0)}
<!-- There are no options to choose from, simply show the input element: fill out the text field -->
<FreeformInput
{config}
{#if config.freeform?.key && !(mappings?.length > 0)}
<!-- There are no options to choose from, simply show the input element: fill out the text field -->
<FreeformInput
{config}
{tags}
{feedback}
{unit}
{state}
feature={selectedElement}
value={freeformInput}
unvalidatedText={freeformInputUnvalidated}
on:submit={onSave}
/>
{:else if mappings !== undefined && !config.multiAnswer}
<!-- Simple radiobuttons as mapping -->
<div class="flex flex-col">
{#each config.mappings as mapping, i (mapping.then)}
<!-- Even though we have a list of 'mappings' already, we still iterate over the list as to keep the original indices-->
<TagRenderingMappingInput
{mapping}
{tags}
{feedback}
{unit}
{state}
feature={selectedElement}
value={freeformInput}
on:submit={onSave}
/>
{:else if mappings !== undefined && !config.multiAnswer}
<!-- Simple radiobuttons as mapping -->
<div class="flex flex-col">
{#each config.mappings as mapping, i (mapping.then)}
<!-- Even though we have a list of 'mappings' already, we still iterate over the list as to keep the original indices-->
<TagRenderingMappingInput
{mapping}
{tags}
{state}
{selectedElement}
{layer}
{searchTerm}
mappingIsSelected={selectedMapping === i}
>
<input
type="radio"
bind:group={selectedMapping}
name={"mappings-radio-" + config.id}
value={i}
on:keypress={(e) => onInputKeypress(e)}
/>
</TagRenderingMappingInput>
{/each}
{#if config.freeform?.key}
<label class="flex gap-x-1">
<input
type="radio"
bind:group={selectedMapping}
name={"mappings-radio-" + config.id}
value={config.mappings?.length}
on:keypress={(e) => onInputKeypress(e)}
/>
<FreeformInput
{config}
{tags}
{feedback}
{unit}
{state}
feature={selectedElement}
value={freeformInput}
on:selected={() => (selectedMapping = config.mappings?.length)}
on:submit={onSave}
/>
</label>
{/if}
</div>
{:else if mappings !== undefined && config.multiAnswer}
<!-- Multiple answers can be chosen: checkboxes -->
<div class="flex flex-col">
{#each config.mappings as mapping, i (mapping.then)}
<TagRenderingMappingInput
{mapping}
{tags}
{state}
{selectedElement}
{layer}
{searchTerm}
mappingIsSelected={checkedMappings[i]}
>
<input
type="checkbox"
name={"mappings-checkbox-" + config.id + "-" + i}
bind:checked={checkedMappings[i]}
on:keypress={(e) => onInputKeypress(e)}
/>
</TagRenderingMappingInput>
{/each}
{#if config.freeform?.key}
<label class="flex gap-x-1">
<input
type="checkbox"
name={"mappings-checkbox-" + config.id + "-" + config.mappings?.length}
bind:checked={checkedMappings[config.mappings.length]}
on:keypress={(e) => onInputKeypress(e)}
/>
<FreeformInput
{config}
{tags}
{feedback}
{unit}
{state}
feature={selectedElement}
value={freeformInput}
on:submit={onSave}
/>
</label>
{/if}
</div>
{selectedElement}
{layer}
{searchTerm}
mappingIsSelected={selectedMapping === i}
>
<input
type="radio"
bind:group={selectedMapping}
name={"mappings-radio-" + config.id}
value={i}
on:keypress={(e) => onInputKeypress(e)}
/>
</TagRenderingMappingInput>
{/each}
{#if config.freeform?.key}
<label class="flex gap-x-1">
<input
type="radio"
bind:group={selectedMapping}
name={"mappings-radio-" + config.id}
value={config.mappings?.length}
on:keypress={(e) => onInputKeypress(e)}
/>
<FreeformInput
{config}
{tags}
{feedback}
{unit}
{state}
feature={selectedElement}
value={freeformInput}
unvalidatedText={freeformInputUnvalidated}
on:selected={() => (selectedMapping = config.mappings?.length)}
on:submit={onSave}
/>
</label>
{/if}
</div>
{:else if mappings !== undefined && config.multiAnswer}
<!-- Multiple answers can be chosen: checkboxes -->
<div class="flex flex-col">
{#each config.mappings as mapping, i (mapping.then)}
<TagRenderingMappingInput
{mapping}
{tags}
{state}
{selectedElement}
{layer}
{searchTerm}
mappingIsSelected={checkedMappings[i]}
>
<input
type="checkbox"
name={"mappings-checkbox-" + config.id + "-" + i}
bind:checked={checkedMappings[i]}
on:keypress={(e) => onInputKeypress(e)}
/>
</TagRenderingMappingInput>
{/each}
{#if config.freeform?.key}
<label class="flex gap-x-1">
<input
type="checkbox"
name={"mappings-checkbox-" + config.id + "-" + config.mappings?.length}
bind:checked={checkedMappings[config.mappings.length]}
on:keypress={(e) => onInputKeypress(e)}
/>
<FreeformInput
{config}
{tags}
{feedback}
{unit}
{state}
feature={selectedElement}
value={freeformInput}
unvalidatedText={freeformInputUnvalidated}
on:submit={onSave}
/>
</label>
{/if}
</div>
{/if}
<LoginToggle {state}>
<Loading slot="loading" />
<SubtleButton slot="not-logged-in" on:click={() => state?.osmConnection?.AttemptLogin()}>
@ -379,12 +394,19 @@
<!-- TagRenderingQuestion-buttons -->
<slot name="cancel" />
<slot name="save-button" {selectedTags}>
<button
on:click={onSave}
class={twJoin(selectedTags === undefined ? "disabled" : "button-shadow", "primary")}
>
<Tr t={Translations.t.general.save} />
</button>
{#if allowDeleteOfFreeform && (mappings?.length ?? 0) === 0 && $freeformInput === undefined && $freeformInputUnvalidated === ""}
<button class="primary flex" on:click|stopPropagation|preventDefault={onSave}>
<TrashIcon class="w-6 h-6 text-red-500" />
<Tr t={Translations.t.general.eraseValue}/>
</button>
{:else}
<button
on:click={onSave}
class={twJoin(selectedTags === undefined ? "disabled" : "button-shadow", "primary")}
>
<Tr t={Translations.t.general.save} />
</button>
{/if}
</slot>
</div>
{#if UserRelatedState.SHOW_TAGS_VALUES.indexOf($showTags) >= 0 || ($showTags === "" && numberOfCs >= Constants.userJourney.tagsVisibleAt) || $featureSwitchIsTesting || $featureSwitchIsDebugging}

View file

@ -5,13 +5,18 @@
import Translations from "./i18n/Translations"
import { Utils } from "../Utils"
import Add from "../assets/svg/Add.svelte"
import LanguagePicker from "./InputElement/LanguagePicker.svelte"
</script>
<div class="flex h-screen flex-col overflow-hidden p-4">
<div class="flex h-screen flex-col overflow-hidden px-4">
<div class="flex justify-between">
<h2 class="flex items-center">
<EyeIcon class="w-6 pr-2" />
<Tr t={Translations.t.privacy.title} />
</h2>
<LanguagePicker availableLanguages={Translations.t.privacy.intro.SupportedLanguages()}/>
</div>
<div class="h-full overflow-auto border border-gray-500 p-4">
<PrivacyPolicy />
</div>

View file

@ -3,15 +3,12 @@
import SingleReview from "./SingleReview.svelte"
import { Utils } from "../../Utils"
import StarsBar from "./StarsBar.svelte"
import ReviewForm from "./ReviewForm.svelte"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import { UIEventSource } from "../../Logic/UIEventSource"
import type { Feature } from "geojson"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import ToSvelte from "../Base/ToSvelte.svelte"
import Svg from "../../Svg"
import Mangrove_logo from "../../assets/svg/Mangrove_logo.svelte"
/**

View file

@ -1,63 +1,88 @@
<script lang="ts">
import FeatureReviews from "../../Logic/Web/MangroveReviews"
import StarsBar from "./StarsBar.svelte"
import SpecialTranslation from "../Popup/TagRendering/SpecialTranslation.svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import { UIEventSource } from "../../Logic/UIEventSource"
import type { Feature } from "geojson"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import Translations from "../i18n/Translations"
import Checkbox from "../Base/Checkbox.svelte"
import Tr from "../Base/Tr.svelte"
import If from "../Base/If.svelte"
import Loading from "../Base/Loading.svelte"
import { Review } from "mangrove-reviews-typescript"
import { Utils } from "../../Utils"
import { placeholder } from "../../Utils/placeholder"
import FeatureReviews from "../../Logic/Web/MangroveReviews"
import StarsBar from "./StarsBar.svelte"
import SpecialTranslation from "../Popup/TagRendering/SpecialTranslation.svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import type { Feature } from "geojson"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import Translations from "../i18n/Translations"
import Checkbox from "../Base/Checkbox.svelte"
import Tr from "../Base/Tr.svelte"
import If from "../Base/If.svelte"
import Loading from "../Base/Loading.svelte"
import { Review } from "mangrove-reviews-typescript"
import { Utils } from "../../Utils"
import { placeholder } from "../../Utils/placeholder"
import { ExclamationTriangle } from "@babeard/svelte-heroicons/solid/ExclamationTriangle"
export let state: SpecialVisualizationState
export let tags: UIEventSource<Record<string, string>>
export let feature: Feature
export let layer: LayerConfig
/**
* The form to create a new review.
* This is multi-stepped.
*/
export let reviews: FeatureReviews
export let state: SpecialVisualizationState
export let tags: UIEventSource<Record<string, string>>
export let feature: Feature
export let layer: LayerConfig
/**
* The form to create a new review.
* This is multi-stepped.
*/
export let reviews: FeatureReviews
let score = 0
let confirmedScore = undefined
let isAffiliated = new UIEventSource(false)
let opinion = new UIEventSource<string>(undefined)
let score = 0
let confirmedScore = undefined
let isAffiliated = new UIEventSource(false)
let opinion = new UIEventSource<string>(undefined)
const t = Translations.t.reviews
const t = Translations.t.reviews
let _state: "ask" | "saving" | "done" = "ask"
let _state: "ask" | "saving" | "done" = "ask"
const connection = state.osmConnection
let connection = state.osmConnection
async function save() {
_state = "saving"
let nickname = undefined
if (connection.isLoggedIn.data) {
nickname = connection.userDetails.data.name
let hasError: Store<undefined | "too_long"> = opinion.mapD(op => {
const tooLong = op.length > FeatureReviews.REVIEW_OPINION_MAX_LENGTH
if (tooLong) {
return "too_long"
}
return undefined
})
let uploadFailed: string = undefined
async function save() {
if (hasError.data) {
return
}
_state = "saving"
let nickname = undefined
if (connection.isLoggedIn.data) {
nickname = connection.userDetails.data.name
}
const review: Omit<Review, "sub"> = {
rating: confirmedScore,
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"
}
const review: Omit<Review, "sub"> = {
rating: confirmedScore,
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 {
await reviews.createReview(review)
}
_state = "done"
}
</script>
{#if _state === "done"}
{#if uploadFailed}
<div class="alert flex">
<ExclamationTriangle class="w-6 h-6" />
<Tr t={Translations.t.general.error}/>
{uploadFailed}
</div>
{:else if _state === "done"}
<Tr cls="thanks w-full" t={t.saved} />
{:else if _state === "saving"}
<Loading>
@ -77,7 +102,7 @@
on:hover={(e) => {
score = e.detail.score
}}
on:mouseout={(e) => {
on:mouseout={() => {
score = null
}}
score={score ?? confirmedScore ?? 0}
@ -95,6 +120,13 @@
class="mb-1 w-full"
use:placeholder={t.reviewPlaceholder}
/>
{#if $hasError === "too_long"}
<div class="alert flex items-center px-2">
<ExclamationTriangle class="w-12 h-12" />
<Tr
t={t.too_long.Subs({max: FeatureReviews.REVIEW_OPINION_MAX_LENGTH, amount: $opinion?.length ?? 0})}></Tr>
</div>
{/if}
</label>
<Checkbox selected={isAffiliated}>
@ -108,7 +140,8 @@
<Tr t={t.reviewing_as.Subs({ nickname: state.osmConnection.userDetails.data.name })} />
<Tr slot="else" t={t.reviewing_as_anonymous} />
</If>
<button class="primary" on:click={save}>
<button class="primary" class:disabled={$hasError !== undefined}
on:click={save}>
<Tr t={t.save} />
</button>
</div>

View file

@ -0,0 +1,40 @@
<script lang="ts">
import type { SpecialVisualizationState } from "../SpecialVisualization"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
import LoginToggle from "../Base/LoginToggle.svelte"
import LoginButton from "../Base/LoginButton.svelte"
import SingleReview from "./SingleReview.svelte"
import Mangrove_logo from "../../assets/svg/Mangrove_logo.svelte"
/**
* A panel showing all the reviews by the logged-in user
*/
export let state: SpecialVisualizationState
let reviews = state.userRelatedState.mangroveIdentity.getAllReviews()
const t = Translations.t.reviews
</script>
<LoginToggle {state}>
<div slot="not-logged-in">
<LoginButton osmConnection={state.osmConnection}>
<Tr t={Translations.t.favouritePoi.loginToSeeList} />
</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>
{:else}
<Tr t={t.your_reviews_empty} />
{/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} />
</div>
</LoginToggle>

View file

@ -1,22 +1,43 @@
<script lang="ts">
import { Review } from "mangrove-reviews-typescript"
import { Store } from "../../Logic/UIEventSource"
import { ImmutableStore, Store } from "../../Logic/UIEventSource"
import StarsBar from "./StarsBar.svelte"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
import { ariaLabel } from "../../Utils/ariaLabel"
import type { SpecialVisualizationState } from "../SpecialVisualization"
export let review: Review & { kid: string; signature: string; madeByLoggedInUser: Store<boolean> }
export let state: SpecialVisualizationState = undefined
export let review: Review & { kid: string; signature: string; madeByLoggedInUser?: Store<boolean> }
let name = review.metadata.nickname
name ??= ((review.metadata.given_name ?? "") + " " + (review.metadata.family_name ?? "")).trim()
let d = new Date()
d.setTime(review.iat * 1000)
let date = d.toDateString()
let byLoggedInUser = review.madeByLoggedInUser
let byLoggedInUser = review.madeByLoggedInUser ?? ImmutableStore.FALSE
export let showSub = false
let subUrl = new URL(review.sub)
let [lat, lon] = subUrl.pathname.split(",").map(l => Number(l))
let sub = subUrl.searchParams.get("q")
function selectFeature(){
console.log("Selecting and zooming to", {lon, lat})
state?.mapProperties?.location?.setData({lon, lat})
state?.mapProperties?.zoom?.setData(Math.max(16, state?.mapProperties?.zoom?.data))
state?.guistate?.closeAll()
}
</script>
<div class={"low-interaction rounded-lg p-1 px-2 " + ($byLoggedInUser ? "border-interactive" : "")}>
<div class="flex items-center justify-between">
<div class={"low-interaction rounded-lg p-1 px-2 flex flex-col" + ($byLoggedInUser ? "border-interactive" : "")}>
{#if showSub}
<button class="link" on:click={() => selectFeature()}>
<h3>{sub}</h3>
</button>
{/if}
<div class="flex items-center justify-between w-full">
<div
tabindex="0"
use:ariaLabel={Translations.t.reviews.rated.Subs({

View file

@ -3,7 +3,7 @@
import Star from "../../assets/svg/Star.svelte"
import Star_half from "../../assets/svg/Star_half.svelte"
import Star_outline from "../../assets/svg/Star_outline.svelte"
import { ariaLabel, ariaLabelStore } from "../../Utils/ariaLabel"
import { ariaLabel } from "../../Utils/ariaLabel"
import Translations from "../i18n/Translations"
export let score: number

View file

@ -0,0 +1,106 @@
import { RenderingSpecification, SpecialVisualization } from "./SpecialVisualization"
export default class SpecialVisualisationUtils {
/**
* Seeded by 'SpecialVisualisations' when that static class is initialized
* This is to avoid some pesky circular imports
*/
public static specialVisualizations: SpecialVisualization[]
/**
*
* For a given string, returns a specification what parts are fixed and what parts are special renderings.
* Note that _normal_ substitutions are ignored.
*
* import SpecialVisualisations from "./SpecialVisualizations"
*
* // Return empty list on empty input
* SpecialVisualisationUtils.specialVisualizations = SpecialVisualisations.specialVisualizations
* SpecialVisualisationUtils.constructSpecification("") // => []
*
* // Simple case
* SpecialVisualisationUtils.specialVisualizations = SpecialVisualisations.specialVisualizations
* const oh = SpecialVisualisationUtils.constructSpecification("The opening hours with value {opening_hours} can be seen in the following table: <br/> {opening_hours_table()}")
* oh[0] // => "The opening hours with value {opening_hours} can be seen in the following table: <br/> "
* oh[1].func.funcName // => "opening_hours_table"
*
* // Advanced cases with commas, braces and newlines should be handled without problem
* SpecialVisualisationUtils.specialVisualizations = SpecialVisualisations.specialVisualizations
* const templates = SpecialVisualisationUtils.constructSpecification("{send_email(&LBRACEemail&RBRACE,Broken bicycle pump,Hello&COMMA\n\nWith this email&COMMA I'd like to inform you that the bicycle pump located at https://mapcomplete.org/cyclofix?lat=&LBRACE_lat&RBRACE&lon=&LBRACE_lon&RBRACE&z=18#&LBRACEid&RBRACE is broken.\n\n Kind regards,Report this bicycle pump as broken)}")
* const templ = <Exclude<RenderingSpecification, string>> templates[0]
* templ.func.funcName // => "send_email"
* templ.args[0] = "{email}"
*/
public static constructSpecification(
template: string,
extraMappings: SpecialVisualization[] = []
): RenderingSpecification[] {
if (template === "") {
return []
}
if (template["type"] !== undefined) {
console.trace(
"Got a non-expanded template while constructing the specification, it still has a 'special-key':",
template
)
throw "Got a non-expanded template while constructing the specification"
}
const allKnownSpecials = extraMappings.concat(
SpecialVisualisationUtils.specialVisualizations
)
for (const knownSpecial of allKnownSpecials) {
// Note: the '.*?' in the regex reads as 'any character, but in a non-greedy way'
const matched = template.match(
new RegExp(`(.*){${knownSpecial.funcName}\\((.*?)\\)(:.*)?}(.*)`, "s")
)
if (matched != null) {
// We found a special component that should be brought to live
const partBefore = SpecialVisualisationUtils.constructSpecification(
matched[1],
extraMappings
)
const argument =
matched[2] /* .trim() // We don't trim, as spaces might be relevant, e.g. "what is ... of {title()}"*/
const style = matched[3]?.substring(1) ?? ""
const partAfter = SpecialVisualisationUtils.constructSpecification(
matched[4],
extraMappings
)
const args = knownSpecial.args.map((arg) => arg.defaultValue ?? "")
if (argument.length > 0) {
const realArgs = argument
.split(",")
.map((str) => SpecialVisualisationUtils.undoEncoding(str))
for (let i = 0; i < realArgs.length; i++) {
if (args.length <= i) {
args.push(realArgs[i])
} else {
args[i] = realArgs[i]
}
}
}
const element: RenderingSpecification = {
args: args,
style: style,
func: knownSpecial,
}
return [...partBefore, element, ...partAfter]
}
}
// IF we end up here, no changes have to be made - except to remove any resting {}
return [template]
}
private static undoEncoding(str: string) {
return str
.trim()
.replace(/&LPARENS/g, "(")
.replace(/&RPARENS/g, ")")
.replace(/&LBRACE/g, "{")
.replace(/&RBRACE/g, "}")
.replace(/&COMMA/g, ",")
}
}

View file

@ -97,7 +97,7 @@ export interface SpecialVisualization {
readonly funcName: string
readonly docs: string | BaseUIElement
readonly example?: string
readonly needsUrls?: string[] | ((args: string[]) => string)
readonly needsUrls?: string[] | ((args: string[]) => string | string[])
/**
* Indicates that this special visualisation will make requests to the 'alLNodesDatabase' and that it thus should be included

View file

@ -3,11 +3,7 @@ import { FixedUiElement } from "./Base/FixedUiElement"
import BaseUIElement from "./BaseUIElement"
import Title from "./Base/Title"
import Table from "./Base/Table"
import {
RenderingSpecification,
SpecialVisualization,
SpecialVisualizationState,
} from "./SpecialVisualization"
import { RenderingSpecification, SpecialVisualization, SpecialVisualizationState } from "./SpecialVisualization"
import { HistogramViz } from "./Popup/HistogramViz"
import { MinimapViz } from "./Popup/MinimapViz"
import { ShareLinkViz } from "./Popup/ShareLinkViz"
@ -18,7 +14,7 @@ import { PlantNetDetectionViz } from "./Popup/PlantNetDetectionViz"
import TagApplyButton from "./Popup/TagApplyButton"
import { CloseNoteButton } from "./Popup/Notes/CloseNoteButton"
import { MapillaryLinkVis } from "./Popup/MapillaryLinkVis"
import { Store, Stores, UIEventSource } from "../Logic/UIEventSource"
import { ImmutableStore, Store, Stores, UIEventSource } from "../Logic/UIEventSource"
import AllTagsPanel from "./Popup/AllTagsPanel.svelte"
import AllImageProviders from "../Logic/ImageProviders/AllImageProviders"
import { ImageCarousel } from "./Image/ImageCarousel"
@ -31,11 +27,10 @@ import OpeningHoursVisualization from "./OpeningHours/OpeningHoursVisualization"
import { SubtleButton } from "./Base/SubtleButton"
import Svg from "../Svg"
import NoteCommentElement from "./Popup/Notes/NoteCommentElement"
import { SubstitutedTranslation } from "./SubstitutedTranslation"
import List from "./Base/List"
import StatisticsPanel from "./BigComponents/StatisticsPanel"
import AutoApplyButton from "./Popup/AutoApplyButton"
import { LanguageElement } from "./Popup/LanguageElement"
import { LanguageElement } from "./Popup/LanguageElement/LanguageElement"
import FeatureReviews from "../Logic/Web/MangroveReviews"
import Maproulette from "../Logic/Maproulette"
import SvelteUIElement from "./Base/SvelteUIElement"
@ -88,6 +83,11 @@ import MaprouletteSetStatus from "./MapRoulette/MaprouletteSetStatus.svelte"
import DirectionIndicator from "./Base/DirectionIndicator.svelte"
import Img from "./Base/Img"
import Qr from "../Utils/Qr"
import ComparisonTool from "./Comparison/ComparisonTool.svelte"
import SpecialTranslation from "./Popup/TagRendering/SpecialTranslation.svelte"
import SpecialVisualisationUtils from "./SpecialVisualisationUtils"
import LoginButton from "./Base/LoginButton.svelte"
import Toggle from "./Input/Toggle"
class NearbyImageVis implements SpecialVisualization {
// Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests
@ -97,6 +97,11 @@ class NearbyImageVis implements SpecialVisualization {
defaultValue: "closed",
doc: "Either `open` or `closed`. If `open`, then the image carousel will always be shown",
},
{
name: "readonly",
required: false,
doc: "If 'readonly', will not show the 'link'-button",
},
]
docs =
"A component showing nearby images loaded from various online services such as Mapillary. In edit mode and when used on a feature, the user can select an image to add to the feature"
@ -109,9 +114,10 @@ class NearbyImageVis implements SpecialVisualization {
tags: UIEventSource<Record<string, string>>,
args: string[],
feature: Feature,
layer: LayerConfig
layer: LayerConfig,
): BaseUIElement {
const isOpen = args[0] === "open"
const readonly = args[1] === "readonly"
const [lon, lat] = GeoOperations.centerpointCoordinates(feature)
return new SvelteUIElement(isOpen ? NearbyImages : NearbyImagesCollapsed, {
tags,
@ -120,6 +126,7 @@ class NearbyImageVis implements SpecialVisualization {
lat,
feature,
layer,
linkable: !readonly,
})
}
}
@ -174,7 +181,7 @@ class StealViz implements SpecialVisualization {
selectedElement: otherFeature,
state,
layer,
})
}),
)
}
if (elements.length === 1) {
@ -182,8 +189,8 @@ class StealViz implements SpecialVisualization {
}
return new Combine(elements).SetClass("flex flex-col")
},
[state.indexedFeatures.featuresById]
)
[state.indexedFeatures.featuresById],
),
)
}
@ -222,7 +229,7 @@ export class QuestionViz implements SpecialVisualization {
tags: UIEventSource<Record<string, string>>,
args: string[],
feature: Feature,
layer: LayerConfig
layer: LayerConfig,
): BaseUIElement {
const labels = args[0]
?.split(";")
@ -246,94 +253,6 @@ export class QuestionViz implements SpecialVisualization {
export default class SpecialVisualizations {
public static specialVisualizations: SpecialVisualization[] = SpecialVisualizations.initList()
static undoEncoding(str: string) {
return str
.trim()
.replace(/&LPARENS/g, "(")
.replace(/&RPARENS/g, ")")
.replace(/&LBRACE/g, "{")
.replace(/&RBRACE/g, "}")
.replace(/&COMMA/g, ",")
}
/**
*
* For a given string, returns a specification what parts are fixed and what parts are special renderings.
* Note that _normal_ substitutions are ignored.
*
* // Return empty list on empty input
* SpecialVisualizations.constructSpecification("") // => []
*
* // Simple case
* const oh = SpecialVisualizations.constructSpecification("The opening hours with value {opening_hours} can be seen in the following table: <br/> {opening_hours_table()}")
* oh[0] // => "The opening hours with value {opening_hours} can be seen in the following table: <br/> "
* oh[1].func.funcName // => "opening_hours_table"
*
* // Advanced cases with commas, braces and newlines should be handled without problem
* const templates = SpecialVisualizations.constructSpecification("{send_email(&LBRACEemail&RBRACE,Broken bicycle pump,Hello&COMMA\n\nWith this email&COMMA I'd like to inform you that the bicycle pump located at https://mapcomplete.org/cyclofix?lat=&LBRACE_lat&RBRACE&lon=&LBRACE_lon&RBRACE&z=18#&LBRACEid&RBRACE is broken.\n\n Kind regards,Report this bicycle pump as broken)}")
* const templ = <Exclude<RenderingSpecification, string>> templates[0]
* templ.func.funcName // => "send_email"
* templ.args[0] = "{email}"
*/
public static constructSpecification(
template: string,
extraMappings: SpecialVisualization[] = []
): RenderingSpecification[] {
if (template === "") {
return []
}
if (template["type"] !== undefined) {
console.trace(
"Got a non-expanded template while constructing the specification, it still has a 'special-key':",
template
)
throw "Got a non-expanded template while constructing the specification"
}
const allKnownSpecials = extraMappings.concat(SpecialVisualizations.specialVisualizations)
for (const knownSpecial of allKnownSpecials) {
// Note: the '.*?' in the regex reads as 'any character, but in a non-greedy way'
const matched = template.match(
new RegExp(`(.*){${knownSpecial.funcName}\\((.*?)\\)(:.*)?}(.*)`, "s")
)
if (matched != null) {
// We found a special component that should be brought to live
const partBefore = SpecialVisualizations.constructSpecification(
matched[1],
extraMappings
)
const argument =
matched[2] /* .trim() // We don't trim, as spaces might be relevant, e.g. "what is ... of {title()}"*/
const style = matched[3]?.substring(1) ?? ""
const partAfter = SpecialVisualizations.constructSpecification(
matched[4],
extraMappings
)
const args = knownSpecial.args.map((arg) => arg.defaultValue ?? "")
if (argument.length > 0) {
const realArgs = argument.split(",").map((str) => this.undoEncoding(str))
for (let i = 0; i < realArgs.length; i++) {
if (args.length <= i) {
args.push(realArgs[i])
} else {
args[i] = realArgs[i]
}
}
}
const element: RenderingSpecification = {
args: args,
style: style,
func: knownSpecial,
}
return [...partBefore, element, ...partAfter]
}
}
// IF we end up here, no changes have to be made - except to remove any resting {}
return [template]
}
public static DocumentationFor(viz: string | SpecialVisualization): BaseUIElement | undefined {
if (typeof viz === "string") {
viz = SpecialVisualizations.specialVisualizations.find((sv) => sv.funcName === viz)
@ -346,31 +265,38 @@ export default class SpecialVisualizations {
viz.docs,
viz.args.length > 0
? new Table(
["name", "default", "description"],
viz.args.map((arg) => {
let defaultArg = arg.defaultValue ?? "_undefined_"
if (defaultArg == "") {
defaultArg = "_empty string_"
}
return [arg.name, defaultArg, arg.doc]
})
)
["name", "default", "description"],
viz.args.map((arg) => {
let defaultArg = arg.defaultValue ?? "_undefined_"
if (defaultArg == "") {
defaultArg = "_empty string_"
}
return [arg.name, defaultArg, arg.doc]
}),
)
: undefined,
new Title("Example usage of " + viz.funcName, 4),
new FixedUiElement(
viz.example ??
"`{" +
viz.funcName +
"(" +
viz.args.map((arg) => arg.defaultValue).join(",") +
")}`"
"`{" +
viz.funcName +
"(" +
viz.args.map((arg) => arg.defaultValue).join(",") +
")}`",
).SetClass("literal-code"),
])
}
public static constructSpecification(
template: string,
extraMappings: SpecialVisualization[] = [],
): RenderingSpecification[] {
return SpecialVisualisationUtils.constructSpecification(template, extraMappings)
}
public static HelpMessage() {
const helpTexts = SpecialVisualizations.specialVisualizations.map((viz) =>
SpecialVisualizations.DocumentationFor(viz)
SpecialVisualizations.DocumentationFor(viz),
)
return new Combine([
@ -404,10 +330,10 @@ export default class SpecialVisualizations {
},
},
null,
" "
)
" ",
),
).SetClass("code"),
'In other words: use `{ "before": ..., "after": ..., "special": {"type": ..., "argname": ...argvalue...}`. The args are in the `special` block; an argvalue can be a string, a translation or another value. (Refer to class `RewriteSpecial` in case of problems)',
"In other words: use `{ \"before\": ..., \"after\": ..., \"special\": {\"type\": ..., \"argname\": ...argvalue...}`. The args are in the `special` block; an argvalue can be a string, a translation or another value. (Refer to class `RewriteSpecial` in case of problems)",
]).SetClass("flex flex-col"),
...helpTexts,
]).SetClass("flex flex-col")
@ -416,20 +342,20 @@ export default class SpecialVisualizations {
// noinspection JSUnusedGlobalSymbols
public static renderExampleOfSpecial(
state: SpecialVisualizationState,
s: SpecialVisualization
s: SpecialVisualization,
): BaseUIElement {
const examples =
s.structuredExamples === undefined
? []
: s.structuredExamples().map((e) => {
return s.constr(
state,
new UIEventSource<Record<string, string>>(e.feature.properties),
e.args,
e.feature,
undefined
)
})
return s.constr(
state,
new UIEventSource<Record<string, string>>(e.feature.properties),
e.args,
e.feature,
undefined,
)
})
return new Combine([new Title(s.funcName), s.docs, ...examples])
}
@ -469,7 +395,7 @@ export default class SpecialVisualizations {
assignTo: state.userRelatedState.language,
availableLanguages: state.layout.language,
preferredLanguages: state.osmConnection.userDetails.map(
(ud) => ud.languages
(ud) => ud.languages,
),
})
},
@ -494,7 +420,7 @@ export default class SpecialVisualizations {
constr(
state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>
tagSource: UIEventSource<Record<string, string>>,
): BaseUIElement {
return new VariableUiElement(
tagSource
@ -504,7 +430,7 @@ export default class SpecialVisualizations {
return new SplitRoadWizard(<WayId>id, state)
}
return undefined
})
}),
)
},
},
@ -518,7 +444,7 @@ export default class SpecialVisualizations {
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig
layer: LayerConfig,
): BaseUIElement {
if (feature.geometry.type !== "Point") {
return undefined
@ -541,7 +467,7 @@ export default class SpecialVisualizations {
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig
layer: LayerConfig,
): BaseUIElement {
if (!layer.deletion) {
return undefined
@ -569,7 +495,7 @@ export default class SpecialVisualizations {
state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature
feature: Feature,
): BaseUIElement {
const [lon, lat] = GeoOperations.centerpointCoordinates(feature)
return new SvelteUIElement(CreateNewNote, {
@ -633,7 +559,7 @@ export default class SpecialVisualizations {
.map((tags) => tags[args[0]])
.map((wikidata) => {
wikidata = Utils.NoEmpty(
wikidata?.split(";")?.map((wd) => wd.trim()) ?? []
wikidata?.split(";")?.map((wd) => wd.trim()) ?? [],
)[0]
const entry = Wikidata.LoadWikidataEntry(wikidata)
return new VariableUiElement(
@ -643,9 +569,9 @@ export default class SpecialVisualizations {
}
const response = <WikidataResponse>e["success"]
return Translation.fromMap(response.labels)
})
}),
)
})
}),
),
},
new MapillaryLinkVis(),
@ -654,8 +580,13 @@ export default class SpecialVisualizations {
funcName: "all_tags",
docs: "Prints all key-value pairs of the object - used for debugging",
args: [],
constr: (state, tags: UIEventSource<any>) =>
new SvelteUIElement(AllTagsPanel, { tags, state }),
constr: (
state,
tags: UIEventSource<Record<string, string>>,
_,
__,
layer: LayerConfig,
) => new SvelteUIElement(AllTagsPanel, { tags, layer }),
},
{
funcName: "image_carousel",
@ -677,7 +608,6 @@ export default class SpecialVisualizations {
AllImageProviders.LoadImagesFor(tags, imagePrefixes),
tags,
state,
feature
)
},
},
@ -733,7 +663,7 @@ export default class SpecialVisualizations {
{
nameKey: nameKey,
fallbackName,
}
},
)
return new SvelteUIElement(StarsBarIcon, {
score: reviews.average,
@ -766,7 +696,7 @@ export default class SpecialVisualizations {
{
nameKey: nameKey,
fallbackName,
}
},
)
return new SvelteUIElement(ReviewForm, { reviews, state, tags, feature, layer })
},
@ -798,7 +728,7 @@ export default class SpecialVisualizations {
{
nameKey: nameKey,
fallbackName,
}
},
)
return new SvelteUIElement(AllReviews, { reviews, state, tags, feature, layer })
},
@ -856,7 +786,7 @@ export default class SpecialVisualizations {
tags: UIEventSource<Record<string, string>>,
args: string[],
feature: Feature,
layer: LayerConfig
layer: LayerConfig,
): SvelteUIElement {
const keyToUse = args[0]
const prefix = args[1]
@ -893,18 +823,17 @@ export default class SpecialVisualizations {
return undefined
}
const allUnits: Unit[] = [].concat(
...(state?.layout?.layers?.map((lyr) => lyr.units) ?? [])
...(state?.layout?.layers?.map((lyr) => lyr.units) ?? []),
)
const unit = allUnits.filter((unit) =>
unit.isApplicableToKey(key)
unit.isApplicableToKey(key),
)[0]
if (unit === undefined) {
return value
}
const getCountry = () => tagSource.data._country
const [v, denom] = unit.findDenomination(value, getCountry)
return unit.asHumanLongValue(v, getCountry)
})
return unit.asHumanLongValue(value, getCountry)
}),
)
},
},
@ -921,7 +850,7 @@ export default class SpecialVisualizations {
new Combine([
t.downloadFeatureAsGeojson.SetClass("font-bold text-lg"),
t.downloadGeoJsonHelper.SetClass("subtle"),
]).SetClass("flex flex-col")
]).SetClass("flex flex-col"),
)
.onClick(() => {
console.log("Exporting as Geojson")
@ -934,7 +863,7 @@ export default class SpecialVisualizations {
title + "_mapcomplete_export.geojson",
{
mimetype: "application/vnd.geo+json",
}
},
)
})
.SetClass("w-full")
@ -970,7 +899,7 @@ export default class SpecialVisualizations {
constr: (state) => {
return new SubtleButton(
Svg.delete_icon_svg().SetStyle("height: 1.5rem"),
Translations.t.general.removeLocationHistory
Translations.t.general.removeLocationHistory,
).onClick(() => {
state.historicalUserLocations.features.setData([])
state.selectedElement.setData(undefined)
@ -1008,10 +937,10 @@ export default class SpecialVisualizations {
.filter((c) => c.text !== "")
.map(
(c, i) =>
new NoteCommentElement(c, state, i, comments.length)
)
new NoteCommentElement(c, state, i, comments.length),
),
).SetClass("flex flex-col")
})
}),
),
},
{
@ -1040,21 +969,30 @@ export default class SpecialVisualizations {
docs: "Shows the title of the popup. Useful for some cases, e.g. 'What is phone number of {title()}?'",
example:
"`What is the phone number of {title()}`, which might automatically become `What is the phone number of XYZ`.",
constr: (state, tagsSource) =>
constr: (
state: SpecialVisualizationState,
tagsSource: UIEventSource<Record<string, string>>,
_: string[],
feature: Feature,
layer: LayerConfig,
) =>
new VariableUiElement(
tagsSource.map((tags) => {
if (state.layout === undefined) {
return "<feature title>"
}
const layer = state.layout?.getMatchingLayer(tags)
const title = layer?.title?.GetRenderValue(tags)
if (title === undefined) {
return undefined
}
return new SubstitutedTranslation(title, tagsSource, state).SetClass(
"px-1"
)
})
return new SvelteUIElement(SpecialTranslation, {
t: title,
tags: tagsSource,
state,
feature,
layer,
}).SetClass("px-1")
}),
),
},
{
@ -1070,8 +1008,8 @@ export default class SpecialVisualizations {
let challenge = Stores.FromPromise(
Utils.downloadJsonCached(
`${Maproulette.defaultEndpoint}/challenge/${parentId}`,
24 * 60 * 60 * 1000
)
24 * 60 * 60 * 1000,
),
)
return new VariableUiElement(
@ -1096,7 +1034,7 @@ export default class SpecialVisualizations {
} else {
return [title, new List(listItems)]
}
})
}),
)
},
docs: "Fetches the metadata of MapRoulette campaign that this task is part of and shows those details (namely `title`, `description` and `instruction`).\n\nThis reads the property `mr_challengeId` to detect the parent campaign.",
@ -1110,15 +1048,15 @@ export default class SpecialVisualizations {
"\n" +
"```json\n" +
"{\n" +
' "id": "mark_duplicate",\n' +
' "render": {\n' +
' "special": {\n' +
' "type": "maproulette_set_status",\n' +
' "message": {\n' +
' "en": "Mark as not found or false positive"\n' +
" \"id\": \"mark_duplicate\",\n" +
" \"render\": {\n" +
" \"special\": {\n" +
" \"type\": \"maproulette_set_status\",\n" +
" \"message\": {\n" +
" \"en\": \"Mark as not found or false positive\"\n" +
" },\n" +
' "status": "2",\n' +
' "image": "close"\n' +
" \"status\": \"2\",\n" +
" \"image\": \"close\"\n" +
" }\n" +
" }\n" +
"}\n" +
@ -1147,16 +1085,22 @@ export default class SpecialVisualizations {
doc: "The property name containing the maproulette id",
defaultValue: "mr_taskId",
},
{
name: "ask_feedback",
doc: "If not an empty string, this will be used as question to ask some additional feedback. A text field will be added",
defaultValue: ""
}
],
constr: (state, tagsSource, args) => {
let [message, image, message_closed, statusToSet, maproulette_id_key] = args
let [message, image, message_closed, statusToSet, maproulette_id_key, askFeedback] = args
if (image === "") {
image = "confirm"
}
if (maproulette_id_key === "" || maproulette_id_key === undefined) {
maproulette_id_key = "mr_taskId"
}
statusToSet = statusToSet ?? "1"
return new SvelteUIElement(MaprouletteSetStatus, {
state,
tags: tagsSource,
@ -1165,6 +1109,7 @@ export default class SpecialVisualizations {
message_closed,
statusToSet,
maproulette_id_key,
askFeedback
})
},
},
@ -1184,8 +1129,8 @@ export default class SpecialVisualizations {
const fsBboxed = new BBoxFeatureSourceForLayer(fs, bbox)
return new StatisticsPanel(fsBboxed)
},
[state.mapProperties.bounds]
)
[state.mapProperties.bounds],
),
)
},
},
@ -1231,7 +1176,7 @@ export default class SpecialVisualizations {
},
{
name: "href",
doc: "The URL to link to",
doc: "The URL to link to. Note that this will be URI-encoded before ",
required: true,
},
{
@ -1240,7 +1185,7 @@ export default class SpecialVisualizations {
},
{
name: "download",
doc: "If set, this link will act as a download-button. The contents of `href` will be offered for download; this parameter will act as the proposed filename",
doc: "Expects a string which denotes the filename to download the contents of `href` into. If set, this link will act as a download-button.",
},
{
name: "arialabel",
@ -1251,7 +1196,7 @@ export default class SpecialVisualizations {
constr(
state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>,
args: string[]
args: string[],
): BaseUIElement {
let [text, href, classnames, download, ariaLabel] = args
if (download === "") {
@ -1263,20 +1208,19 @@ export default class SpecialVisualizations {
(tags) =>
new SvelteUIElement(Link, {
text: Utils.SubstituteKeys(text, tags),
href: Utils.SubstituteKeys(href, tags),
href: Utils.SubstituteKeys(href, tags).replaceAll(/ /g, "%20") /* Chromium based browsers eat the spaces */,
classnames,
download: Utils.SubstituteKeys(download, tags),
ariaLabel: Utils.SubstituteKeys(ariaLabel, tags),
newTab,
})
)
}),
),
)
},
},
{
funcName: "multi",
docs: "Given an embedded tagRendering (read only) and a key, will read the keyname as a JSON-list. Every element of this list will be considered as tags and rendered with the tagRendering",
example:
"```json\n" +
JSON.stringify(
@ -1292,7 +1236,7 @@ export default class SpecialVisualizations {
},
},
null,
" "
" ",
) +
"\n```",
args: [
@ -1306,24 +1250,42 @@ export default class SpecialVisualizations {
doc: "An entire tagRenderingConfig",
required: true,
},
{
name: "classes",
doc: "CSS-classes to apply on every individual item. Seperated by `space`",
},
],
constr(state, featureTags, args) {
const [key, tr] = args
constr(
state: SpecialVisualizationState,
featureTags: UIEventSource<Record<string, string>>,
args: string[],
feature: Feature,
layer: LayerConfig,
) {
const [key, tr, classesRaw] = args
let classes = classesRaw ?? ""
const translation = new Translation({ "*": tr })
return new VariableUiElement(
featureTags.map((tags) => {
const properties: object[] = JSON.parse(tags[key])
let properties: object[]
if (typeof tags[key] === "string") {
properties = JSON.parse(tags[key])
} else {
properties = <any>tags[key]
}
const elements = []
for (const property of properties) {
const subsTr = new SubstitutedTranslation(
translation,
new UIEventSource<any>(property),
state
)
const subsTr = new SvelteUIElement(SpecialTranslation, {
t: translation,
tags: new ImmutableStore(property),
state,
feature,
layer,
}).SetClass(classes)
elements.push(subsTr)
}
return new List(elements)
})
return elements
}),
)
},
},
@ -1343,7 +1305,7 @@ export default class SpecialVisualizations {
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig
layer: LayerConfig,
): BaseUIElement {
return new VariableUiElement(
tagSource.map((tags) => {
@ -1355,7 +1317,7 @@ export default class SpecialVisualizations {
console.error("Cannot create a translation for", v, "due to", e)
return JSON.stringify(v)
}
})
}),
)
},
},
@ -1375,7 +1337,7 @@ export default class SpecialVisualizations {
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig
layer: LayerConfig,
): BaseUIElement {
const key = argument[0]
const validator = new FediverseValidator()
@ -1385,14 +1347,38 @@ export default class SpecialVisualizations {
.map((fediAccount) => {
fediAccount = validator.reformat(fediAccount)
const [_, username, host] = fediAccount.match(
FediverseValidator.usernameAtServer
FediverseValidator.usernameAtServer,
)
return new SvelteUIElement(Link, {
const normalLink = new SvelteUIElement(Link, {
text: fediAccount,
url: "https://" + host + "/@" + username,
href: "https://" + host + "/@" + username,
newTab: true,
})
})
const loggedInContributorMastodon =
state.userRelatedState?.preferencesAsTags?.data?.[
"_mastodon_link"
]
console.log(
"LoggedinContributorMastodon",
loggedInContributorMastodon,
)
if (!loggedInContributorMastodon) {
return normalLink
}
const homeUrl = new URL(loggedInContributorMastodon)
const homeHost = homeUrl.protocol + "//" + homeUrl.hostname
return new Combine([
normalLink,
new SvelteUIElement(Link, {
href: homeHost + "/" + fediAccount,
text: Translations.t.validation.fediverse.onYourServer,
newTab: true,
}).SetClass("button"),
])
}),
)
},
},
@ -1412,7 +1398,7 @@ export default class SpecialVisualizations {
tagSource: UIEventSource<Record<string, string>>,
args: string[],
feature: Feature,
layer: LayerConfig
layer: LayerConfig,
): BaseUIElement {
return new FixedUiElement("{" + args[0] + "}")
},
@ -1433,7 +1419,7 @@ export default class SpecialVisualizations {
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig
layer: LayerConfig,
): BaseUIElement {
const key = argument[0] ?? "value"
return new VariableUiElement(
@ -1451,12 +1437,12 @@ export default class SpecialVisualizations {
} catch (e) {
return new FixedUiElement(
"Could not parse this tag: " +
JSON.stringify(value) +
" due to " +
e
JSON.stringify(value) +
" due to " +
e,
).SetClass("alert")
}
})
}),
)
},
},
@ -1477,7 +1463,7 @@ export default class SpecialVisualizations {
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig
layer: LayerConfig,
): BaseUIElement {
const giggityUrl = argument[0]
return new SvelteUIElement(Giggity, { tags: tagSource, state, giggityUrl })
@ -1493,12 +1479,12 @@ export default class SpecialVisualizations {
_: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig
layer: LayerConfig,
): BaseUIElement {
const tags = (<ThemeViewState>(
state
)).geolocation.currentUserLocation.features.map(
(features) => features[0]?.properties
(features) => features[0]?.properties,
)
return new Combine([
new SvelteUIElement(OrientationDebugPanel, {}),
@ -1520,7 +1506,7 @@ export default class SpecialVisualizations {
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig
layer: LayerConfig,
): BaseUIElement {
return new SvelteUIElement(MarkAsFavourite, {
tags: tagSource,
@ -1540,7 +1526,7 @@ export default class SpecialVisualizations {
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig
layer: LayerConfig,
): BaseUIElement {
return new SvelteUIElement(MarkAsFavouriteMini, {
tags: tagSource,
@ -1560,7 +1546,7 @@ export default class SpecialVisualizations {
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig
layer: LayerConfig,
): BaseUIElement {
return new SvelteUIElement(DirectionIndicator, { state, feature })
},
@ -1575,7 +1561,7 @@ export default class SpecialVisualizations {
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig
layer: LayerConfig,
): BaseUIElement {
return new VariableUiElement(
tagSource
@ -1597,9 +1583,9 @@ export default class SpecialVisualizations {
`${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"
"width: 75px",
)
})
}),
)
},
},
@ -1619,7 +1605,7 @@ export default class SpecialVisualizations {
tagSource: UIEventSource<Record<string, string>>,
args: string[],
feature: Feature,
layer: LayerConfig
layer: LayerConfig,
): BaseUIElement {
const key = args[0] === "" ? "_direction:centerpoint" : args[0]
return new VariableUiElement(
@ -1630,14 +1616,72 @@ export default class SpecialVisualizations {
})
.mapD((value) => {
const dir = GeoOperations.bearingToHuman(
GeoOperations.parseBearing(value)
GeoOperations.parseBearing(value),
)
console.log("Human dir", dir)
return Translations.t.general.visualFeedback.directionsAbsolute[dir]
})
}),
)
},
},
{
funcName: "compare_data",
needsUrls: (args) => args[1].split(";"),
args: [
{
name: "url",
required: true,
doc: "The attribute containing the url where to fetch more data",
},
{
name: "host",
required: true,
doc: "The domain name(s) where data might be fetched from - this is needed to set the CSP. A domain must include 'https', e.g. 'https://example.com'. For multiple domains, separate them with ';'. If you don't know the possible domains, use '*'. ",
},
{
name: "postprocessing",
required: false,
doc: "Apply some postprocessing. Currently, only 'velopark' is allowed as value",
},
{
name: "readonly",
required: false,
doc: "If 'yes', will not show 'apply'-buttons",
},
],
docs: "Gives an interactive element which shows a tag comparison between the OSM-object and the upstream object. This allows to copy some or all tags into OSM",
constr(
state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>,
args: string[],
feature: Feature,
layer: LayerConfig,
): BaseUIElement {
const url = args[0]
const postprocessVelopark = args[2] === "velopark"
const readonly = args[3] === "yes"
return new SvelteUIElement(ComparisonTool, {
url,
postprocessVelopark,
state,
tags: tagSource,
layer,
feature,
readonly,
})
},
},
{
funcName: "login_button",
args: [
],
docs: "Show a login button",
needsUrls: [],
constr(state: SpecialVisualizationState, tagSource: UIEventSource<Record<string, string>>, args: string[], feature: Feature, layer: LayerConfig): BaseUIElement {
return new Toggle(undefined,
new SvelteUIElement(LoginButton), state.osmConnection.isLoggedIn)
},
},
]
specialVisualizations.push(new AutoApplyButton(specialVisualizations))
@ -1649,10 +1693,11 @@ export default class SpecialVisualizations {
throw (
"Invalid special visualisation found: funcName is undefined for " +
invalid.map((sp) => sp.i).join(", ") +
'. Did you perhaps type \n funcName: "funcname" // type declaration uses COLON\ninstead of:\n funcName = "funcName" // value definition uses EQUAL'
". Did you perhaps type \n funcName: \"funcname\" // type declaration uses COLON\ninstead of:\n funcName = \"funcName\" // value definition uses EQUAL"
)
}
SpecialVisualisationUtils.specialVisualizations = Utils.NoNull(specialVisualizations)
return specialVisualizations
}
}

Some files were not shown because too many files have changed in this diff Show more