Merge branch 'develop' into RobinLinde-patch-10

This commit is contained in:
Robin van der Linde 2023-07-17 22:34:16 +02:00
commit ff8442f90b
Signed by untrusted user: Robin-van-der-Linde
GPG key ID: 53956B3252478F0D
654 changed files with 17365 additions and 15965 deletions

57
src/UI/AllThemesGui.ts Normal file
View file

@ -0,0 +1,57 @@
import UserRelatedState from "../Logic/State/UserRelatedState"
import { FixedUiElement } from "./Base/FixedUiElement"
import Combine from "./Base/Combine"
import MoreScreen from "./BigComponents/MoreScreen"
import Translations from "./i18n/Translations"
import Constants from "../Models/Constants"
import LanguagePicker from "./LanguagePicker"
import IndexText from "./BigComponents/IndexText"
import { LoginToggle } from "./Popup/LoginButton"
import { ImmutableStore } from "../Logic/UIEventSource"
import { OsmConnection } from "../Logic/Osm/OsmConnection"
import { QueryParameters } from "../Logic/Web/QueryParameters"
import { OsmConnectionFeatureSwitches } from "../Logic/State/FeatureSwitchState"
export default class AllThemesGui {
setup() {
try {
const featureSwitches = new OsmConnectionFeatureSwitches()
const osmConnection = new OsmConnection({
fakeUser: featureSwitches.featureSwitchFakeUser.data,
oauth_token: QueryParameters.GetQueryParameter(
"oauth_token",
undefined,
"Used to complete the login"
),
osmConfiguration: <"osm" | "osm-test">featureSwitches.featureSwitchApiURL.data,
})
const state = new UserRelatedState(osmConnection)
const intro = new Combine([
new LanguagePicker(
Translations.t.index.title.SupportedLanguages(),
state.language
).SetClass("flex absolute top-2 right-3"),
new IndexText(),
])
new Combine([
intro,
new MoreScreen(state, true),
new LoginToggle(undefined, Translations.t.index.logIn, {
osmConnection,
featureSwitchUserbadge: new ImmutableStore(true),
}).SetClass("flex justify-center w-full"),
Translations.t.general.aboutMapComplete.intro.SetClass("link-underline"),
new FixedUiElement("v" + Constants.vNumber).SetClass("block"),
])
.SetClass("block m-5 lg:w-3/4 lg:ml-40")
.AttachTo("main")
} catch (e) {
console.error(">>>> CRITICAL", e)
new FixedUiElement(
"Seems like no layers are compiled - check the output of `npm run generate:layeroverview`. Is this visible online? Contact pietervdvn immediately!"
)
.SetClass("alert")
.AttachTo("main")
}
}
}

26
src/UI/Base/AsyncLazy.ts Normal file
View file

@ -0,0 +1,26 @@
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

@ -0,0 +1,21 @@
<script lang="ts">
/**
* Wrapper around 'subtleButton' with an arrow pointing to the right
* See also: NextButton
*/
import SubtleButton from "./SubtleButton.svelte"
import { ChevronLeftIcon } from "@rgossiaux/svelte-heroicons/solid"
import { createEventDispatcher } from "svelte"
import { twMerge } from "tailwind-merge"
const dispatch = createEventDispatcher<{ click }>()
export let clss: string | undefined = undefined
</script>
<SubtleButton
on:click={() => dispatch("click")}
options={{ extraClasses: twMerge("flex items-center", clss) }}
>
<ChevronLeftIcon class="h-12 w-12" slot="image" />
<slot slot="message" />
</SubtleButton>

25
src/UI/Base/Button.ts Normal file
View file

@ -0,0 +1,25 @@
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
export class Button extends BaseUIElement {
private _text: BaseUIElement
constructor(text: string | BaseUIElement, onclick: () => void | Promise<void>) {
super()
this._text = Translations.W(text)
this.onClick(onclick)
}
protected InnerConstructElement(): HTMLElement {
const el = this._text.ConstructElement()
if (el === undefined) {
return undefined
}
const form = document.createElement("form")
const button = document.createElement("button")
button.type = "button"
button.appendChild(el)
form.appendChild(button)
return form
}
}

View file

@ -0,0 +1,32 @@
import BaseUIElement from "../BaseUIElement"
export class CenterFlexedElement extends BaseUIElement {
private _html: string
constructor(html: string) {
super()
this._html = html ?? ""
}
InnerRender(): string {
return this._html
}
AsMarkdown(): string {
return this._html
}
protected InnerConstructElement(): HTMLElement {
const e = document.createElement("div")
e.innerHTML = this._html
e.style.display = "flex"
e.style.height = "100%"
e.style.width = "100%"
e.style.flexDirection = "column"
e.style.flexWrap = "nowrap"
e.style.alignContent = "center"
e.style.justifyContent = "center"
e.style.alignItems = "center"
return e
}
}

38
src/UI/Base/ChartJs.ts Normal file
View file

@ -0,0 +1,38 @@
import BaseUIElement from "../BaseUIElement"
import { Chart, ChartConfiguration, ChartType, DefaultDataPoint, registerables } from "chart.js"
Chart?.register(...(registerables ?? []))
export default class ChartJs<
TType extends ChartType = ChartType,
TData = DefaultDataPoint<TType>,
TLabel = unknown
> extends BaseUIElement {
private readonly _config: ChartConfiguration<TType, TData, TLabel>
constructor(config: ChartConfiguration<TType, TData, TLabel>) {
super()
this._config = config
}
protected InnerConstructElement(): HTMLElement {
const canvas = document.createElement("canvas")
// A bit exceptional: we apply the styles before giving them to 'chartJS'
if (this.style !== undefined) {
canvas.style.cssText = this.style
}
if (this.clss?.size > 0) {
try {
canvas.classList.add(...Array.from(this.clss))
} catch (e) {
console.error(
"Invalid class name detected in:",
Array.from(this.clss).join(" "),
"\nErr msg is ",
e
)
}
}
new Chart(canvas, this._config)
return canvas
}
}

View file

@ -0,0 +1,12 @@
<script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource.js"
/**
* For some stupid reason, it is very hard to bind inputs
*/
export let selected: UIEventSource<boolean>
let _c: boolean = selected.data ?? true
$: selected.setData(_c)
</script>
<input type="checkbox" bind:checked={_c} />

71
src/UI/Base/Combine.ts Normal file
View file

@ -0,0 +1,71 @@
import { FixedUiElement } from "./FixedUiElement"
import { Utils } from "../../Utils"
import BaseUIElement from "../BaseUIElement"
export default class Combine extends BaseUIElement {
private readonly uiElements: BaseUIElement[]
constructor(uiElements: (string | BaseUIElement)[]) {
super()
this.uiElements = Utils.NoNull(uiElements).map((el) => {
if (typeof el === "string") {
return new FixedUiElement(el)
}
return el
})
}
AsMarkdown(): string {
let sep = " "
if (this.HasClass("flex-col")) {
sep = "\n\n"
}
return this.uiElements.map((el) => el.AsMarkdown()).join(sep)
}
Destroy() {
super.Destroy()
for (const uiElement of this.uiElements) {
uiElement.Destroy()
}
}
public getElements(): BaseUIElement[] {
return this.uiElements
}
protected InnerConstructElement(): HTMLElement {
const el = document.createElement("span")
try {
if (this.uiElements === undefined) {
console.error(
"PANIC: this.uiElements is undefined. (This might indicate a constructor which did not call 'super'. The constructor name is",
this.constructor /*Disable code quality: used for debugging*/.name + ")"
)
}
for (const subEl of this.uiElements) {
if (subEl === undefined || subEl === null) {
continue
}
try {
const subHtml = subEl.ConstructElement()
if (subHtml !== undefined) {
el.appendChild(subHtml)
}
} catch (e) {
console.error("Could not generate subelement in combine due to ", e)
}
}
} catch (e) {
const domExc = e as DOMException
console.error("DOMException: ", domExc.name)
el.appendChild(
new FixedUiElement("Could not generate this combine!")
.SetClass("alert")
.ConstructElement()
)
}
return el
}
}

View file

@ -0,0 +1,19 @@
import BaseUIElement from "../BaseUIElement"
/**
* Introduces a new element which has an ID
* Mostly a workaround for the import viewer
*/
export default class DivContainer extends BaseUIElement {
private readonly _id: string
constructor(id: string) {
super()
this._id = id
}
protected InnerConstructElement(): HTMLElement {
const e = document.createElement("div")
e.id = this._id
return e
}
}

View file

@ -0,0 +1,89 @@
<script lang="ts">
/**
* This overlay element will regularly show a hand that swipes over the underlying element.
* This element will hide as soon as the Store 'hideSignal' receives a change (which is not undefined)
*/
import { Store } from "../../Logic/UIEventSource"
import { onDestroy } from "svelte"
let mainElem: HTMLElement
export let hideSignal: Store<any>
function hide() {
mainElem.style.visibility = "hidden"
}
let initTime = Date.now()
if (hideSignal) {
onDestroy(
hideSignal.addCallbackD(() => {
if (initTime + 1000 > Date.now()) {
console.log("Ignoring hide signal")
return
}
console.log("Received hide signal")
hide()
return true
})
)
}
$: {
mainElem?.addEventListener("click", (_) => hide())
mainElem?.addEventListener("touchstart", (_) => hide())
}
</script>
<div bind:this={mainElem} class="pointer-events-none absolute bottom-0 right-0 h-full w-full">
<div id="hand-container">
<img src="./assets/svg/hand.svg" />
</div>
</div>
<style>
@keyframes hand-drag-animation {
/* This is the animation on the little extra hand on the location input. If fades in, invites the user to interact/drag the map */
0% {
opacity: 0;
transform: rotate(-30deg);
}
6% {
opacity: 1;
transform: rotate(-30deg);
}
12% {
opacity: 1;
transform: rotate(-45deg);
}
24% {
opacity: 1;
transform: rotate(-00deg);
}
30% {
opacity: 1;
transform: rotate(-30deg);
}
36% {
opacity: 0;
transform: rotate(-30deg);
}
100% {
opacity: 0;
transform: rotate(-30deg);
}
}
#hand-container {
position: absolute;
width: 2rem;
left: calc(50% + 4rem);
top: calc(50%);
opacity: 0.7;
animation: hand-drag-animation 4s ease-in-out infinite;
transform-origin: 50% 125%;
}
</style>

View file

@ -0,0 +1,14 @@
<script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource.js"
/**
* For some stupid reason, it is very hard to bind inputs
*/
export let value: UIEventSource<number>
let i: number = value.data
$: value.setData(i)
</script>
<select bind:value={i}>
<slot />
</select>

View file

@ -0,0 +1,50 @@
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

@ -0,0 +1,33 @@
import BaseUIElement from "../BaseUIElement"
export class FixedUiElement extends BaseUIElement {
public readonly content: string
constructor(html: string) {
super()
this.content = html ?? ""
}
InnerRender(): string {
return this.content
}
AsMarkdown(): string {
if (this.HasClass("code")) {
if (this.content.indexOf("\n") > 0 || this.HasClass("block")) {
return "\n```\n" + this.content + "\n```\n"
}
return "`" + this.content + "`"
}
if (this.HasClass("font-bold")) {
return "*" + this.content + "*"
}
return this.content
}
protected InnerConstructElement(): HTMLElement {
const e = document.createElement("span")
e.innerHTML = this.content
return e
}
}

View file

@ -0,0 +1,38 @@
<script lang="ts">
import { createEventDispatcher } from "svelte"
import { XCircleIcon } from "@rgossiaux/svelte-heroicons/solid"
/**
* The slotted element will be shown on top, with a lower-opacity border
*/
const dispatch = createEventDispatcher<{ close }>()
</script>
<div
class="absolute top-0 right-0 h-screen w-screen p-4 md:p-6"
style="background-color: #00000088"
>
<div class="content normal-background">
<div class="h-full rounded-xl">
<slot />
</div>
<slot name="close-button">
<!-- The close button is placed _after_ the default slot in order to always paint it on top -->
<div
class="absolute right-10 top-10 h-8 w-8 cursor-pointer"
on:click={() => dispatch("close")}
>
<XCircleIcon />
</div>
</slot>
</div>
</div>
<style>
.content {
height: calc(100vh - 2rem);
border-radius: 0.5rem;
overflow-x: auto;
box-shadow: 0 0 1rem #00000088;
}
</style>

View file

@ -0,0 +1,19 @@
<script lang="ts">
/**
* Given an HTML string, properly shows this
*/
export let src: string
let htmlElem: HTMLElement
$: {
if (htmlElem) {
htmlElem.innerHTML = src
}
}
export let clss: string | undefined = undefined
</script>
{#if src !== undefined}
<span bind:this={htmlElem} class={clss} />
{/if}

135
src/UI/Base/Hotkeys.ts Normal file
View file

@ -0,0 +1,135 @@
import { Utils } from "../../Utils"
import Combine from "./Combine"
import BaseUIElement from "../BaseUIElement"
import Title from "./Title"
import Table from "./Table"
import { UIEventSource } from "../../Logic/UIEventSource"
import { VariableUiElement } from "./VariableUIElement"
import { Translation } from "../i18n/Translation"
import { FixedUiElement } from "./FixedUiElement"
import Translations from "../i18n/Translations"
export default class Hotkeys {
private static readonly _docs: UIEventSource<
{
key: { ctrl?: string; shift?: string; alt?: string; nomod?: string; onUp?: boolean }
documentation: string | Translation
}[]
> = new UIEventSource<
{
key: { ctrl?: string; shift?: string; alt?: string; nomod?: string; onUp?: boolean }
documentation: string | Translation
}[]
>([])
private static textElementSelected(): boolean {
return ["input", "textarea"].includes(document?.activeElement?.tagName?.toLowerCase())
}
public static RegisterHotkey(
key: (
| {
ctrl: string
}
| {
shift: string
}
| {
alt: string
}
| {
nomod: string
}
) & {
onUp?: boolean
},
documentation: string | Translation,
action: () => void
) {
const type = key["onUp"] ? "keyup" : "keypress"
let keycode: string = key["ctrl"] ?? key["shift"] ?? key["alt"] ?? key["nomod"]
if (keycode.length == 1) {
keycode = keycode.toLowerCase()
if (key["shift"] !== undefined) {
keycode = keycode.toUpperCase()
}
}
this._docs.data.push({ key, documentation })
this._docs.ping()
if (Utils.runningFromConsole) {
return
}
if (key["ctrl"] !== undefined) {
document.addEventListener("keydown", function (event) {
if (event.ctrlKey && event.key === keycode) {
action()
event.preventDefault()
}
})
} else if (key["shift"] !== undefined) {
document.addEventListener(type, function (event) {
if (Hotkeys.textElementSelected()) {
// A text element is selected, we don't do anything special
return
}
if (event.shiftKey && event.key === keycode) {
action()
event.preventDefault()
}
})
} else if (key["alt"] !== undefined) {
document.addEventListener(type, function (event) {
if (event.altKey && event.key === keycode) {
action()
event.preventDefault()
}
})
} else if (key["nomod"] !== undefined) {
document.addEventListener(type, function (event) {
if (Hotkeys.textElementSelected()) {
// A text element is selected, we don't do anything special
return
}
if (event.key === keycode) {
action()
event.preventDefault()
}
})
}
}
static generateDocumentation(): BaseUIElement {
let byKey: [string, string | Translation][] = Hotkeys._docs.data
.map(({ key, documentation }) => {
const modifiers = Object.keys(key).filter((k) => k !== "nomod" && k !== "onUp")
let keycode: string = key["ctrl"] ?? key["shift"] ?? key["alt"] ?? key["nomod"]
if (keycode.length == 1) {
keycode = keycode.toUpperCase()
}
modifiers.push(keycode)
return <[string, string | Translation]>[modifiers.join("+"), documentation]
})
.sort()
byKey = Utils.NoNull(byKey)
for (let i = byKey.length - 1; i > 0; i--) {
if (byKey[i - 1][0] === byKey[i][0]) {
byKey.splice(i, 1)
}
}
const t = Translations.t.hotkeyDocumentation
return new Combine([
new Title(t.title, 1),
t.intro,
new Table(
[t.key, t.action],
byKey.map(([key, doc]) => {
return [new FixedUiElement(key).SetClass("literal-code"), doc]
})
),
])
}
static generateDocumentationDynamic(): BaseUIElement {
return new VariableUiElement(Hotkeys._docs.map((_) => Hotkeys.generateDocumentation()))
}
}

24
src/UI/Base/If.svelte Normal file
View file

@ -0,0 +1,24 @@
<script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource"
import { onDestroy } from "svelte"
/**
* For some stupid reason, it is very hard to let {#if} work together with UIEventSources, so we wrap then here
*/
export let condition: UIEventSource<boolean>
let _c = condition.data
onDestroy(
condition.addCallback((c) => {
/* Do _not_ abbreviate this as `.addCallback(c => _c = c)`. This is the same as writing `.addCallback(c => {return _c = c})`,
which will _unregister_ the callback if `c = true`! */
_c = c
return false
})
)
</script>
{#if _c}
<slot />
{:else}
<slot name="else" />
{/if}

View file

@ -0,0 +1,34 @@
<script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource"
import { onDestroy } from "svelte"
/**
* Functions as 'If', but uses 'display:hidden' instead.
*/
export let condition: UIEventSource<boolean>
let _c = condition.data
let hasBeenShownPositive = false
let hasBeenShownNegative = false
onDestroy(
condition.addCallbackAndRun((c) => {
/* Do _not_ abbreviate this as `.addCallback(c => _c = c)`. This is the same as writing `.addCallback(c => {return _c = c})`,
which will _unregister_ the callback if `c = true`! */
hasBeenShownPositive = hasBeenShownPositive || c
hasBeenShownNegative = hasBeenShownNegative || !c
_c = c
return false
})
)
</script>
{#if hasBeenShownPositive}
<span class={_c ? "" : "hidden"}>
<slot />
</span>
{/if}
{#if hasBeenShownNegative}
<span class={_c ? "hidden" : ""}>
<slot name="else" />
</span>
{/if}

20
src/UI/Base/IfNot.svelte Normal file
View file

@ -0,0 +1,20 @@
<script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource"
import { onDestroy } from "svelte"
/**
* For some stupid reason, it is very hard to let {#if} work together with UIEventSources, so we wrap then here
*/
export let condition: UIEventSource<boolean>
let _c = !condition.data
onDestroy(
condition.addCallback((c) => {
_c = !c
return false
})
)
</script>
{#if _c}
<slot />
{/if}

78
src/UI/Base/Img.ts Normal file
View file

@ -0,0 +1,78 @@
import { Utils } from "../../Utils"
import BaseUIElement from "../BaseUIElement"
export default class Img extends BaseUIElement {
private readonly _src: string
private readonly _rawSvg: boolean
private readonly _options: { readonly fallbackImage?: string }
constructor(
src: string,
rawSvg = false,
options?: {
fallbackImage?: string
}
) {
super()
if (src === undefined || src === "undefined") {
throw "Undefined src for image"
}
this._src = src
this._rawSvg = rawSvg
this._options = options
}
static AsData(source: string) {
if (Utils.runningFromConsole) {
return source
}
try {
return `data:image/svg+xml;base64,${btoa(source)}`
} catch (e) {
console.error("Cannot create an image for", source.slice(0, 100))
console.trace("Cannot create an image for the given source string due to ", e)
return ""
}
}
static AsImageElement(source: string, css_class: string = "", style = ""): string {
return `<img class="${css_class}" style="${style}" alt="" src="${Img.AsData(source)}">`
}
AsMarkdown(): string {
if (this._rawSvg === true) {
console.warn("Converting raw svgs to markdown is not supported")
return undefined
}
let src = this._src
if (this._src.startsWith("./")) {
src = "https://mapcomplete.osm.be/" + src
}
return "![](" + src + ")"
}
protected InnerConstructElement(): HTMLElement {
const self = this
if (this._rawSvg) {
const e = document.createElement("div")
e.innerHTML = this._src
return e
}
const el = document.createElement("img")
el.src = this._src
el.onload = () => {
el.style.opacity = "1"
}
el.onerror = () => {
if (self._options?.fallbackImage) {
if (el.src === self._options.fallbackImage) {
// Sigh... nothing to be done anymore
return
}
el.src = self._options.fallbackImage
}
}
return el
}
}

15
src/UI/Base/Lazy.ts Normal file
View file

@ -0,0 +1,15 @@
import BaseUIElement from "../BaseUIElement"
export default class Lazy extends BaseUIElement {
private readonly _f: () => BaseUIElement
constructor(f: () => BaseUIElement) {
super()
this._f = f
}
protected InnerConstructElement(): HTMLElement {
// The caching of the BaseUIElement will guarantee that _f will only be called once
return this._f().ConstructElement()
}
}

64
src/UI/Base/Link.ts Normal file
View file

@ -0,0 +1,64 @@
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
import { Store } from "../../Logic/UIEventSource"
export default class Link extends BaseUIElement {
private readonly _href: string | Store<string>
private readonly _embeddedShow: BaseUIElement
private readonly _newTab: boolean
constructor(
embeddedShow: BaseUIElement | string,
href: string | Store<string>,
newTab: boolean = false
) {
super()
this._embeddedShow = Translations.W(embeddedShow)
this._href = href
this._newTab = newTab
if (this._embeddedShow === undefined) {
throw "Error: got a link where embeddedShow is undefined"
}
this.onClick(() => {})
}
public static OsmWiki(key: string, value?: string, hideKey = false) {
if (value !== undefined) {
let k = ""
if (!hideKey) {
k = key + "="
}
return new Link(
k + value,
`https://wiki.openstreetmap.org/wiki/Tag:${key}%3D${value}`,
true
)
}
return new Link(key, "https://wiki.openstreetmap.org/wiki/Key:" + key, true)
}
AsMarkdown(): string {
// @ts-ignore
return `[${this._embeddedShow.AsMarkdown()}](${this._href.data ?? this._href})`
}
protected InnerConstructElement(): HTMLElement {
const embeddedShow = this._embeddedShow?.ConstructElement()
if (embeddedShow === undefined) {
return undefined
}
const el = document.createElement("a")
if (typeof this._href === "string") {
el.href = this._href
} else {
this._href.addCallbackAndRun((href) => {
el.href = href
})
}
if (this._newTab) {
el.target = "_blank"
}
el.appendChild(embeddedShow)
return el
}
}

View file

@ -0,0 +1,78 @@
import { VariableUiElement } from "./VariableUIElement"
import Locale from "../i18n/Locale"
import Link from "./Link"
import Svg from "../../Svg"
/**
* The little 'translate'-icon next to every icon + some static helper functions
*/
export default class LinkToWeblate extends VariableUiElement {
constructor(context: string, availableTranslations: object) {
super(
Locale.language.map(
(ln) => {
if (Locale.showLinkToWeblate.data === false) {
return undefined
}
if (availableTranslations["*"] !== undefined) {
return undefined
}
if (context === undefined || context.indexOf(":") < 0) {
return undefined
}
const icon = Svg.translate_svg().SetClass(
"rounded-full inline-block w-3 h-3 ml-1 weblate-link self-center"
)
if (availableTranslations[ln] === undefined) {
icon.SetClass("bg-red-400")
}
return new Link(icon, LinkToWeblate.hrefToWeblate(ln, context), true)
},
[Locale.showLinkToWeblate]
)
)
this.SetClass("enable-links")
const self = this
Locale.showLinkOnMobile.addCallbackAndRunD((showOnMobile) => {
if (showOnMobile) {
self.RemoveClass("hidden-on-mobile")
} else {
self.SetClass("hidden-on-mobile")
}
})
}
/**
* Creates the url to Hosted weblate
*
* LinkToWeblate.hrefToWeblate("nl", "category:some.context") // => "https://hosted.weblate.org/translate/mapcomplete/category/nl/?offset=1&q=context%3A%3D%22some.context%22"
*/
public static hrefToWeblate(language: string, contextKey: string): string {
if (contextKey === undefined || contextKey.indexOf(":") < 0) {
return undefined
}
const [category, ...rest] = contextKey.split(":")
const key = rest.join(":")
const baseUrl = "https://hosted.weblate.org/translate/mapcomplete/"
return baseUrl + category + "/" + language + "/?offset=1&q=context%3A%3D%22" + key + "%22"
}
public static hrefToWeblateZen(
language: string,
category: "core" | "themes" | "layers" | "shared-questions" | "glossary" | string,
searchKey: string
): string {
const baseUrl = "https://hosted.weblate.org/zen/mapcomplete/"
// ?offset=1&q=+state%3A%3Ctranslated+context%3Acampersite&sort_by=-priority%2Cposition&checksum=
return (
baseUrl +
category +
"/" +
language +
"?offset=1&q=+state%3A%3Ctranslated+context%3A" +
encodeURIComponent(searchKey) +
"&sort_by=-priority%2Cposition&checksum="
)
}
}

52
src/UI/Base/List.ts Normal file
View file

@ -0,0 +1,52 @@
import { Utils } from "../../Utils"
import BaseUIElement from "../BaseUIElement"
import Translations from "../i18n/Translations"
export default class List extends BaseUIElement {
private readonly uiElements: BaseUIElement[]
private readonly _ordered: boolean
constructor(uiElements: (string | BaseUIElement)[], ordered = false) {
super()
this._ordered = ordered
this.uiElements = Utils.NoNull(uiElements).map((s) => Translations.W(s))
}
AsMarkdown(): string {
if (this._ordered) {
return (
"\n\n" +
this.uiElements
.map((el, i) => " " + i + ". " + el.AsMarkdown().replace(/\n/g, " \n"))
.join("\n") +
"\n"
)
} else {
return (
"\n\n" +
this.uiElements
.map((el) => " - " + el.AsMarkdown().replace(/\n/g, " \n"))
.join("\n") +
"\n"
)
}
}
protected InnerConstructElement(): HTMLElement {
const el = document.createElement(this._ordered ? "ol" : "ul")
for (const subEl of this.uiElements) {
if (subEl === undefined || subEl === null) {
continue
}
const subHtml = subEl.ConstructElement()
if (subHtml !== undefined) {
const item = document.createElement("li")
item.appendChild(subHtml)
el.appendChild(item)
}
}
return el
}
}

View file

@ -0,0 +1,13 @@
<script>
import ToSvelte from "./ToSvelte.svelte"
import Svg from "../../Svg"
</script>
<div class="flex p-1 pl-2">
<div class="min-w-6 h-6 w-6 animate-spin self-center">
<ToSvelte construct={Svg.loading_svg()} />
</div>
<div class="ml-2">
<slot />
</div>
</div>

18
src/UI/Base/Loading.ts Normal file
View file

@ -0,0 +1,18 @@
import Combine from "./Combine"
import Svg from "../../Svg"
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
export default class Loading extends Combine {
constructor(msg?: BaseUIElement | string) {
const t = Translations.W(msg) ?? Translations.t.general.loading
t.SetClass("pl-2")
super([
Svg.loading_svg()
.SetClass("animate-spin self-center")
.SetStyle("width: 1.5rem; height: 1.5rem; min-width: 1.5rem;"),
t,
])
this.SetClass("flex p-1")
}
}

View file

@ -0,0 +1,17 @@
<script lang="ts">
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"
export let osmConnection: OsmConnection
export let clss: string | undefined = undefined
</script>
<button class={clss} on:click={() => osmConnection.AttemptLogin()}>
<ToSvelte construct={Svg.login_svg().SetClass("w-12 m-1")} />
<slot name="message">
<Tr t={Translations.t.general.loginWithOpenStreetMap} />
</slot>
</button>

View file

@ -0,0 +1,45 @@
<script lang="ts">
import Loading from "./Loading.svelte"
import type { OsmServiceState } from "../../Logic/Osm/OsmConnection"
import { Translation } from "../i18n/Translation"
import Translations from "../i18n/Translations"
import Tr from "./Tr.svelte"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { ImmutableStore, UIEventSource } from "../../Logic/UIEventSource"
export let state: {
osmConnection: OsmConnection
featureSwitches?: { featureSwitchUserbadge?: UIEventSource<boolean> }
}
/**
* If set, 'loading' will act as if we are already logged in.
*/
export let ignoreLoading: boolean = false
let loadingStatus = state.osmConnection.loadingStatus
let badge = state.featureSwitches?.featureSwitchUserbadge ?? new ImmutableStore(true)
const t = Translations.t.general
const offlineModes: Partial<Record<OsmServiceState, Translation>> = {
offline: t.loginFailedOfflineMode,
unreachable: t.loginFailedUnreachableMode,
unknown: t.loginFailedUnreachableMode,
readonly: t.loginFailedReadonlyMode,
}
const apiState = state.osmConnection.apiIsOnline
</script>
{#if $badge}
{#if !ignoreLoading && $loadingStatus === "loading"}
<slot name="loading">
<Loading />
</slot>
{:else if $loadingStatus === "error"}
<div class="alert max-w-64 flex items-center">
<img src="./assets/svg/invalid.svg" class="m-2 h-8 w-8 shrink-0" />
<Tr t={offlineModes[$apiState]} />
</div>
{:else if $loadingStatus === "logged-in"}
<slot />
{:else if $loadingStatus === "not-attempted"}
<slot name="not-logged-in" />
{/if}
{/if}

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { createEventDispatcher } from "svelte"
import { twJoin } from "tailwind-merge"
/**
* A round button with an icon and possible a small text, which hovers above the map
*/
const dispatch = createEventDispatcher()
export let cls = ""
</script>
<button
on:click={(e) => dispatch("click", e)}
class={twJoin("pointer-events-auto m-0.5 h-fit w-fit rounded-full p-0.5 sm:p-1 md:m-1", cls)}
>
<slot />
</button>

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { createEventDispatcher } from "svelte"
import { XCircleIcon } from "@rgossiaux/svelte-heroicons/solid"
/**
* The slotted element will be shown on the right side
*/
const dispatch = createEventDispatcher<{ close }>()
</script>
<div
class="absolute top-0 right-0 h-screen w-full overflow-auto drop-shadow-2xl md:w-6/12 lg:w-5/12 xl:w-4/12"
style="max-width: 100vw; max-height: 100vh"
>
<div class="normal-background m-0 flex flex-col">
<slot name="close-button">
<div
class="absolute right-10 top-10 h-8 w-8 cursor-pointer"
on:click={() => dispatch("close")}
>
<XCircleIcon />
</div>
</slot>
<slot />
</div>
</div>

View file

@ -0,0 +1,25 @@
<script lang="ts">
/**
* Wrapper around 'subtleButton' with an arrow pointing to the right
* See also: BackButton
*/
import SubtleButton from "./SubtleButton.svelte"
import { ChevronRightIcon } from "@rgossiaux/svelte-heroicons/solid"
import { createEventDispatcher } from "svelte"
import { twMerge } from "tailwind-merge"
const dispatch = createEventDispatcher<{ click }>()
export let clss: string | undefined = undefined
</script>
<SubtleButton
on:click={() => dispatch("click")}
options={{ extraClasses: twMerge("flex items-center", clss) }}
>
<slot name="image" slot="image" />
<div class="flex w-full items-center justify-between" slot="message">
<slot />
<ChevronRightIcon class="h-12 w-12" />
</div>
</SubtleButton>

30
src/UI/Base/Paragraph.ts Normal file
View file

@ -0,0 +1,30 @@
import BaseUIElement from "../BaseUIElement"
export class Paragraph extends BaseUIElement {
public readonly content: string | BaseUIElement
constructor(html: string | BaseUIElement) {
super()
this.content = html ?? ""
}
AsMarkdown(): string {
let c: string
if (typeof this.content !== "string") {
c = this.content.AsMarkdown()
} else {
c = this.content
}
return "\n\n" + c + "\n\n"
}
protected InnerConstructElement(): HTMLElement {
const e = document.createElement("p")
if (typeof this.content !== "string") {
e.appendChild(this.content.ConstructElement())
} else {
e.innerHTML = this.content
}
return e
}
}

View file

@ -0,0 +1,30 @@
<script lang="ts">
import ToSvelte from "./ToSvelte.svelte"
import Svg from "../../Svg"
export let generateShareData: () => {
text: string
title: string
url: string
}
function share() {
if (!navigator.share) {
console.log("web share not supported")
return
}
navigator
.share(generateShareData())
.then(() => {
console.log("Thanks for sharing!")
})
.catch((err) => {
console.log(`Couldn't share because of`, err.message)
})
}
</script>
<button on:click={share} class="secondary m-0 h-8 w-8 p-0">
<slot name="content">
<ToSvelte construct={Svg.share_svg().SetClass("w-7 h-7 p-1")} />
</slot>
</button>

View file

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

View file

@ -0,0 +1,97 @@
import BaseUIElement from "../BaseUIElement"
import { Store, UIEventSource } 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"
import Combine from "./Combine"
import Img from "./Img"
/**
* @deprecated
*/
export class SubtleButton extends UIElement {
private readonly imageUrl: string | BaseUIElement
private readonly message: string | BaseUIElement
private readonly options: {
url?: string | Store<string>
newTab?: boolean
imgSize?: string
extraClasses?: string
}
constructor(
imageUrl: string | BaseUIElement,
message: string | BaseUIElement,
options: {
url?: string | Store<string>
newTab?: boolean
imgSize?: "h-11 w-11" | string
extraClasses?: string
} = {}
) {
super()
this.imageUrl = imageUrl
this.message = message
this.options = options
}
protected InnerRender(): string | BaseUIElement {
if (this.options.url !== undefined) {
return new SvelteUIElement(SubtleLink, {
href: this.options.url,
newTab: this.options.newTab,
})
}
const classes = "button"
const message = Translations.W(this.message)?.SetClass(
"block overflow-ellipsis no-images flex-shrink"
)
let img
const imgClasses =
"block justify-center flex-none mr-4 " + (this.options?.imgSize ?? "h-11 w-11")
if ((this.imageUrl ?? "") === "") {
img = undefined
} else if (typeof this.imageUrl === "string") {
img = new Img(this.imageUrl)?.SetClass(imgClasses)
} else {
img = this.imageUrl?.SetClass(imgClasses)
}
const button = new Combine([img, message]).SetClass("flex items-center group w-full")
this.SetClass(classes)
return button
}
public OnClickWithLoading(
loadingText: BaseUIElement | string,
action: () => Promise<void>
): BaseUIElement {
const state = new UIEventSource<"idle" | "running">("idle")
const button = this
button.onClick(async () => {
state.setData("running")
try {
await action()
} catch (e) {
console.error(e)
} finally {
state.setData("idle")
}
})
const loading = new Lazy(() => new Loading(loadingText))
return new VariableUiElement(
state.map((st) => {
if (st === "idle") {
return button
}
return loading
})
)
}
}

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { onMount } from "svelte"
import { twJoin, twMerge } from "tailwind-merge"
import BaseUIElement from "../BaseUIElement"
import Img from "./Img"
export let imageUrl: string | BaseUIElement = undefined
export let href: string
export let newTab = false
export let options: {
imgSize?: string
extraClasses?: string
} = {}
let imgElem: HTMLElement
let imgClasses = twJoin("block justify-center shrink-0 mr-4", options?.imgSize ?? "h-11 w-11")
onMount(() => {
// Image
if (imgElem && imageUrl) {
let img: BaseUIElement
if ((imageUrl ?? "") === "") {
img = undefined
} else if (typeof imageUrl !== "string") {
img = imageUrl?.SetClass(imgClasses)
}
if (img) imgElem.replaceWith(img.ConstructElement())
}
})
</script>
<a
class={twMerge(options.extraClasses, "button text-ellipsis")}
{href}
target={newTab ? "_blank" : undefined}
>
<slot name="image">
{#if imageUrl !== undefined}
{#if typeof imageUrl === "string"}
<Img src={imageUrl} class={imgClasses} />
{:else}
<template bind:this={imgElem} />
{/if}
{/if}
</slot>
<slot />
</a>

View file

@ -0,0 +1,44 @@
import BaseUIElement from "../BaseUIElement"
import { SvelteComponentTyped } from "svelte"
/**
* The SvelteUIComponent serves as a translating class which which wraps a SvelteElement into the BaseUIElement framework.
* Also see ToSvelte.svelte for the opposite conversion
*/
export default class SvelteUIElement<
Props extends Record<string, any> = any,
Events extends Record<string, any> = any,
Slots extends Record<string, any> = any
> extends BaseUIElement {
private readonly _svelteComponent: {
new (args: {
target: HTMLElement
props: Props
events?: Events
slots?: Slots
}): SvelteComponentTyped<Props, Events, Slots>
}
private readonly _props: Props
private readonly _events: Events
private readonly _slots: Slots
constructor(svelteElement, props: Props, events?: Events, slots?: Slots) {
super()
this._svelteComponent = svelteElement
this._props = props
this._events = events
this._slots = slots
}
protected InnerConstructElement(): HTMLElement {
const el = document.createElement("div")
new this._svelteComponent({
target: el,
props: this._props,
events: this._events,
slots: this._slots,
})
return el
}
}

View file

@ -0,0 +1,135 @@
<script lang="ts">
/**
* Thin wrapper around 'TabGroup' which binds the state
*/
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@rgossiaux/svelte-headlessui"
import { UIEventSource } from "../../Logic/UIEventSource"
import { twJoin } from "tailwind-merge"
export let tab: UIEventSource<number>
let tabElements: HTMLElement[] = []
$: tabElements[$tab]?.click()
$: {
if (tabElements[tab.data]) {
window.setTimeout(() => tabElements[tab.data].click(), 50)
}
}
</script>
<div class="tabbedgroup flex h-full w-full">
<TabGroup
class="flex h-full w-full flex-col"
defaultIndex={1}
on:change={(e) => {
if (e.detail >= 0) {
tab.setData(e.detail)
}
}}
>
<div class="interactive sticky top-0 flex items-center justify-between">
<TabList class="flex flex-wrap">
{#if $$slots.title1}
<Tab class={({ selected }) => twJoin("tab", selected && "primary")}>
<div bind:this={tabElements[0]} class="flex">
<slot name="title0">Tab 0</slot>
</div>
</Tab>
{/if}
{#if $$slots.title1}
<Tab class={({ selected }) => twJoin("tab", selected && "primary")}>
<div bind:this={tabElements[1]} class="flex">
<slot name="title1" />
</div>
</Tab>
{/if}
{#if $$slots.title2}
<Tab class={({ selected }) => twJoin("tab", selected && "primary")}>
<div bind:this={tabElements[2]} class="flex">
<slot name="title2" />
</div>
</Tab>
{/if}
{#if $$slots.title3}
<Tab class={({ selected }) => twJoin("tab", selected && "primary")}>
<div bind:this={tabElements[3]} class="flex">
<slot name="title3" />
</div>
</Tab>
{/if}
{#if $$slots.title4}
<Tab class={({ selected }) => twJoin("tab", selected && "primary")}>
<div bind:this={tabElements[4]} class="flex">
<slot name="title4" />
</div>
</Tab>
{/if}
</TabList>
<slot name="post-tablist" />
</div>
<div class="normal-background h-full overflow-y-auto">
<TabPanels class="tabpanels" defaultIndex={$tab}>
<TabPanel class="tabpanel">
<slot name="content0">
<div>Empty</div>
</slot>
</TabPanel>
<TabPanel class="tabpanel">
<slot name="content1" />
</TabPanel>
<TabPanel class="tabpanel">
<slot name="content2" />
</TabPanel>
<TabPanel class="tabpanel">
<slot name="content3" />
</TabPanel>
<TabPanel class="tabpanel">
<slot name="content4" />
</TabPanel>
</TabPanels>
</div>
</TabGroup>
</div>
<style>
.tabbedgroup {
max-height: 100vh;
height: 100%;
}
:global(.tabpanel) {
height: 100%;
}
:global(.tabpanels) {
height: calc(100% - 2rem);
}
:global(.tab) {
margin: 0.25rem;
padding: 0.25rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
border-radius: 1rem;
}
:global(.tab .flex) {
align-items: center;
gap: 0.25rem;
}
:global(.tab span|div) {
align-items: center;
gap: 0.25rem;
display: flex;
}
:global(.tab-selected svg) {
fill: var(--catch-detail-color-contrast);
}
:global(.tab-unselected) {
background-color: var(--background-color) !important;
color: var(--foreground-color) !important;
}
</style>

135
src/UI/Base/Table.ts Normal file
View file

@ -0,0 +1,135 @@
import BaseUIElement from "../BaseUIElement"
import { Utils } from "../../Utils"
import Translations from "../i18n/Translations"
import { UIEventSource } from "../../Logic/UIEventSource"
export default class Table extends BaseUIElement {
private readonly _header: BaseUIElement[]
private readonly _contents: BaseUIElement[][]
private readonly _contentStyle: string[][]
private readonly _sortable: boolean
constructor(
header: (BaseUIElement | string)[],
contents: (BaseUIElement | string)[][],
options?: {
contentStyle?: string[][]
sortable?: false | boolean
}
) {
super()
this._contentStyle = options?.contentStyle ?? [["min-width: 9rem"]]
this._header = header?.map(Translations.W)
this._contents = contents.map((row) => row.map(Translations.W))
this._sortable = options?.sortable ?? false
}
AsMarkdown(): string {
const headerMarkdownParts = this._header.map((hel) => hel?.AsMarkdown() ?? " ")
const header = Utils.NoNull(headerMarkdownParts).join(" | ")
const headerSep = headerMarkdownParts.map((part) => "-".repeat(part.length + 2)).join(" | ")
const table = this._contents
.map((row) => row.map((el) => el?.AsMarkdown()?.replace("|", "\\|") ?? " ").join(" | "))
.join("\n")
return "\n\n" + [header, headerSep, table, ""].join("\n")
}
protected InnerConstructElement(): HTMLElement {
const table = document.createElement("table")
/**
* Sortmode: i: sort column i ascending;
* if i is negative : sort column (-i - 1) descending
*/
const sortmode = new UIEventSource<number>(undefined)
const self = this
const headerElems = Utils.NoNull(
(this._header ?? []).map((elem, i) => {
if (self._sortable) {
elem.onClick(() => {
const current = sortmode.data
if (current == i) {
sortmode.setData(-1 - i)
} else {
sortmode.setData(i)
}
})
}
return elem.ConstructElement()
})
)
if (headerElems.length > 0) {
const thead = document.createElement("thead")
const tr = document.createElement("tr")
headerElems.forEach((headerElem) => {
const td = document.createElement("th")
td.appendChild(headerElem)
tr.appendChild(td)
})
thead.appendChild(tr)
table.appendChild(thead)
}
for (let i = 0; i < this._contents.length; i++) {
let row = this._contents[i]
const tr = document.createElement("tr")
for (let j = 0; j < row.length; j++) {
try {
let elem = row[j]
if (elem?.ConstructElement === undefined) {
continue
}
const htmlElem = elem?.ConstructElement()
if (htmlElem === undefined) {
continue
}
let style = undefined
if (
this._contentStyle !== undefined &&
this._contentStyle[i] !== undefined &&
this._contentStyle[j] !== undefined
) {
style = this._contentStyle[i][j]
}
const td = document.createElement("td")
td.style.cssText = style
td.appendChild(htmlElem)
tr.appendChild(td)
} catch (e) {
console.error("Could not render an element in a table due to", e, row[j])
}
}
table.appendChild(tr)
}
sortmode.addCallback((sortCol) => {
if (sortCol === undefined) {
return
}
const descending = sortCol < 0
const col = descending ? -sortCol - 1 : sortCol
let rows: HTMLTableRowElement[] = Array.from(table.rows)
rows.splice(0, 1) // remove header row
rows = rows.sort((a, b) => {
const ac = a.cells[col]?.textContent?.toLowerCase()
const bc = b.cells[col]?.textContent?.toLowerCase()
if (ac === bc) {
return 0
}
return ac < bc !== descending ? -1 : 1
})
for (let j = rows.length; j > 1; j--) {
table.deleteRow(j)
}
for (const row of rows) {
table.appendChild(row)
}
})
return table
}
}

View file

@ -0,0 +1,120 @@
import Combine from "./Combine"
import BaseUIElement from "../BaseUIElement"
import { Translation } from "../i18n/Translation"
import { FixedUiElement } from "./FixedUiElement"
import Title from "./Title"
import List from "./List"
import Link from "./Link"
import { Utils } from "../../Utils"
export default class TableOfContents extends Combine {
private readonly titles: Title[]
constructor(
elements: Combine | Title[],
options?: {
noTopLevel: false | boolean
maxDepth?: number
}
) {
let titles: Title[]
if (elements instanceof Combine) {
titles = TableOfContents.getTitles(elements.getElements()) ?? []
} else {
titles = elements ?? []
}
let els: { level: number; content: BaseUIElement }[] = []
for (const title of titles) {
let content: BaseUIElement
if (title.title instanceof Translation) {
content = title.title.Clone()
} else if (title.title instanceof FixedUiElement) {
content = new FixedUiElement(title.title.content)
} else if (Utils.runningFromConsole) {
content = new FixedUiElement(title.AsMarkdown())
} else if (title["title"] !== undefined) {
content = new FixedUiElement(title.title.ConstructElement().textContent)
} else {
console.log("Not generating a title for ", title)
continue
}
const vis = new Link(content, "#" + title.id)
els.push({ level: title.level, content: vis })
}
const minLevel = Math.min(...els.map((e) => e.level))
if (options?.noTopLevel) {
els = els.filter((e) => e.level !== minLevel)
}
if (options?.maxDepth) {
els = els.filter((e) => e.level <= options.maxDepth + minLevel)
}
super(TableOfContents.mergeLevel(els).map((el) => el.SetClass("mt-2")))
this.SetClass("flex flex-col")
this.titles = titles
}
private static getTitles(elements: BaseUIElement[]): Title[] {
const titles = []
for (const uiElement of elements) {
if (uiElement instanceof Combine) {
titles.push(...TableOfContents.getTitles(uiElement.getElements()))
} else if (uiElement instanceof Title) {
titles.push(uiElement)
}
}
return titles
}
private static mergeLevel(
elements: { level: number; content: BaseUIElement }[]
): BaseUIElement[] {
const maxLevel = Math.max(...elements.map((e) => e.level))
const minLevel = Math.min(...elements.map((e) => e.level))
if (maxLevel === minLevel) {
return elements.map((e) => e.content)
}
const result: { level: number; content: BaseUIElement }[] = []
let running: BaseUIElement[] = []
for (const element of elements) {
if (element.level === maxLevel) {
running.push(element.content)
continue
}
if (running.length !== undefined) {
result.push({
content: new List(running),
level: maxLevel - 1,
})
running = []
}
result.push(element)
}
if (running.length !== undefined) {
result.push({
content: new List(running),
level: maxLevel - 1,
})
}
return TableOfContents.mergeLevel(result)
}
AsMarkdown(): string {
const depthIcons = ["1.", " -", " +", " *"]
const lines = ["## Table of contents\n"]
const minLevel = Math.min(...this.titles.map((t) => t.level))
for (const title of this.titles) {
const prefix = depthIcons[title.level - minLevel] ?? " ~"
const text = title.title.AsMarkdown().replace("\n", "")
const link = title.id
lines.push(prefix + " [" + text + "](#" + link + ")")
}
return lines.join("\n") + "\n\n"
}
}

72
src/UI/Base/Title.ts Normal file
View file

@ -0,0 +1,72 @@
import BaseUIElement from "../BaseUIElement"
import { FixedUiElement } from "./FixedUiElement"
import { Utils } from "../../Utils"
export default class Title extends BaseUIElement {
private static readonly defaultClassesPerLevel = [
"",
"text-3xl font-bold",
"text-2xl font-bold",
"text-xl font-bold",
"text-lg font-bold",
]
public readonly title: BaseUIElement
public readonly level: number
public readonly id: string
constructor(embedded: string | BaseUIElement, level: number = 3) {
super()
if (embedded === undefined) {
throw "A title should have some content. Undefined is not allowed"
}
if (typeof embedded === "string") {
this.title = new FixedUiElement(embedded)
} else {
this.title = embedded
}
this.level = level
let text: string = undefined
if (typeof embedded === "string") {
text = embedded
} else if (embedded instanceof FixedUiElement) {
text = embedded.content
} else {
if (!Utils.runningFromConsole) {
text = embedded.ConstructElement()?.textContent
}
}
this.id =
text
?.replace(/ /g, "-")
?.replace(/[?#.;:/]/, "")
?.toLowerCase() ?? ""
this.SetClass(Title.defaultClassesPerLevel[level] ?? "")
}
AsMarkdown(): string {
const embedded = " " + this.title.AsMarkdown() + " "
if (this.level == 1) {
return "\n\n" + embedded + "\n" + "=".repeat(embedded.length) + "\n\n"
}
if (this.level == 2) {
return "\n\n" + embedded + "\n" + "-".repeat(embedded.length) + "\n\n"
}
return "\n\n" + "#".repeat(this.level) + embedded + "\n\n"
}
protected InnerConstructElement(): HTMLElement {
const el = this.title.ConstructElement()
if (el === undefined) {
return undefined
}
const h = document.createElement("h" + this.level)
h.appendChild(el)
el.id = this.id
return h
}
}

View file

@ -0,0 +1,21 @@
<script lang="ts">
import BaseUIElement from "../BaseUIElement.js"
import { onDestroy, onMount } from "svelte"
export let construct: BaseUIElement | (() => BaseUIElement)
let elem: HTMLElement
let html: HTMLElement
onMount(() => {
const uiElem = typeof construct === "function" ? construct() : construct
html = uiElem?.ConstructElement()
if (html !== undefined) {
elem.replaceWith(html)
}
})
onDestroy(() => {
html?.remove()
})
</script>
<span bind:this={elem} />

38
src/UI/Base/Tr.svelte Normal file
View file

@ -0,0 +1,38 @@
<script lang="ts">
/**
* Properly renders a translation
*/
import { Translation } from "../i18n/Translation"
import { onDestroy } from "svelte"
import Locale from "../i18n/Locale"
import { Utils } from "../../Utils"
import FromHtml from "./FromHtml.svelte"
import WeblateLink from "./WeblateLink.svelte"
export let t: Translation
export let cls: string = ""
export let tags: Record<string, string> | undefined = undefined
// Text for the current language
let txt: string | undefined
$: onDestroy(
Locale.language.addCallbackAndRunD((l) => {
const translation = t?.textFor(l)
if (translation === undefined) {
return
}
if (tags) {
txt = Utils.SubstituteKeys(txt, tags)
} else {
txt = translation
}
})
)
</script>
{#if t}
<span class={cls}>
<FromHtml src={txt} />
<WeblateLink context={t.context} />
</span>
{/if}

View file

@ -0,0 +1,62 @@
import { Store } from "../../Logic/UIEventSource"
import BaseUIElement from "../BaseUIElement"
import Combine from "./Combine"
export class VariableUiElement extends BaseUIElement {
private readonly _contents?: Store<string | BaseUIElement | BaseUIElement[]>
constructor(contents?: Store<string | BaseUIElement | BaseUIElement[]>) {
super()
this._contents = contents
}
Destroy() {
super.Destroy()
this.isDestroyed = true
}
AsMarkdown(): string {
const d = this._contents?.data
if (typeof d === "string") {
return d
}
if (d instanceof BaseUIElement) {
return d.AsMarkdown()
}
return new Combine(<BaseUIElement[]>d).AsMarkdown()
}
protected InnerConstructElement(): HTMLElement {
const el = document.createElement("span")
const self = this
this._contents?.addCallbackAndRun((contents) => {
if (self.isDestroyed) {
return true
}
while (el.firstChild) {
el.removeChild(el.lastChild)
}
if (contents === undefined) {
return
}
if (typeof contents === "string") {
el.innerHTML = contents
} else if (contents instanceof Array) {
for (const content of contents) {
const c = content?.ConstructElement()
if (c !== undefined && c !== null) {
el.appendChild(c)
}
}
} else {
const c = contents.ConstructElement()
if (c !== undefined && c !== null) {
el.appendChild(c)
}
}
})
return el
}
}

View file

@ -0,0 +1,33 @@
<script lang="ts">
import Locale from "../i18n/Locale"
import LinkToWeblate from "./LinkToWeblate"
/**
* Shows a small icon which will open up weblate; a contributor can translate the item for 'context' there
*/
export let context: string
let linkToWeblate = Locale.showLinkToWeblate
let linkOnMobile = Locale.showLinkOnMobile
let language = Locale.language
</script>
{#if !!context && context.indexOf(":") > 0}
{#if $linkOnMobile}
<a
href={LinkToWeblate.hrefToWeblate($language, context)}
target="_blank"
class="weblate-link mx-1"
>
<img src="./assets/svg/translate.svg" class="font-gray" />
</a>
{:else if $linkToWeblate}
<a
href={LinkToWeblate.hrefToWeblate($language, context)}
class="weblate-link hidden-on-mobile mx-1"
target="_blank"
>
<img src="./assets/svg/translate.svg" class="font-gray inline-block" />
</a>
{/if}
{/if}

189
src/UI/BaseUIElement.ts Normal file
View file

@ -0,0 +1,189 @@
/**
* A thin wrapper around a html element, which allows to generate a HTML-element.
*
* Assumes a read-only configuration, so it has no 'ListenTo'
*/
import { Utils } from "../Utils"
export default abstract class BaseUIElement {
protected _constructedHtmlElement: HTMLElement
protected isDestroyed = false
protected readonly clss: Set<string> = new Set<string>()
protected style: string
private _onClick: () => void | Promise<void>
public onClick(f: () => void) {
this._onClick = f
this.SetClass("cursor-pointer")
if (this._constructedHtmlElement !== undefined) {
this._constructedHtmlElement.onclick = f
}
return this
}
AttachTo(divId: string) {
let element = document.getElementById(divId)
if (element === null) {
if (Utils.runningFromConsole) {
this.ConstructElement()
return
}
throw "SEVERE: could not attach UIElement to " + divId
}
let alreadyThere = false
const elementToAdd = this.ConstructElement()
const childs = Array.from(element.childNodes)
for (const child of childs) {
if (child === elementToAdd) {
alreadyThere = true
continue
}
element.removeChild(child)
}
if (elementToAdd !== undefined && !alreadyThere) {
element.appendChild(elementToAdd)
}
return this
}
public ScrollIntoView() {
if (this._constructedHtmlElement === undefined) {
return
}
this._constructedHtmlElement?.scrollIntoView({
behavior: "smooth",
block: "start",
})
}
/**
* Adds all the relevant classes, space separated
*/
public SetClass(clss: string) {
if (clss == undefined) {
return this
}
const all = clss.split(" ").map((clsName) => clsName.trim())
let recordedChange = false
for (let c of all) {
c = c.trim()
if (this.clss.has(clss)) {
continue
}
if (c === undefined || c === "") {
continue
}
this.clss.add(c)
recordedChange = true
}
if (recordedChange) {
this._constructedHtmlElement?.classList.add(...Array.from(this.clss))
}
return this
}
public RemoveClass(classes: string): BaseUIElement {
const all = classes.split(" ").map((clsName) => clsName.trim())
for (let clss of all) {
if (this.clss.has(clss)) {
this.clss.delete(clss)
this._constructedHtmlElement?.classList.remove(clss)
}
}
return this
}
public HasClass(clss: string): boolean {
return this.clss.has(clss)
}
public SetStyle(style: string): BaseUIElement {
this.style = style
if (this._constructedHtmlElement !== undefined) {
this._constructedHtmlElement.style.cssText = style
}
return this
}
/**
* The same as 'Render', but creates a HTML element instead of the HTML representation
*/
public ConstructElement(): HTMLElement {
if (typeof window === undefined) {
return undefined
}
if (this._constructedHtmlElement !== undefined) {
return this._constructedHtmlElement
}
try {
const el = this.InnerConstructElement()
if (el === undefined) {
return undefined
}
this._constructedHtmlElement = el
const style = this.style
if (style !== undefined && style !== "") {
el.style.cssText = style
}
if (this.clss?.size > 0) {
try {
el.classList.add(...Array.from(this.clss))
} catch (e) {
console.error(
"Invalid class name detected in:",
Array.from(this.clss).join(" "),
"\nErr msg is ",
e
)
}
}
if (this._onClick !== undefined) {
const self = this
el.onclick = async (e) => {
// @ts-ignore
if (e.consumed) {
return
}
const v = self._onClick()
if (typeof v === "object") {
await v
}
// @ts-ignore
e.consumed = true
}
el.classList.add("cursor-pointer")
}
return el
} catch (e) {
const domExc = e as DOMException
if (domExc) {
console.error(
"An exception occured",
domExc.code,
domExc.message,
domExc.name,
domExc
)
}
console.error(e)
}
}
public AsMarkdown(): string {
throw "AsMarkdown is not implemented; implement it in the subclass"
}
public Destroy() {
this.isDestroyed = true
}
protected abstract InnerConstructElement(): HTMLElement
}

View file

@ -0,0 +1,88 @@
<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.maplibre
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

@ -0,0 +1,45 @@
<script lang="ts">
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { Tiles } from "../../Models/TileRange"
import { Utils } from "../../Utils"
import global_community from "../../assets/community_index_global_resources.json"
import ContactLink from "./ContactLink.svelte"
import { GeoOperations } from "../../Logic/GeoOperations"
import Translations from "../i18n/Translations"
import ToSvelte from "../Base/ToSvelte.svelte"
import type { Feature, Geometry, GeometryCollection } from "@turf/turf"
export let location: Store<{ lat: number; lon: number }>
const tileToFetch: Store<string> = location.mapD((l) => {
const t = Tiles.embedded_tile(l.lat, l.lon, 6)
return `https://raw.githubusercontent.com/pietervdvn/MapComplete-data/main/community_index/tile_${t.z}_${t.x}_${t.y}.geojson`
})
const t = Translations.t.communityIndex
const resources = new UIEventSource<
Feature<Geometry | GeometryCollection, { resources; nameEn: string }>[]
>([])
tileToFetch.addCallbackAndRun(async (url) => {
const data = await Utils.downloadJsonCached(url, 24 * 60 * 60)
if (data === undefined) {
return
}
resources.setData(data.features)
})
const filteredResources = resources.map(
(features) =>
features.filter((f) => {
return GeoOperations.inside([location.data.lon, location.data.lat], f)
}),
[location]
)
</script>
<div>
<ToSvelte construct={t.intro} />
{#each $filteredResources as feature}
<ContactLink country={feature.properties} />
{/each}
<ContactLink country={{ resources: global_community, nameEn: "Global resources" }} />
</div>

View file

@ -0,0 +1,50 @@
<script lang="ts">
// A contact link indicates how a mapper can contact their local community
// The _properties_ of a community feature
import Locale from "../i18n/Locale.js"
import Translations from "../i18n/Translations"
import ToSvelte from "../Base/ToSvelte.svelte"
import * as native from "../../assets/language_native.json"
import { TypedTranslation } from "../i18n/Translation"
const availableTranslationTyped: TypedTranslation<{ native: string }> =
Translations.t.communityIndex.available
const availableTranslation = availableTranslationTyped.OnEveryLanguage((s, ln) =>
s.replace("{native}", native[ln] ?? ln)
)
export let country: { resources; nameEn: string }
let resources: {
id: string
resolved: Record<string, string>
languageCodes: string[]
type: string
}[] = []
$: resources = Array.from(Object.values(country?.resources ?? {}))
const language = Locale.language
</script>
<div>
{#if country?.nameEn}
<h3>{country?.nameEn}</h3>
{/if}
{#each resources as resource}
<div class="link-underline my-4 flex items-center">
<img
class="m-2 h-8 w-8"
src={`https://raw.githubusercontent.com/pietervdvn/MapComplete-data/main/community_index/${resource.type}.svg`}
/>
<div class="flex flex-col">
<a href={resource.resolved.url} target="_blank" rel="noreferrer nofollow" class="font-bold">
{resource.resolved.name ?? resource.resolved.url}
</a>
{resource.resolved?.description}
{#if resource.languageCodes?.indexOf($language) >= 0}
<div class="thanks w-fit">
<ToSvelte construct={() => availableTranslation.Clone()} />
</div>
{/if}
</div>
</div>
{/each}
</div>

View file

@ -0,0 +1,209 @@
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import { Store } from "../../Logic/UIEventSource"
import { FixedUiElement } from "../Base/FixedUiElement"
import licenses from "../../assets/generated/license_info.json"
import SmallLicense from "../../Models/smallLicense"
import { Utils } from "../../Utils"
import Link from "../Base/Link"
import { VariableUiElement } from "../Base/VariableUIElement"
import contributors from "../../assets/contributors.json"
import translators from "../../assets/translators.json"
import BaseUIElement from "../BaseUIElement"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import Title from "../Base/Title"
import { BBox } from "../../Logic/BBox"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import Constants from "../../Models/Constants"
import ContributorCount from "../../Logic/ContributorCount"
import Img from "../Base/Img"
import { TypedTranslation } from "../i18n/Translation"
import GeoIndexedStore from "../../Logic/FeatureSource/Actors/GeoIndexedStore"
import { RasterLayerPolygon } from "../../Models/RasterLayers"
/**
* The attribution panel in the theme menu.
*/
export default class CopyrightPanel extends Combine {
private static LicenseObject = CopyrightPanel.GenerateLicenses()
constructor(state: {
layout: LayoutConfig
mapProperties: {
readonly bounds: Store<BBox>
readonly rasterLayer: Store<RasterLayerPolygon>
}
osmConnection: OsmConnection
dataIsLoading: Store<boolean>
perLayer: ReadonlyMap<string, GeoIndexedStore>
}) {
const t = Translations.t.general.attribution
const layoutToUse = state.layout
const iconAttributions: BaseUIElement[] = Utils.Dedup(layoutToUse.usedImages).map(
CopyrightPanel.IconAttribution
)
let maintainer: BaseUIElement = undefined
if (layoutToUse.credits !== undefined && layoutToUse.credits !== "") {
maintainer = t.themeBy.Subs({ author: layoutToUse.credits })
}
const contributions = new ContributorCount(state).Contributors
const dataContributors = new VariableUiElement(
contributions.map((contributions) => {
if (contributions === undefined) {
return ""
}
const sorted = Array.from(contributions, ([name, value]) => ({
name,
value,
})).filter((x) => x.name !== undefined && x.name !== "undefined")
if (sorted.length === 0) {
return ""
}
sorted.sort((a, b) => b.value - a.value)
let hiddenCount = 0
if (sorted.length > 10) {
hiddenCount = sorted.length - 10
sorted.splice(10, sorted.length - 10)
}
const links = sorted.map(
(kv) =>
`<a href="https://openstreetmap.org/user/${kv.name}" target="_blank">${kv.name}</a>`
)
const contribs = links.join(", ")
if (hiddenCount <= 0) {
return t.mapContributionsBy.Subs({
contributors: contribs,
})
} else {
return t.mapContributionsByAndHidden.Subs({
contributors: contribs,
hiddenCount: hiddenCount,
})
}
})
)
super(
[
new Title(t.attributionTitle),
t.attributionContent,
new VariableUiElement(
state.mapProperties.rasterLayer.mapD((layer) => {
const props = layer.properties
const attrUrl = props.attribution?.url
const attrText = props.attribution?.text
let bgAttr: BaseUIElement | string = undefined
if (attrText && attrUrl) {
bgAttr =
"<a href='" + attrUrl + "' target='_blank'>" + attrText + "</a>"
} else if (attrUrl) {
bgAttr = attrUrl
} else {
bgAttr = attrText
}
if (bgAttr) {
return Translations.t.general.attribution.attributionBackgroundLayerWithCopyright.Subs(
{
name: props.name,
copyright: bgAttr,
}
)
}
return Translations.t.general.attribution.attributionBackgroundLayer.Subs(
props
)
})
),
maintainer,
dataContributors,
CopyrightPanel.CodeContributors(contributors, t.codeContributionsBy),
CopyrightPanel.CodeContributors(translators, t.translatedBy),
new FixedUiElement("MapComplete " + Constants.vNumber).SetClass("font-bold"),
new Title(t.iconAttribution.title, 3),
...iconAttributions,
].map((e) => e?.SetClass("mt-4"))
)
this.SetClass("flex flex-col link-underline overflow-hidden")
this.SetStyle("max-width:100%; width: 40rem; margin-left: 0.75rem; margin-right: 0.5rem")
}
private static CodeContributors(
contributors,
translation: TypedTranslation<{ contributors; hiddenCount }>
): BaseUIElement {
const total = contributors.contributors.length
let filtered = [...contributors.contributors]
filtered.splice(10, total - 10)
let contribsStr = filtered.map((c) => c.contributor).join(", ")
if (contribsStr === "") {
// Hmm, something went wrong loading the contributors list. Lets show nothing
return undefined
}
return translation.Subs({
contributors: contribsStr,
hiddenCount: total - 10,
})
}
private static IconAttribution(iconPath: string): BaseUIElement {
if (iconPath.startsWith("http")) {
try {
iconPath = "." + new URL(iconPath).pathname
} catch (e) {
console.warn(e)
}
}
const license: SmallLicense = CopyrightPanel.LicenseObject[iconPath]
if (license == undefined) {
return undefined
}
if (license.license.indexOf("trivial") >= 0) {
return undefined
}
const sources = Utils.NoNull(Utils.NoEmpty(license.sources))
return new Combine([
new Img(iconPath).SetClass("w-12 min-h-12 mr-2 mb-2"),
new Combine([
new FixedUiElement(license.authors.join("; ")).SetClass("font-bold"),
license.license,
new Combine([
...sources.map((lnk) => {
let sourceLinkContent = lnk
try {
sourceLinkContent = new URL(lnk).hostname
} catch {
console.error("Not a valid URL:", lnk)
}
return new Link(sourceLinkContent, lnk, true).SetClass("mr-2 mb-2")
}),
]).SetClass("flex flex-wrap"),
])
.SetClass("flex flex-col")
.SetStyle("width: calc(100% - 50px - 0.5em); min-width: 12rem;"),
]).SetClass("flex flex-wrap border-b border-gray-300 m-2 border-box")
}
private static GenerateLicenses() {
const allLicenses = {}
for (const key in licenses) {
const license: SmallLicense = licenses[key]
allLicenses[license.path] = license
}
return allLicenses
}
}

View file

@ -0,0 +1,101 @@
import { UIElement } from "../UIElement"
import BaseUIElement from "../BaseUIElement"
import { Store } from "../../Logic/UIEventSource"
import ExtraLinkConfig from "../../Models/ThemeConfig/ExtraLinkConfig"
import Img from "../Base/Img"
import { SubtleButton } from "../Base/SubtleButton"
import Toggle from "../Input/Toggle"
import Locale from "../i18n/Locale"
import { Utils } from "../../Utils"
import Svg from "../../Svg"
import Translations from "../i18n/Translations"
import { Translation } from "../i18n/Translation"
interface ExtraLinkButtonState {
layout: { id: string; title: Translation }
featureSwitches: { featureSwitchWelcomeMessage: Store<boolean> }
mapProperties: {
location: Store<{ lon: number; lat: number }>
zoom: Store<number>
}
}
export default class ExtraLinkButton extends UIElement {
private readonly _config: ExtraLinkConfig
private readonly state: ExtraLinkButtonState
constructor(state: ExtraLinkButtonState, config: ExtraLinkConfig) {
super()
this.state = state
this._config = config
}
protected InnerRender(): BaseUIElement {
if (this._config === undefined) {
return undefined
}
const c = this._config
const isIframe = window !== window.top
if (c.requirements?.has("iframe") && !isIframe) {
return undefined
}
if (c.requirements?.has("no-iframe") && isIframe) {
return undefined
}
let link: BaseUIElement
const theme = this.state.layout?.id ?? ""
const basepath = window.location.host
const href = this.state.mapProperties.location.map(
(loc) => {
const subs = {
...loc,
theme: theme,
basepath,
language: Locale.language.data,
}
return Utils.SubstituteKeys(c.href, subs)
},
[this.state.mapProperties.zoom]
)
let img: BaseUIElement = Svg.pop_out_svg()
if (c.icon !== undefined) {
img = new Img(c.icon).SetClass("h-6")
}
let text: Translation
if (c.text === undefined) {
text = Translations.t.general.screenToSmall.Subs({
theme: this.state.layout.title,
})
} else {
text = c.text.Clone()
}
link = new SubtleButton(img, text, {
url: href,
newTab: c.newTab,
})
if (c.requirements?.has("no-welcome-message")) {
link = new Toggle(
undefined,
link,
this.state.featureSwitches.featureSwitchWelcomeMessage
)
}
if (c.requirements?.has("welcome-message")) {
link = new Toggle(
link,
undefined,
this.state.featureSwitches.featureSwitchWelcomeMessage
)
}
return link
}
}

View file

@ -0,0 +1,110 @@
<script lang="ts">
/**
* The FilterView shows the various options to enable/disable a single layer or to only show a subset of the data.
*/
import type FilteredLayer from "../../Models/FilteredLayer"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import ToSvelte from "../Base/ToSvelte.svelte"
import Checkbox from "../Base/Checkbox.svelte"
import FilterConfig from "../../Models/ThemeConfig/FilterConfig"
import type { Writable } from "svelte/store"
import If from "../Base/If.svelte"
import Dropdown from "../Base/Dropdown.svelte"
import { onDestroy } from "svelte"
import { ImmutableStore, Store } from "../../Logic/UIEventSource"
import FilterviewWithFields from "./FilterviewWithFields.svelte"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
export let filteredLayer: FilteredLayer
export let highlightedLayer: Store<string | undefined> = new ImmutableStore(undefined)
export let zoomlevel: Store<number> = new ImmutableStore(22)
let layer: LayerConfig = filteredLayer.layerDef
let isDisplayed: Store<boolean> = filteredLayer.isDisplayed
/**
* Gets a UIEventSource as boolean for the given option, to be used with a checkbox
*/
function getBooleanStateFor(option: FilterConfig): Writable<boolean> {
const state = filteredLayer.appliedFilters.get(option.id)
return state.sync(
(f) => f === 0,
[],
(b) => (b ? 0 : undefined)
)
}
/**
* Gets a UIEventSource as number for the given option, to be used with a dropdown or radiobutton
*/
function getStateFor(option: FilterConfig): Writable<number> {
return filteredLayer.appliedFilters.get(option.id)
}
let mainElem: HTMLElement
$: onDestroy(
highlightedLayer.addCallbackAndRun((highlightedLayer) => {
if (highlightedLayer === filteredLayer.layerDef.id) {
mainElem?.classList?.add("glowing-shadow")
} else {
mainElem?.classList?.remove("glowing-shadow")
}
})
)
</script>
{#if filteredLayer.layerDef.name}
<div bind:this={mainElem} class="mb-1.5">
<label class="no-image-background flex gap-1">
<Checkbox selected={isDisplayed} />
<If condition={filteredLayer.isDisplayed}>
<ToSvelte
construct={() => layer.defaultIcon()?.SetClass("block h-6 w-6 no-image-background")}
/>
<ToSvelte
slot="else"
construct={() =>
layer.defaultIcon()?.SetClass("block h-6 w-6 no-image-background opacity-50")}
/>
</If>
{filteredLayer.layerDef.name}
{#if $zoomlevel < layer.minzoom}
<span class="alert">
<Tr t={Translations.t.general.layerSelection.zoomInToSeeThisLayer} />
</span>
{/if}
</label>
{#if $isDisplayed && filteredLayer.layerDef.filters?.length > 0}
<div id="subfilters" class="ml-4 flex flex-col gap-y-1">
{#each filteredLayer.layerDef.filters as filter}
<div>
<!-- There are three (and a half) modes of filters: a single checkbox, a radio button/dropdown or with searchable fields -->
{#if filter.options.length === 1 && filter.options[0].fields.length === 0}
<label>
<Checkbox selected={getBooleanStateFor(filter)} />
{filter.options[0].question}
</label>
{/if}
{#if filter.options.length === 1 && filter.options[0].fields.length > 0}
<FilterviewWithFields id={filter.id} {filteredLayer} option={filter.options[0]} />
{/if}
{#if filter.options.length > 1}
<Dropdown value={getStateFor(filter)}>
{#each filter.options as option, i}
<option value={i}>
{option.question}
</option>
{/each}
</Dropdown>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}

View file

@ -0,0 +1,61 @@
<script lang="ts">
import FilteredLayer from "../../Models/FilteredLayer"
import type { FilterConfigOption } from "../../Models/ThemeConfig/FilterConfig"
import Locale from "../i18n/Locale"
import ValidatedInput from "../InputElement/ValidatedInput.svelte"
import { UIEventSource } from "../../Logic/UIEventSource"
import { onDestroy } from "svelte"
import { Utils } from "../../Utils"
export let filteredLayer: FilteredLayer
export let option: FilterConfigOption
export let id: string
let parts: ({ message: string } | { subs: string })[]
let language = Locale.language
$: {
const template = option.question.textFor($language)
parts = Utils.splitIntoSubstitutionParts(template)
}
let fieldValues: Record<string, UIEventSource<string>> = {}
let fieldTypes: Record<string, string> = {}
let appliedFilter = <UIEventSource<string>>filteredLayer.appliedFilters.get(id)
let initialState: Record<string, string> = JSON.parse(appliedFilter?.data ?? "{}")
function setFields() {
const properties: Record<string, string> = {}
for (const key in fieldValues) {
const v = fieldValues[key].data
if (v === undefined) {
properties[key] = undefined
} else {
properties[key] = v
}
}
appliedFilter?.setData(FilteredLayer.fieldsToString(properties))
}
for (const field of option.fields) {
// A bit of cheating: the 'parts' will have '}' suffixed for fields
const src = new UIEventSource<string>(initialState[field.name] ?? "")
fieldTypes[field.name] = field.type
fieldValues[field.name] = src
onDestroy(
src.stabilized(200).addCallback(() => {
setFields()
})
)
}
</script>
<div>
{#each parts as part, i}
{#if part.subs}
<!-- This is a field! -->
<span class="mx-1">
<ValidatedInput value={fieldValues[part.subs]} type={fieldTypes[part.subs]} />
</span>
{:else}
{part.message}
{/if}
{/each}
</div>

View file

@ -0,0 +1,143 @@
import { VariableUiElement } from "../Base/VariableUIElement"
import Svg from "../../Svg"
import { UIEventSource } from "../../Logic/UIEventSource"
import GeoLocationHandler from "../../Logic/Actors/GeoLocationHandler"
import Hotkeys from "../Base/Hotkeys"
import Translations from "../i18n/Translations"
import Constants from "../../Models/Constants"
import { MapProperties } from "../../Models/MapProperties"
/**
* Displays an icon depending on the state of the geolocation.
* Will set the 'lock' if clicked twice
*/
export class GeolocationControl extends VariableUiElement {
constructor(geolocationHandler: GeoLocationHandler, state: MapProperties) {
const lastClick = new UIEventSource<Date>(undefined)
lastClick.addCallbackD((date) => {
geolocationHandler.geolocationState.requestMoment.setData(date)
})
const lastClickWithinThreeSecs = lastClick.map((lastClick) => {
if (lastClick === undefined) {
return false
}
const timeDiff = (new Date().getTime() - lastClick.getTime()) / 1000
return timeDiff <= 3
})
const lastRequestWithinTimeout = geolocationHandler.geolocationState.requestMoment.map(
(date) => {
if (date === undefined) {
return false
}
const timeDiff = (new Date().getTime() - date.getTime()) / 1000
return timeDiff <= Constants.zoomToLocationTimeout
}
)
const geolocationState = geolocationHandler?.geolocationState
super(
geolocationState?.permission?.map(
(permission) => {
if (permission === "denied") {
return Svg.location_refused_svg()
}
if (!geolocationState.allowMoving.data) {
return Svg.location_locked_svg()
}
if (geolocationState.currentGPSLocation.data === undefined) {
if (permission === "prompt") {
return Svg.location_empty_svg()
}
// Position not yet found, but permission is either requested or granted: we spin to indicate activity
const icon =
!geolocationHandler.mapHasMoved.data || lastRequestWithinTimeout.data
? Svg.location_svg()
: Svg.location_empty_svg()
return icon
.SetClass("cursor-wait")
.SetStyle("animation: spin 4s linear infinite;")
}
// We have a location, so we show a dot in the center
if (lastClickWithinThreeSecs.data) {
return Svg.location_unlocked_svg()
}
// We have a location, so we show a dot in the center
return Svg.location_svg()
},
[
geolocationState.currentGPSLocation,
geolocationState.allowMoving,
geolocationHandler.mapHasMoved,
lastClickWithinThreeSecs,
lastRequestWithinTimeout,
]
)
)
async function handleClick() {
if (
geolocationState.permission.data !== "granted" &&
geolocationState.currentGPSLocation.data === undefined
) {
lastClick.setData(new Date())
geolocationState.requestMoment.setData(new Date())
await geolocationState.requestPermission()
}
if (geolocationState.allowMoving.data === false) {
// Unlock
geolocationState.allowMoving.setData(true)
return
}
// A location _is_ known! Let's move to this location
const currentLocation = geolocationState.currentGPSLocation.data
if (currentLocation === undefined) {
// No location is known yet, not much we can do
lastClick.setData(new Date())
return
}
const inBounds = state.bounds.data.contains([
currentLocation.longitude,
currentLocation.latitude,
])
geolocationHandler.MoveMapToCurrentLocation()
if (inBounds) {
state.zoom.update((z) => z + 3)
}
if (lastClickWithinThreeSecs.data) {
geolocationState.allowMoving.setData(false)
lastClick.setData(undefined)
return
}
lastClick.setData(new Date())
}
this.onClick(handleClick)
Hotkeys.RegisterHotkey(
{ nomod: "L" },
Translations.t.hotkeyDocumentation.geolocate,
handleClick
)
lastClick.addCallbackAndRunD((_) => {
window.setTimeout(() => {
if (lastClickWithinThreeSecs.data) {
lastClick.ping()
}
}, 500)
})
geolocationHandler.geolocationState.requestMoment.addCallbackAndRunD((_) => {
window.setTimeout(() => {
if (lastRequestWithinTimeout.data) {
geolocationHandler.geolocationState.requestMoment.ping()
}
}, 500)
})
}
}

View file

@ -0,0 +1,116 @@
<script lang="ts">
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.js"
import Translations from "../i18n/Translations"
import Loading from "../Base/Loading.svelte"
import Hotkeys from "../Base/Hotkeys"
import { Geocoding } from "../../Logic/Osm/Geocoding"
import { BBox } from "../../Logic/BBox"
import { GeoIndexedStoreForLayer } from "../../Logic/FeatureSource/Actors/GeoIndexedStore"
import { createEventDispatcher, onDestroy } from "svelte"
export let perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer> | undefined = undefined
export let bounds: UIEventSource<BBox>
export let selectedElement: UIEventSource<Feature> | undefined = undefined
export let selectedLayer: UIEventSource<LayerConfig> | undefined = undefined
export let clearAfterView: boolean = true
let searchContents: string = ""
export let triggerSearch: UIEventSource<any> = new UIEventSource<any>(undefined)
onDestroy(
triggerSearch.addCallback((_) => {
performSearch()
})
)
let isRunning: boolean = false
let inputElement: HTMLInputElement
let feedback: string = undefined
Hotkeys.RegisterHotkey({ ctrl: "F" }, Translations.t.hotkeyDocumentation.selectSearch, () => {
inputElement?.focus()
inputElement?.select()
})
const dispatch = createEventDispatcher<{ searchCompleted; searchIsValid: boolean }>()
$: {
if (!searchContents?.trim()) {
dispatch("searchIsValid", false)
} else {
dispatch("searchIsValid", true)
}
}
async function performSearch() {
try {
isRunning = true
searchContents = searchContents?.trim() ?? ""
if (searchContents === "") {
return
}
const result = await Geocoding.Search(searchContents, bounds.data)
if (result.length == 0) {
feedback = Translations.t.general.search.nothing.txt
return
}
const poi = result[0]
const [lat0, lat1, lon0, lon1] = poi.boundingbox
bounds.set(
new BBox([
[lon0, lat0],
[lon1, lat1],
]).pad(0.01)
)
if (perLayer !== undefined) {
const id = poi.osm_type + "/" + poi.osm_id
const layers = Array.from(perLayer?.values() ?? [])
for (const layer of layers) {
const found = layer.features.data.find((f) => f.properties.id === id)
selectedElement?.setData(found)
selectedLayer?.setData(layer.layer.layerDef)
}
}
if (clearAfterView) {
searchContents = ""
}
dispatch("searchIsValid", false)
dispatch("searchCompleted")
} catch (e) {
console.error(e)
feedback = Translations.t.general.search.error.txt
} finally {
isRunning = false
}
}
</script>
<div class="normal-background flex justify-between rounded-full pl-2">
<form class="w-full">
{#if isRunning}
<Loading>{Translations.t.general.search.searching}</Loading>
{:else if feedback !== undefined}
<div class="alert" on:click={() => (feedback = undefined)}>
{feedback}
</div>
{:else}
<input
type="search"
class="w-full"
bind:this={inputElement}
on:keypress={(keypr) => (keypr.key === "Enter" ? performSearch() : undefined)}
bind:value={searchContents}
placeholder={Translations.t.general.search.search}
/>
{/if}
</form>
<div class="h-6 w-6 self-end" on:click={performSearch}>
<ToSvelte construct={Svg.search_svg} />
</div>
</div>

View file

@ -0,0 +1,50 @@
<script lang="ts">
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { UIEventSource } from "../../Logic/UIEventSource"
import * as themeOverview from "../../assets/generated/theme_overview.json"
import { Utils } from "../../Utils"
import ThemesList from "./ThemesList.svelte"
import Translations from "../i18n/Translations"
import { LayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import LoginToggle from "../Base/LoginToggle.svelte"
export let search: UIEventSource<string>
export let state: { osmConnection: OsmConnection }
export let onMainScreen: boolean = true
const prefix = "mapcomplete-hidden-theme-"
const hiddenThemes: LayoutInformation[] =
(themeOverview["default"] ?? themeOverview)?.filter((layout) => layout.hideFromOverview) ?? []
const userPreferences = state.osmConnection.preferencesHandler.preferences
const t = Translations.t.general.morescreen
let knownThemesId: string[]
$: knownThemesId = Utils.NoNull(
Object.keys($userPreferences)
.filter((key) => key.startsWith(prefix))
.map((key) => key.substring(prefix.length, key.length - "-enabled".length))
)
$: console.log("Known theme ids:", knownThemesId)
$: knownThemes = hiddenThemes.filter((theme) => knownThemesId.includes(theme.id))
</script>
<LoginToggle {state}>
<ThemesList
hideThemes={false}
isCustom={false}
{onMainScreen}
{search}
{state}
themes={knownThemes}
>
<svelte:fragment slot="title">
<h3>{t.previouslyHiddenTitle.toString()}</h3>
<p>
{t.hiddenExplanation.Subs({
hidden_discovered: knownThemes.length.toString(),
total_hidden: hiddenThemes.length.toString(),
})}
</p>
</svelte:fragment>
</ThemesList>
</LoginToggle>

View file

@ -0,0 +1,154 @@
import { VariableUiElement } from "../Base/VariableUIElement"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Table from "../Base/Table"
import Combine from "../Base/Combine"
import { FixedUiElement } from "../Base/FixedUiElement"
import { Utils } from "../../Utils"
import BaseUIElement from "../BaseUIElement"
import Svg from "../../Svg"
export default class Histogram<T> extends VariableUiElement {
private static defaultPalette = [
"#ff5858",
"#ffad48",
"#ffff59",
"#56bd56",
"#63a9ff",
"#9d62d9",
"#fa61fa",
]
constructor(
values: Store<string[]>,
title: string | BaseUIElement,
countTitle: string | BaseUIElement,
options?: {
assignColor?: (t0: string) => string
sortMode?: "name" | "name-rev" | "count" | "count-rev"
}
) {
const sortMode = new UIEventSource<"name" | "name-rev" | "count" | "count-rev">(
options?.sortMode ?? "name"
)
const sortName = new VariableUiElement(
sortMode.map((m) => {
switch (m) {
case "name":
return Svg.up_svg()
case "name-rev":
return Svg.up_svg().SetStyle("transform: rotate(180deg)")
default:
return Svg.circle_svg()
}
})
)
const titleHeader = new Combine([sortName.SetClass("w-4 mr-2"), title])
.SetClass("flex")
.onClick(() => {
if (sortMode.data === "name") {
sortMode.setData("name-rev")
} else {
sortMode.setData("name")
}
})
const sortCount = new VariableUiElement(
sortMode.map((m) => {
switch (m) {
case "count":
return Svg.up_svg()
case "count-rev":
return Svg.up_svg().SetStyle("transform: rotate(180deg)")
default:
return Svg.circle_svg()
}
})
)
const countHeader = new Combine([sortCount.SetClass("w-4 mr-2"), countTitle])
.SetClass("flex")
.onClick(() => {
if (sortMode.data === "count-rev") {
sortMode.setData("count")
} else {
sortMode.setData("count-rev")
}
})
const header = [titleHeader, countHeader]
super(
values.map(
(values) => {
if (values === undefined) {
return undefined
}
values = Utils.NoNull(values)
const counts = new Map<string, number>()
for (const value of values) {
const c = counts.get(value) ?? 0
counts.set(value, c + 1)
}
const keys = Array.from(counts.keys())
switch (sortMode.data) {
case "name":
keys.sort()
break
case "name-rev":
keys.sort().reverse(/*Copy of array, inplace reverse if fine*/)
break
case "count":
keys.sort((k0, k1) => counts.get(k0) - counts.get(k1))
break
case "count-rev":
keys.sort((k0, k1) => counts.get(k1) - counts.get(k0))
break
}
const max = Math.max(...Array.from(counts.values()))
const fallbackColor = (keyValue: string) => {
const index = keys.indexOf(keyValue)
return Histogram.defaultPalette[index % Histogram.defaultPalette.length]
}
let actualAssignColor = undefined
if (options?.assignColor === undefined) {
actualAssignColor = fallbackColor
} else {
actualAssignColor = (keyValue: string) => {
return options.assignColor(keyValue) ?? fallbackColor(keyValue)
}
}
return new Table(
header,
keys.map((key) => [
key,
new Combine([
new Combine([
new FixedUiElement("" + counts.get(key)).SetClass(
"font-bold rounded-full block"
),
])
.SetClass("flex justify-center rounded border border-black")
.SetStyle(
`background: ${actualAssignColor(key)}; width: ${
(100 * counts.get(key)) / max
}%`
),
]).SetClass("block w-full"),
]),
{
contentStyle: keys.map((_) => ["width: 20%"]),
}
).SetClass("w-full zebra-table")
},
[sortMode]
)
)
}
}

View file

@ -0,0 +1,29 @@
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import { FixedUiElement } from "../Base/FixedUiElement"
export default class IndexText extends Combine {
constructor() {
super([
new FixedUiElement(
`<img class="w-12 h-12 sm:h-24 sm:w-24" src="./assets/svg/logo.svg" alt="MapComplete Logo">`
).SetClass("flex-none m-3"),
new Combine([
Translations.t.index.title.SetClass(
"text-2xl tracking-tight font-extrabold text-gray-900 sm:text-5xl md:text-6xl block text-gray-800 xl:inline"
),
Translations.t.index.intro.SetClass(
"mt-3 text-base font-semibold text-gray-500 sm:mt-5 sm:text-lg sm:max-w-xl sm:mx-auto md:mt-5 md:text-xl lg:mx-0"
),
Translations.t.index.pickTheme.SetClass(
"mt-3 text-base sm:mt-5 sm:text-lg sm:max-w-xl sm:mx-auto md:mt-5 md:text-xl lg:mx-0"
),
]).SetClass("flex flex-col sm:text-center lg:text-left m-1 mt-2 md:m-2 md:mt-4"),
])
this.SetClass("flex flex-row")
}
}

View file

@ -0,0 +1,32 @@
<script lang="ts">
/**
* Shows a 'floorSelector' and maps the selected floor onto a global filter
*/
import LayerState from "../../Logic/State/LayerState"
import FloorSelector from "../InputElement/Helpers/FloorSelector.svelte"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
export let layerState: LayerState
export let floors: Store<string[]>
export let zoom: Store<number>
const maxZoom = 16
let selectedFloor: UIEventSource<string> = new UIEventSource<string>(undefined)
selectedFloor.stabilized(5).map(
(floor) => {
if (floors.data === undefined || floors.data.length <= 1 || zoom.data < maxZoom) {
// Only a single floor is visible -> disable the 'level' global filter
// OR we might have zoomed out to much ant want to show all
layerState.setLevelFilter(undefined)
} else {
layerState.setLevelFilter(floor)
}
},
[floors, zoom]
)
</script>
{#if $zoom >= maxZoom}
<FloorSelector {floors} value={selectedFloor} />
{/if}

View file

@ -0,0 +1,29 @@
<script lang="ts">
import Translations from "../i18n/Translations"
import Svg from "../../Svg"
import { Store } from "../../Logic/UIEventSource"
import Tr from "../Base/Tr.svelte"
import ToSvelte from "../Base/ToSvelte.svelte"
/*
A subtleButton which opens mapillary in a new tab at the current location
*/
export let mapProperties: {
readonly zoom: Store<number>
readonly location: Store<{ lon: number; lat: number }>
}
let location = mapProperties.location
let zoom = mapProperties.zoom
let mapillaryLink = `https://www.mapillary.com/app/?focus=map&lat=${$location?.lat ?? 0}&lng=${
$location?.lon ?? 0
}&z=${Math.max(($zoom ?? 2) - 1, 1)}`
</script>
<a class="button flex items-center" href={mapillaryLink} target="_blank">
<ToSvelte construct={() => Svg.mapillary_black_svg().SetClass("w-12 h-12 m-2 mr-4 shrink-0")} />
<div class="flex flex-col">
<Tr t={Translations.t.general.attribution.openMapillary} />
<Tr cls="subtle" t={Translations.t.general.attribution.mapillaryHelp} />
</div>
</a>

View file

@ -0,0 +1,183 @@
import Svg from "../../Svg"
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import LayoutConfig, { LayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import { ImmutableStore, Store } from "../../Logic/UIEventSource"
import UserRelatedState from "../../Logic/State/UserRelatedState"
import { Utils } from "../../Utils"
import themeOverview from "../../assets/generated/theme_overview.json"
import { TextField } from "../Input/TextField"
import Locale from "../i18n/Locale"
import SvelteUIElement from "../Base/SvelteUIElement"
import ThemesList from "./ThemesList.svelte"
import HiddenThemeList from "./HiddenThemeList.svelte"
import UnofficialThemeList from "./UnofficialThemeList.svelte"
export default class MoreScreen extends Combine {
private static readonly officialThemes: LayoutInformation[] = themeOverview
constructor(
state: UserRelatedState & {
layoutToUse?: LayoutConfig
},
onMainScreen: boolean = false
) {
const tr = Translations.t.general.morescreen
const search = new TextField({
placeholder: tr.searchForATheme,
})
search.enterPressed.addCallbackD((searchTerm) => {
searchTerm = searchTerm.toLowerCase()
if (!searchTerm) {
return
}
if (searchTerm === "personal") {
window.location.href = MoreScreen.createUrlFor(
{ id: "personal" },
false,
state
).data
}
if (searchTerm === "bugs" || searchTerm === "issues") {
window.location.href = "https://github.com/pietervdvn/MapComplete/issues"
}
if (searchTerm === "source") {
window.location.href = "https://github.com/pietervdvn/MapComplete"
}
if (searchTerm === "docs") {
window.location.href = "https://github.com/pietervdvn/MapComplete/tree/develop/Docs"
}
if (searchTerm === "osmcha" || searchTerm === "stats") {
window.location.href = Utils.OsmChaLinkFor(7)
}
// Enter pressed -> search the first _official_ matchin theme and open it
const publicTheme = MoreScreen.officialThemes.find(
(th) =>
th.hideFromOverview == false &&
th.id !== "personal" &&
MoreScreen.MatchesLayout(th, searchTerm)
)
if (publicTheme !== undefined) {
window.location.href = MoreScreen.createUrlFor(publicTheme, false, state).data
}
const hiddenTheme = MoreScreen.officialThemes.find(
(th) => th.id !== "personal" && MoreScreen.MatchesLayout(th, searchTerm)
)
if (hiddenTheme !== undefined) {
window.location.href = MoreScreen.createUrlFor(hiddenTheme, false, state).data
}
})
if (onMainScreen) {
search.focus()
document.addEventListener("keydown", function (event) {
if (event.ctrlKey && event.code === "KeyF") {
search.focus()
event.preventDefault()
}
})
}
const searchBar = new Combine([
Svg.search_svg().SetClass("w-8"),
search.SetClass("mr-4 w-full"),
]).SetClass("flex rounded-full border-2 border-black items-center my-2 w-1/2")
super([
new Combine([searchBar]).SetClass("flex justify-center"),
new SvelteUIElement(ThemesList, {
state,
onMainScreen,
search: search.GetValue(),
themes: MoreScreen.officialThemes,
}),
new SvelteUIElement(HiddenThemeList, {
state,
onMainScreen,
search: search.GetValue(),
}),
new SvelteUIElement(UnofficialThemeList, {
state,
onMainScreen,
search: search.GetValue(),
}),
tr.streetcomplete.Clone().SetClass("block text-base mx-10 my-3 mb-10"),
])
}
public static MatchesLayout(
layout: {
id: string
title: any
shortDescription: any
keywords?: any[]
},
search: string
): boolean {
if (search === undefined) {
return true
}
search = search.toLocaleLowerCase()
if (search.length > 3 && layout.id.toLowerCase().indexOf(search) >= 0) {
return true
}
if (layout.id === "personal") {
return false
}
const entitiesToSearch = [layout.shortDescription, layout.title, ...(layout.keywords ?? [])]
for (const entity of entitiesToSearch) {
if (entity === undefined) {
continue
}
const term = entity["*"] ?? entity[Locale.language.data]
if (term?.toLowerCase()?.indexOf(search) >= 0) {
return true
}
}
return false
}
private static createUrlFor(
layout: { id: string; definition?: string },
isCustom: boolean,
state?: { layoutToUse?: { id } }
): Store<string> {
if (layout === undefined) {
return undefined
}
if (layout.id === undefined) {
console.error("ID is undefined for layout", layout)
return undefined
}
if (layout.id === state?.layoutToUse?.id) {
return undefined
}
let path = window.location.pathname
// Path starts with a '/' and contains everything, e.g. '/dir/dir/page.html'
path = path.substr(0, path.lastIndexOf("/"))
// Path will now contain '/dir/dir', or empty string in case of nothing
if (path === "") {
path = "."
}
let linkPrefix = `${path}/${layout.id.toLowerCase()}.html?`
if (location.hostname === "localhost" || location.hostname === "127.0.0.1") {
linkPrefix = `${path}/theme.html?layout=${layout.id}&`
}
if (isCustom) {
linkPrefix = `${path}/theme.html?userlayout=${layout.id}&`
}
let hash = ""
if (layout.definition !== undefined) {
hash = "#" + btoa(JSON.stringify(layout.definition))
}
return new ImmutableStore<string>(`${linkPrefix}${hash}`)
}
}

View file

@ -0,0 +1,117 @@
<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"
/**
* 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 }
export let snapToLayers: string[] | undefined
export let targetLayer: LayerConfig
export let maxSnapDistance: number = undefined
export let snappedTo: UIEventSource<string | undefined>
export let value: UIEventSource<{ lon: number; lat: number }>
if (value.data === undefined) {
value.setData(coordinate)
}
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),
}
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
{map}
on:click={(data) => dispatch("click", data)}
mapProperties={initialMapProperties}
value={preciseLocation}
initialCoordinate={coordinate}
maxDistanceInMeters="50"
/>

View file

@ -0,0 +1,34 @@
<script context="module" lang="ts">
export interface Theme {
id: string
icon: string
title: any
shortDescription: any
definition?: any
mustHaveLanguage?: boolean
hideFromOverview: boolean
keywords?: any[]
}
</script>
<script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource"
import Svg from "../../Svg"
import ToSvelte from "../Base/ToSvelte.svelte"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
export let search: UIEventSource<string>
const t = Translations.t.general.morescreen
</script>
<div class="w-full">
<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} />
</button>
</div>
</div>

View file

@ -0,0 +1,21 @@
<script lang="ts">
/**
* A mapcontrol button which allows the user to select a different background.
* Even though the componenet is very small, it gets it's own class as it is often reused
*/
import { Square3Stack3dIcon } from "@babeard/svelte-heroicons/solid"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import Translations from "../i18n/Translations"
import MapControlButton from "../Base/MapControlButton.svelte"
import Tr from "../Base/Tr.svelte"
export let state: SpecialVisualizationState
export let hideTooltip = false
</script>
<MapControlButton on:click={() => state.guistate.backgroundLayerSelectionIsOpened.setData(true)}>
<Square3Stack3dIcon class="h-6 w-6" />
{#if !hideTooltip}
<Tr cls="mx-2" t={Translations.t.general.backgroundSwitch} />
{/if}
</MapControlButton>

View file

@ -0,0 +1,32 @@
<script lang="ts">
import { Store } from "../../Logic/UIEventSource"
import { PencilIcon } from "@babeard/svelte-heroicons/solid"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
export let mapProperties: { location: Store<{ lon: number; lat: number }>; zoom: Store<number> }
let location = mapProperties.location
let zoom = mapProperties.zoom
export let objectId: undefined | string = undefined
let elementSelect = ""
if (objectId !== undefined) {
const parts = objectId?.split("/")
const tp = parts[0]
if (
parts.length === 2 &&
!isNaN(Number(parts[1])) &&
(tp === "node" || tp === "way" || tp === "relation")
) {
elementSelect = "&" + tp + "=" + parts[1]
}
}
const idLink = `https://www.openstreetmap.org/edit?editor=id${elementSelect}#map=${$zoom ?? 0}/${
$location?.lat ?? 0
}/${$location?.lon ?? 0}`
</script>
<a class="button flex items-center" target="_blank" href={idLink}>
<PencilIcon class="h-12 w-12 p-2 pr-4" />
<Tr t={Translations.t.general.attribution.editId} />
</a>

View file

@ -0,0 +1,59 @@
import Combine from "../Base/Combine"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { BBox } from "../../Logic/BBox"
import Translations from "../i18n/Translations"
import { VariableUiElement } from "../Base/VariableUIElement"
import Toggle from "../Input/Toggle"
import { SubtleButton } from "../Base/SubtleButton"
import Svg from "../../Svg"
import { Utils } from "../../Utils"
import Constants from "../../Models/Constants"
export class OpenJosm extends Combine {
constructor(osmConnection: OsmConnection, bounds: Store<BBox>, iconStyle?: string) {
const t = Translations.t.general.attribution
const josmState = new UIEventSource<string>(undefined)
// Reset after 15s
josmState.stabilized(15000).addCallbackD((_) => josmState.setData(undefined))
const stateIndication = new VariableUiElement(
josmState.map((state) => {
if (state === undefined) {
return undefined
}
state = state.toUpperCase()
if (state === "OK") {
return t.josmOpened.SetClass("thanks")
}
return t.josmNotOpened.SetClass("alert")
})
)
const toggle = new Toggle(
new SubtleButton(Svg.josm_logo_svg().SetStyle(iconStyle), t.editJosm)
.onClick(() => {
const bbox = bounds.data
if (bbox === undefined) {
return
}
const top = bbox.getNorth()
const bottom = bbox.getSouth()
const right = bbox.getEast()
const left = bbox.getWest()
const josmLink = `http://127.0.0.1:8111/load_and_zoom?left=${left}&right=${right}&top=${top}&bottom=${bottom}`
Utils.download(josmLink)
.then((answer) => josmState.setData(answer.replace(/\n/g, "").trim()))
.catch((_) => josmState.setData("ERROR"))
})
.SetClass("w-full"),
undefined,
osmConnection.userDetails.map(
(ud) => ud.loggedIn && ud.csCount >= Constants.userJourney.historyLinkVisible
)
)
super([stateIndication, toggle])
}
}

View file

@ -0,0 +1,50 @@
<script lang="ts">
/**
* The OverlayToggle shows a single toggle to enable or disable an overlay
*/
import Checkbox from "../Base/Checkbox.svelte"
import { onDestroy } from "svelte"
import { UIEventSource } from "../../Logic/UIEventSource"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
import { Translation } from "../i18n/Translation"
import type { RasterLayerProperties } from "../../Models/RasterLayerProperties"
export let layerproperties: RasterLayerProperties
export let state: { isDisplayed: UIEventSource<boolean> }
export let zoomlevel: UIEventSource<number>
export let highlightedLayer: UIEventSource<string> | undefined
let isDisplayed: boolean = state.isDisplayed.data
onDestroy(
state.isDisplayed.addCallbackAndRunD((d) => {
isDisplayed = d
return false
})
)
let mainElem: HTMLElement
$: onDestroy(
highlightedLayer.addCallbackAndRun((highlightedLayer) => {
if (highlightedLayer === layerproperties.id) {
mainElem?.classList?.add("glowing-shadow")
} else {
mainElem?.classList?.remove("glowing-shadow")
}
})
)
</script>
{#if layerproperties.name}
<div bind:this={mainElem}>
<label class="flex gap-1">
<Checkbox selected={state.isDisplayed} />
<Tr t={new Translation(layerproperties.name)} />
{#if $zoomlevel < layerproperties.min_zoom}
<span class="alert">
<Tr t={Translations.t.general.layerSelection.zoomInToSeeThisLayer} />
</span>
{/if}
</label>
</div>
{/if}

View file

@ -0,0 +1,127 @@
import { VariableUiElement } from "../Base/VariableUIElement"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import PlantNet from "../../Logic/Web/PlantNet"
import Loading from "../Base/Loading"
import Wikidata from "../../Logic/Web/Wikidata"
import WikidataPreviewBox from "../Wikipedia/WikidataPreviewBox"
import { Button } from "../Base/Button"
import Combine from "../Base/Combine"
import Title from "../Base/Title"
import Translations from "../i18n/Translations"
import List from "../Base/List"
import Svg from "../../Svg"
export default class PlantNetSpeciesSearch extends VariableUiElement {
/***
* Given images, queries plantnet to search a species matching those images.
* A list of species will be presented to the user, after which they can confirm an item.
* The wikidata-url is returned in the callback when the user selects one
*/
constructor(images: Store<string[]>, onConfirm: (wikidataUrl: string) => Promise<void>) {
const t = Translations.t.plantDetection
super(
images
.bind((images) => {
if (images.length === 0) {
return null
}
return UIEventSource.FromPromiseWithErr(PlantNet.query(images.slice(0, 5)))
})
.map((result) => {
if (images.data.length === 0) {
return new Combine([
t.takeImages,
t.howTo.intro,
new List([t.howTo.li0, t.howTo.li1, t.howTo.li2, t.howTo.li3]),
]).SetClass("flex flex-col")
}
if (result === undefined) {
return new Loading(t.querying.Subs(images.data))
}
if (result["error"] !== undefined) {
return t.error.Subs(<any>result).SetClass("alert")
}
console.log(result)
const success = result["success"]
const selectedSpecies = new UIEventSource<string>(undefined)
const speciesInformation = success.results
.filter((species) => species.score >= 0.005)
.map((species) => {
const wikidata = UIEventSource.FromPromise(
Wikidata.Sparql<{ species }>(
["?species", "?speciesLabel"],
['?species wdt:P846 "' + species.gbif.id + '"']
)
)
const confirmButton = new Button(t.seeInfo, async () => {
await selectedSpecies.setData(wikidata.data[0].species?.value)
}).SetClass("btn")
const match = t.matchPercentage
.Subs({ match: Math.round(species.score * 100) })
.SetClass("font-bold")
const extraItems = new Combine([match, confirmButton]).SetClass(
"flex flex-col"
)
return new WikidataPreviewBox(
wikidata.map((wd) =>
wd == undefined ? undefined : wd[0]?.species?.value
),
{
whileLoading: new Loading(
t.loadingWikidata.Subs({
species: species.species.scientificNameWithoutAuthor,
})
),
extraItems: [new Combine([extraItems])],
imageStyle: "max-width: 8rem; width: unset; height: 8rem",
}
).SetClass("border-2 border-subtle rounded-xl block mb-2")
})
const plantOverview = new Combine([
new Title(t.overviewTitle),
t.overviewIntro,
t.overviewVerify.SetClass("font-bold"),
...speciesInformation,
]).SetClass("flex flex-col")
return new VariableUiElement(
selectedSpecies.map((wikidataSpecies) => {
if (wikidataSpecies === undefined) {
return plantOverview
}
return new Combine([
new Button(
new Combine([
Svg.back_svg().SetClass(
"w-6 mr-1 bg-white rounded-full p-1"
),
t.back,
]).SetClass("flex"),
() => {
selectedSpecies.setData(undefined)
}
).SetClass("btn btn-secondary"),
new Button(
new Combine([
Svg.confirm_svg().SetClass("w-6 mr-1"),
t.confirm,
]).SetClass("flex"),
() => {
onConfirm(wikidataSpecies)
}
).SetClass("btn"),
]).SetClass("flex justify-between")
})
)
})
)
}
}

View file

@ -0,0 +1,25 @@
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import Title from "../Base/Title"
export default class PrivacyPolicy extends Combine {
constructor() {
const t = Translations.t.privacy
super([
new Title(t.title, 2),
t.intro,
new Title(t.trackingTitle),
t.tracking,
new Title(t.geodataTitle),
t.geodata,
new Title(t.editingTitle),
t.editing,
new Title(t.miscCookiesTitle),
t.miscCookies,
new Title(t.whileYoureHere),
t.surveillance,
])
this.SetClass("link-underline")
}
}

View file

@ -0,0 +1,74 @@
<script lang="ts">
import type { Feature } from "geojson"
import { UIEventSource } from "../../Logic/UIEventSource"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import TagRenderingAnswer from "../Popup/TagRendering/TagRenderingAnswer.svelte"
import { onDestroy } from "svelte"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
import { XCircleIcon } from "@rgossiaux/svelte-heroicons/solid"
export let state: SpecialVisualizationState
export let layer: LayerConfig
export let selectedElement: Feature
export let tags: UIEventSource<Record<string, string>>
let _tags: Record<string, string>
onDestroy(
tags.addCallbackAndRun((tags) => {
_tags = tags
})
)
let _metatags: Record<string, string>
onDestroy(
state.userRelatedState.preferencesAsTags.addCallbackAndRun((tags) => {
_metatags = tags
})
)
</script>
{#if _tags._deleted === "yes"}
<Tr t={Translations.t.delete.isDeleted} />
{:else}
<div
class="low-interaction flex items-center justify-between border-b-2 border-black px-3 drop-shadow-md"
>
<div class="flex flex-col">
<!-- Title element-->
<h3>
<TagRenderingAnswer config={layer.title} {selectedElement} {state} {tags} {layer} />
</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"
>
{#each layer.titleIcons as titleIconConfig}
{#if (titleIconConfig.condition?.matchesProperties(_tags) ?? true) && (titleIconConfig.metacondition?.matchesProperties( { ..._metatags, ..._tags } ) ?? true) && titleIconConfig.IsKnown(_tags)}
<div class="flex h-8 w-8 items-center">
<TagRenderingAnswer
config={titleIconConfig}
{tags}
{selectedElement}
{state}
{layer}
extraClasses="h-full justify-center"
/>
</div>
{/if}
{/each}
</div>
</div>
<XCircleIcon
class="h-8 w-8 cursor-pointer"
on:click={() => state.selectedElement.setData(undefined)}
/>
</div>
{/if}
<style>
:global(.title-icons a) {
display: block !important;
}
</style>

View file

@ -0,0 +1,54 @@
<script lang="ts">
import type { Feature } from "geojson"
import { UIEventSource } from "../../Logic/UIEventSource"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import TagRenderingEditable from "../Popup/TagRendering/TagRenderingEditable.svelte"
import { onDestroy } from "svelte"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
export let state: SpecialVisualizationState
export let layer: LayerConfig
export let selectedElement: Feature
export let tags: UIEventSource<Record<string, string>>
export let highlightedRendering: UIEventSource<string> = undefined
let _tags: Record<string, string>
onDestroy(
tags.addCallbackAndRun((tags) => {
_tags = tags
})
)
let _metatags: Record<string, string>
onDestroy(
state.userRelatedState.preferencesAsTags.addCallbackAndRun((tags) => {
_metatags = tags
})
)
</script>
{#if _tags._deleted === "yes"}
<Tr t={Translations.t.delete.isDeleted} />
<button class="w-full" on:click={() => state.selectedElement.setData(undefined)}>
<Tr t={Translations.t.general.returnToTheMap} />
</button>
{:else}
<div class="flex flex-col gap-y-2 overflow-y-auto p-1 px-2">
{#each layer.tagRenderings as config (config.id)}
{#if (config.condition === undefined || config.condition.matchesProperties(_tags)) && (config.metacondition === undefined || config.metacondition.matchesProperties( { ..._tags, ..._metatags } ))}
{#if config.IsKnown(_tags)}
<TagRenderingEditable
{tags}
{config}
{state}
{selectedElement}
{layer}
{highlightedRendering}
/>
{/if}
{/if}
{/each}
</div>
{/if}

View file

@ -0,0 +1,256 @@
import { VariableUiElement } from "../Base/VariableUIElement"
import { Translation } from "../i18n/Translation"
import Svg from "../../Svg"
import Combine from "../Base/Combine"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { Utils } from "../../Utils"
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { InputElement } from "../Input/InputElement"
import { CheckBox } from "../Input/Checkboxes"
import { SubtleButton } from "../Base/SubtleButton"
import LZString from "lz-string"
import { SpecialVisualizationState } from "../SpecialVisualization"
export class ShareScreen extends Combine {
constructor(state: SpecialVisualizationState) {
const layout = state?.layout
const tr = Translations.t.general.sharescreen
const optionCheckboxes: InputElement<boolean>[] = []
const optionParts: Store<string>[] = []
const includeLocation = new CheckBox(tr.fsIncludeCurrentLocation, true)
optionCheckboxes.push(includeLocation)
const currentLocation = state.mapProperties.location
const zoom = state.mapProperties.zoom
optionParts.push(
includeLocation.GetValue().map(
(includeL) => {
if (currentLocation === undefined) {
return null
}
if (includeL) {
return [
["z", zoom.data],
["lat", currentLocation.data?.lat],
["lon", currentLocation.data?.lon],
]
.filter((p) => p[1] !== undefined)
.map((p) => p[0] + "=" + p[1])
.join("&")
} else {
return null
}
},
[currentLocation, zoom]
)
)
function fLayerToParam(flayer: {
isDisplayed: UIEventSource<boolean>
layerDef: LayerConfig
}) {
if (flayer.isDisplayed.data) {
return null // Being displayed is the default
}
return "layer-" + flayer.layerDef.id + "=" + flayer.isDisplayed.data
}
const currentLayer: Store<
{ id: string; name: string | Record<string, string> } | undefined
> = state.mapProperties.rasterLayer.map((l) => l?.properties)
const currentBackground = new VariableUiElement(
currentLayer.map((layer) => {
return tr.fsIncludeCurrentBackgroundMap.Subs({ name: layer?.name ?? "" })
})
)
const includeCurrentBackground = new CheckBox(currentBackground, true)
optionCheckboxes.push(includeCurrentBackground)
optionParts.push(
includeCurrentBackground.GetValue().map(
(includeBG) => {
if (includeBG) {
return "background=" + currentLayer.data?.id
} else {
return null
}
},
[currentLayer]
)
)
const includeLayerChoices = new CheckBox(tr.fsIncludeCurrentLayers, true)
optionCheckboxes.push(includeLayerChoices)
optionParts.push(
includeLayerChoices.GetValue().map(
(includeLayerSelection) => {
if (includeLayerSelection) {
return Utils.NoNull(
Array.from(state.layerState.filteredLayers.values()).map(fLayerToParam)
).join("&")
} else {
return null
}
},
Array.from(state.layerState.filteredLayers.values()).map(
(flayer) => flayer.isDisplayed
)
)
)
const switches = [
{ urlName: "fs-userbadge", human: tr.fsUserbadge },
{ urlName: "fs-search", human: tr.fsSearch },
{ urlName: "fs-welcome-message", human: tr.fsWelcomeMessage },
{ urlName: "fs-layers", human: tr.fsLayers },
{ urlName: "fs-add-new", human: tr.fsAddNew },
{ urlName: "fs-geolocation", human: tr.fsGeolocation },
]
for (const swtch of switches) {
const checkbox = new CheckBox(Translations.W(swtch.human))
optionCheckboxes.push(checkbox)
optionParts.push(
checkbox.GetValue().map((isEn) => {
if (isEn) {
return null
} else {
return `${swtch.urlName}=false`
}
})
)
}
if (layout.definitionRaw !== undefined) {
optionParts.push(new UIEventSource("userlayout=" + (layout.definedAtUrl ?? layout.id)))
}
const options = new Combine(optionCheckboxes).SetClass("flex flex-col")
const url = (currentLocation ?? new UIEventSource(undefined)).map(() => {
const host = window.location.host
let path = window.location.pathname
path = path.substr(0, path.lastIndexOf("/"))
let id = layout.id.toLowerCase()
if (layout.definitionRaw !== undefined) {
id = "theme.html"
}
let literalText = `https://${host}${path}/${id}`
let hash = ""
if (layout.definedAtUrl === undefined && layout.definitionRaw !== undefined) {
hash = "#" + LZString.compressToBase64(Utils.MinifyJSON(layout.definitionRaw))
}
const parts = Utils.NoEmpty(
Utils.NoNull(optionParts.map((eventSource) => eventSource.data))
)
if (parts.length === 0) {
return literalText + hash
}
return literalText + "?" + parts.join("&") + hash
}, optionParts)
const iframeCode = new VariableUiElement(
url.map((url) => {
return `<span class='literal-code iframe-code-block'>
&lt;iframe src="${url}" allow="geolocation" width="100%" height="100%" style="min-width: 250px; min-height: 250px" title="${
layout.title?.txt ?? "MapComplete"
} with MapComplete"&gt;&lt;/iframe&gt
</span>`
})
)
const linkStatus = new UIEventSource<string | Translation>("")
const link = new VariableUiElement(
url.map(
(url) =>
`<input type="text" value=" ${url}" id="code-link--copyable" style="width:90%">`
)
).onClick(async () => {
const shareData = {
title: Translations.W(layout.title)?.ConstructElement().textContent ?? "",
text: Translations.W(layout.description)?.ConstructElement().textContent ?? "",
url: url.data,
}
function rejected() {
const copyText = document.getElementById("code-link--copyable")
// @ts-ignore
copyText.select()
// @ts-ignore
copyText.setSelectionRange(0, 99999) /*For mobile devices*/
document.execCommand("copy")
const copied = tr.copiedToClipboard.Clone()
copied.SetClass("thanks")
linkStatus.setData(copied)
}
try {
navigator
.share(shareData)
.then(() => {
const thx = tr.thanksForSharing.Clone()
thx.SetClass("thanks")
linkStatus.setData(thx)
}, rejected)
.catch(rejected)
} catch (err) {
rejected()
}
})
let downloadThemeConfig: BaseUIElement = undefined
if (layout.definitionRaw !== undefined) {
const downloadThemeConfigAsJson = new SubtleButton(
Svg.download_svg(),
new Combine([tr.downloadCustomTheme, tr.downloadCustomThemeHelp.SetClass("subtle")])
.onClick(() => {
Utils.offerContentsAsDownloadableFile(
layout.definitionRaw,
layout.id + ".mapcomplete-theme-definition.json",
{
mimetype: "application/json",
}
)
})
.SetClass("flex flex-col")
)
let editThemeConfig: BaseUIElement = undefined
if (layout.definedAtUrl === undefined) {
const patchedDefinition = JSON.parse(layout.definitionRaw)
patchedDefinition["language"] = Object.keys(patchedDefinition.title)
editThemeConfig = new SubtleButton(
Svg.pencil_svg(),
"Edit this theme on the custom theme generator",
{
url: `https://pietervdvn.github.io/mc/legacy/070/customGenerator.html#${btoa(
JSON.stringify(patchedDefinition)
)}`,
}
)
}
downloadThemeConfig = new Combine([
downloadThemeConfigAsJson,
editThemeConfig,
]).SetClass("flex flex-col")
}
super([
tr.intro,
link,
new VariableUiElement(linkStatus),
downloadThemeConfig,
tr.addToHomeScreen,
tr.embedIntro,
options,
iframeCode,
])
this.SetClass("flex flex-col link-underline")
}
}

View file

@ -0,0 +1,21 @@
/**
* Asks to add a feature at the last clicked location, at least if zoom is sufficient
*/
import BaseUIElement from "../BaseUIElement"
import PresetConfig from "../../Models/ThemeConfig/PresetConfig"
import FilteredLayer from "../../Models/FilteredLayer"
/*
* The SimpleAddUI is a single panel, which can have multiple states:
* - A list of presets which can be added by the user
* - A 'confirm-selection' button (or alternatively: please enable the layer)
* - A 'something is wrong - please soom in further'
* - A 'read your unread messages before adding a point'
*/
export interface PresetInfo extends PresetConfig {
name: string | BaseUIElement
icon: () => BaseUIElement
layerToAddTo: FilteredLayer
boundsFactor?: 0.25 | number
}

View file

@ -0,0 +1,41 @@
<script lang="ts">
import ThemeViewState from "../../Models/ThemeViewState"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
import Loading from "../Base/Loading.svelte"
export let state: ThemeViewState
/**
* Gives the contributor some feedback based on the current state:
* - is data loading?
* - Is all data hidden due to filters?
* - Is no data in view?
*/
let dataIsLoading = state.dataIsLoading
let currentState = state.hasDataInView
currentState.data === ""
const t = Translations.t.centerMessage
</script>
{#if $currentState === "has-visible-features"}
<!-- don't show anything -->
{:else if $currentState === "zoom-to-low"}
<div class="alert w-fit p-4">
<Tr t={t.zoomIn} />
</div>
{:else if $currentState === "all-filtered-away"}
<div class="alert w-fit p-4">
<Tr t={t.allFilteredAway} />
</div>
{:else if $dataIsLoading}
<div class="alert w-fit p-4">
<Loading>
<Tr t={Translations.t.centerMessage.loadingData} />
</Loading>
</div>
{:else if $currentState === "no-data"}
<div class="alert w-fit p-4">
<Tr t={t.noData} />
</div>
{/if}

View file

@ -0,0 +1,55 @@
import { VariableUiElement } from "../Base/VariableUIElement"
import Loading from "../Base/Loading"
import Title from "../Base/Title"
import TagRenderingChart from "./TagRenderingChart"
import Combine from "../Base/Combine"
import Locale from "../i18n/Locale"
import { FeatureSourceForLayer } from "../../Logic/FeatureSource/FeatureSource"
import BaseUIElement from "../BaseUIElement"
export default class StatisticsForLayerPanel extends VariableUiElement {
constructor(elementsInview: FeatureSourceForLayer) {
const layer = elementsInview.layer.layerDef
super(
elementsInview.features.stabilized(1000).map(
(features) => {
if (features === undefined) {
return new Loading("Loading data")
}
if (features.length === 0) {
return "No elements in view"
}
const els: BaseUIElement[] = []
const featuresForLayer = features
if (featuresForLayer.length === 0) {
return
}
els.push(new Title(layer.name.Clone(), 1).SetClass("mt-8"))
const layerStats = []
for (const tagRendering of layer?.tagRenderings ?? []) {
const chart = new TagRenderingChart(featuresForLayer, tagRendering, {
chartclasses: "w-full",
chartstyle: "height: 60rem",
includeTitle: false,
})
const title = new Title(
tagRendering.question?.Clone() ?? tagRendering.id,
4
).SetClass("mt-8")
if (!chart.HasClass("hidden")) {
layerStats.push(
new Combine([title, chart]).SetClass(
"flex flex-col w-full lg:w-1/3"
)
)
}
}
els.push(new Combine(layerStats).SetClass("flex flex-wrap"))
return new Combine(els)
},
[Locale.language]
)
)
}
}

View file

@ -0,0 +1,381 @@
import ChartJs from "../Base/ChartJs"
import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig"
import { ChartConfiguration } from "chart.js"
import Combine from "../Base/Combine"
import { TagUtils } from "../../Logic/Tags/TagUtils"
import { Utils } from "../../Utils"
import { OsmFeature } from "../../Models/OsmFeature"
export interface TagRenderingChartOptions {
groupToOtherCutoff?: 3 | number
sort?: boolean
}
export class StackedRenderingChart extends ChartJs {
constructor(
tr: TagRenderingConfig,
features: (OsmFeature & { properties: { date: string } })[],
options?: {
period: "day" | "month"
groupToOtherCutoff?: 3 | number
}
) {
const { labels, data } = TagRenderingChart.extractDataAndLabels(tr, features, {
sort: true,
groupToOtherCutoff: options?.groupToOtherCutoff,
})
if (labels === undefined || data === undefined) {
console.error(
"Could not extract data and labels for ",
tr,
" with features",
features,
": no labels or no data"
)
throw "No labels or data given..."
}
// labels: ["cyclofix", "buurtnatuur", ...]; data : [ ["cyclofix-changeset", "cyclofix-changeset", ...], ["buurtnatuur-cs", "buurtnatuur-cs"], ... ]
for (let i = labels.length; i >= 0; i--) {
if (data[i]?.length != 0) {
continue
}
data.splice(i, 1)
labels.splice(i, 1)
}
const datasets: {
label: string /*themename*/
data: number[] /*counts per day*/
backgroundColor: string
}[] = []
const allDays = StackedRenderingChart.getAllDays(features)
let trimmedDays = allDays.map((d) => d.substr(0, 10))
if (options?.period === "month") {
trimmedDays = trimmedDays.map((d) => d.substr(0, 7))
}
trimmedDays = Utils.Dedup(trimmedDays)
for (let i = 0; i < labels.length; i++) {
const label = labels[i]
const changesetsForTheme = data[i]
const perDay: Record<string, OsmFeature[]> = {}
for (const changeset of changesetsForTheme) {
const csDate = new Date(changeset.properties.date)
Utils.SetMidnight(csDate)
let str = csDate.toISOString()
str = str.substr(0, 10)
if (options?.period === "month") {
str = str.substr(0, 7)
}
if (perDay[str] === undefined) {
perDay[str] = [changeset]
} else {
perDay[str].push(changeset)
}
}
const countsPerDay: number[] = []
for (let i = 0; i < trimmedDays.length; i++) {
const day = trimmedDays[i]
countsPerDay[i] = perDay[day]?.length ?? 0
}
let backgroundColor =
TagRenderingChart.borderColors[i % TagRenderingChart.borderColors.length]
if (label === "Unknown") {
backgroundColor = TagRenderingChart.unkownBorderColor
}
if (label === "Other") {
backgroundColor = TagRenderingChart.otherBorderColor
}
datasets.push({
data: countsPerDay,
backgroundColor,
label,
})
}
const perDayData = {
labels: trimmedDays,
datasets,
}
const config = <ChartConfiguration>{
type: "bar",
data: perDayData,
options: {
responsive: true,
legend: {
display: false,
},
scales: {
x: {
stacked: true,
},
y: {
stacked: true,
},
},
},
}
super(config)
}
public static getAllDays(
features: (OsmFeature & { properties: { date: string } })[]
): string[] {
let earliest: Date = undefined
let latest: Date = undefined
let allDates = new Set<string>()
features.forEach((value) => {
const d = new Date(value.properties.date)
Utils.SetMidnight(d)
if (earliest === undefined) {
earliest = d
} else if (d < earliest) {
earliest = d
}
if (latest === undefined) {
latest = d
} else if (d > latest) {
latest = d
}
allDates.add(d.toISOString())
})
while (earliest < latest) {
earliest.setDate(earliest.getDate() + 1)
allDates.add(earliest.toISOString())
}
const days = Array.from(allDates)
days.sort()
return days
}
}
export default class TagRenderingChart extends Combine {
public static readonly unkownColor = "rgba(128, 128, 128, 0.2)"
public static readonly unkownBorderColor = "rgba(128, 128, 128, 0.2)"
public static readonly otherColor = "rgba(128, 128, 128, 0.2)"
public static readonly otherBorderColor = "rgba(128, 128, 255)"
public static readonly notApplicableColor = "rgba(128, 128, 128, 0.2)"
public static readonly notApplicableBorderColor = "rgba(255, 0, 0)"
public static readonly backgroundColors = [
"rgba(255, 99, 132, 0.2)",
"rgba(54, 162, 235, 0.2)",
"rgba(255, 206, 86, 0.2)",
"rgba(75, 192, 192, 0.2)",
"rgba(153, 102, 255, 0.2)",
"rgba(255, 159, 64, 0.2)",
]
public static readonly borderColors = [
"rgba(255, 99, 132, 1)",
"rgba(54, 162, 235, 1)",
"rgba(255, 206, 86, 1)",
"rgba(75, 192, 192, 1)",
"rgba(153, 102, 255, 1)",
"rgba(255, 159, 64, 1)",
]
/**
* Creates a chart about this tagRendering for the given data
*/
constructor(
features: { properties: Record<string, string> }[],
tagRendering: TagRenderingConfig,
options?: TagRenderingChartOptions & {
chartclasses?: string
chartstyle?: string
includeTitle?: boolean
chartType?: "pie" | "bar" | "doughnut"
}
) {
if (tagRendering.mappings?.length === 0 && tagRendering.freeform?.key === undefined) {
super([])
this.SetClass("hidden")
return
}
const { labels, data } = TagRenderingChart.extractDataAndLabels(
tagRendering,
features,
options
)
if (labels === undefined || data === undefined) {
super([])
this.SetClass("hidden")
return
}
const borderColor = [
TagRenderingChart.unkownBorderColor,
TagRenderingChart.otherBorderColor,
TagRenderingChart.notApplicableBorderColor,
]
const backgroundColor = [
TagRenderingChart.unkownColor,
TagRenderingChart.otherColor,
TagRenderingChart.notApplicableColor,
]
while (borderColor.length < data.length) {
borderColor.push(...TagRenderingChart.borderColors)
backgroundColor.push(...TagRenderingChart.backgroundColors)
}
for (let i = data.length; i >= 0; i--) {
if (data[i]?.length === 0) {
labels.splice(i, 1)
data.splice(i, 1)
borderColor.splice(i, 1)
backgroundColor.splice(i, 1)
}
}
let barchartMode = tagRendering.multiAnswer
if (labels.length > 9) {
barchartMode = true
}
const config = <ChartConfiguration>{
type: options.chartType ?? (barchartMode ? "bar" : "doughnut"),
data: {
labels,
datasets: [
{
data: data.map((l) => l.length),
backgroundColor,
borderColor,
borderWidth: 1,
label: undefined,
},
],
},
options: {
plugins: {
legend: {
display: !barchartMode,
},
},
},
}
const chart = new ChartJs(config).SetClass(options?.chartclasses ?? "w-32 h-32")
if (options.chartstyle !== undefined) {
chart.SetStyle(options.chartstyle)
}
super([
options?.includeTitle ? tagRendering.question.Clone() ?? tagRendering.id : undefined,
chart,
])
this.SetClass("block")
}
public static extractDataAndLabels<T extends { properties: Record<string, string> }>(
tagRendering: TagRenderingConfig,
features: T[],
options?: TagRenderingChartOptions
): { labels: string[]; data: T[][] } {
const mappings = tagRendering.mappings ?? []
options = options ?? {}
let unknownCount: T[] = []
const categoryCounts: T[][] = mappings.map((_) => [])
const otherCounts: Record<string, T[]> = {}
let notApplicable: T[] = []
for (const feature of features) {
const props = feature.properties
if (
tagRendering.condition !== undefined &&
!tagRendering.condition.matchesProperties(props)
) {
notApplicable.push(feature)
continue
}
if (!tagRendering.IsKnown(props)) {
unknownCount.push(feature)
continue
}
let foundMatchingMapping = false
if (!tagRendering.multiAnswer) {
for (let i = 0; i < mappings.length; i++) {
const mapping = mappings[i]
if (mapping.if.matchesProperties(props)) {
categoryCounts[i].push(feature)
foundMatchingMapping = true
break
}
}
} else {
for (let i = 0; i < mappings.length; i++) {
const mapping = mappings[i]
if (TagUtils.MatchesMultiAnswer(mapping.if, props)) {
categoryCounts[i].push(feature)
foundMatchingMapping = true
}
}
}
if (!foundMatchingMapping) {
if (
tagRendering.freeform?.key !== undefined &&
props[tagRendering.freeform.key] !== undefined
) {
const otherValue = props[tagRendering.freeform.key]
otherCounts[otherValue] = otherCounts[otherValue] ?? []
otherCounts[otherValue].push(feature)
} else {
unknownCount.push(feature)
}
}
}
if (unknownCount.length + notApplicable.length === features.length) {
console.log("Returning no label nor data: all features are unkown or notApplicable")
return { labels: undefined, data: undefined }
}
let otherGrouped: T[] = []
const otherLabels: string[] = []
const otherData: T[][] = []
const sortedOtherCounts: [string, T[]][] = []
for (const v in otherCounts) {
sortedOtherCounts.push([v, otherCounts[v]])
}
if (options?.sort) {
sortedOtherCounts.sort((a, b) => b[1].length - a[1].length)
}
for (const [v, count] of sortedOtherCounts) {
if (count.length >= (options.groupToOtherCutoff ?? 3)) {
otherLabels.push(v)
otherData.push(otherCounts[v])
} else {
otherGrouped.push(...count)
}
}
const labels = [
"Unknown",
"Other",
"Not applicable",
...(mappings?.map((m) => m.then.txt) ?? []),
...otherLabels,
]
const data: T[][] = [
unknownCount,
otherGrouped,
notApplicable,
...categoryCounts,
...otherData,
]
return { labels, data }
}
}

View file

@ -0,0 +1,90 @@
<script lang="ts">
import { Translation } from "../i18n/Translation"
import * as personal from "../../../assets/themes/personal/personal.json"
import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
import UserDetails, { OsmConnection } from "../../Logic/Osm/OsmConnection"
import Constants from "../../Models/Constants"
import type { LayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import Tr from "../Base/Tr.svelte"
import SubtleLink from "../Base/SubtleLink.svelte"
import Translations from "../i18n/Translations"
export let theme: LayoutInformation
export let isCustom: boolean = false
export let userDetails: UIEventSource<UserDetails>
export let state: { layoutToUse?: { id: string }; osmConnection: OsmConnection }
export let selected: boolean = false
$: title = new Translation(
theme.title,
!isCustom && !theme.mustHaveLanguage ? "themes:" + theme.id + ".title" : undefined
)
$: description = new Translation(theme.shortDescription)
// TODO: Improve this function
function createUrl(
layout: { id: string; definition?: string },
isCustom: boolean,
state?: { layoutToUse?: { id } }
): Store<string> {
if (layout === undefined) {
return undefined
}
if (layout.id === undefined) {
console.error("ID is undefined for layout", layout)
return undefined
}
if (layout.id === state?.layoutToUse?.id) {
return undefined
}
let path = window.location.pathname
// Path starts with a '/' and contains everything, e.g. '/dir/dir/page.html'
path = path.substr(0, path.lastIndexOf("/"))
// Path will now contain '/dir/dir', or empty string in case of nothing
if (path === "") {
path = "."
}
let linkPrefix = `${path}/${layout.id.toLowerCase()}.html?`
if (
location.hostname === "localhost" ||
location.hostname === "127.0.0.1" ||
location.port === "1234"
) {
// Redirect to 'theme.html?layout=* instead of 'layout.html'. This is probably a debug run, where the routing does not work
linkPrefix = `${path}/theme.html?layout=${layout.id}&`
}
if (isCustom) {
linkPrefix = `${path}/theme.html?userlayout=${layout.id}&`
}
let hash = ""
if (layout.definition !== undefined) {
hash = "#" + btoa(JSON.stringify(layout.definition))
}
return new ImmutableStore<string>(`${linkPrefix}${hash}`)
}
let href = createUrl(theme, isCustom, state)
</script>
{#if theme.id !== personal.id || $userDetails.csCount > Constants.userJourney.personalLayoutUnlock}
<SubtleLink href={$href} options={{ extraClasses: "w-full" }}>
<img slot="image" src={theme.icon} class="mx-4 block h-11 w-11" alt="" />
<span class="flex flex-col overflow-hidden text-ellipsis">
<Tr t={title} />
<span class="subtle max-h-12 truncate text-ellipsis">
<Tr t={description} />
</span>
{#if selected}
<span class="alert">
<Tr t={Translations.t.general.morescreen.enterToOpen} />
</span>
{/if}
</span>
</SubtleLink>
{/if}

View file

@ -0,0 +1,102 @@
<script lang="ts">
import Translations from "../i18n/Translations"
import Svg from "../../Svg"
import Tr from "../Base/Tr.svelte"
import NextButton from "../Base/NextButton.svelte"
import Geosearch from "./Geosearch.svelte"
import IfNot from "../Base/IfNot.svelte"
import ToSvelte from "../Base/ToSvelte.svelte"
import ThemeViewState from "../../Models/ThemeViewState"
import If from "../Base/If.svelte"
import { UIEventSource } from "../../Logic/UIEventSource"
import { SearchIcon } from "@rgossiaux/svelte-heroicons/solid"
import { twJoin } from "tailwind-merge"
import { Utils } from "../../Utils"
/**
* The theme introduction panel
*/
export let state: ThemeViewState
let layout = state.layout
let selectedElement = state.selectedElement
let selectedLayer = state.selectedLayer
let triggerSearch: UIEventSource<any> = new UIEventSource<any>(undefined)
let searchEnabled = false
function jumpToCurrentLocation() {
const glstate = state.geolocation.geolocationState
if (glstate.currentGPSLocation.data !== undefined) {
const c: GeolocationCoordinates = glstate.currentGPSLocation.data
state.guistate.themeIsOpened.setData(false)
const coor = { lon: c.longitude, lat: c.latitude }
state.mapProperties.location.setData(coor)
}
if (glstate.permission.data !== "granted") {
glstate.requestPermission()
return
}
}
</script>
<div class="flex h-full flex-col justify-between">
<div>
<!-- Intro, description, ... -->
<Tr t={layout.description} />
<Tr t={Translations.t.general.welcomeExplanation.general} />
{#if layout.layers.some((l) => l.presets?.length > 0)}
<If condition={state.featureSwitches.featureSwitchAddNew}>
<Tr t={Translations.t.general.welcomeExplanation.addNew} />
</If>
{/if}
<Tr t={layout.descriptionTail} />
<!-- Buttons: open map, go to location, search -->
<NextButton clss="primary w-full" on:click={() => state.guistate.themeIsOpened.setData(false)}>
<div class="flex w-full justify-center text-2xl">
<Tr t={Translations.t.general.openTheMap} />
</div>
</NextButton>
<div class="flex w-full flex-wrap sm:flex-nowrap">
<IfNot condition={state.geolocation.geolocationState.permission.map((p) => p === "denied")}>
<button class="flex w-full items-center gap-x-2" on:click={jumpToCurrentLocation}>
<ToSvelte construct={Svg.crosshair_svg().SetClass("w-8 h-8")} />
<Tr t={Translations.t.general.openTheMapAtGeolocation} />
</button>
</IfNot>
<div class=".button low-interaction m-1 flex w-full items-center gap-x-2 rounded border p-2">
<div class="w-full">
<Geosearch
bounds={state.mapProperties.bounds}
on:searchCompleted={() => state.guistate.themeIsOpened.setData(false)}
on:searchIsValid={(isValid) => {
searchEnabled = isValid
}}
perLayer={state.perLayer}
{selectedElement}
{selectedLayer}
{triggerSearch}
/>
</div>
<button
class={twJoin("flex items-center justify-between gap-x-2", !searchEnabled && "disabled")}
on:click={() => triggerSearch.ping()}
>
<Tr t={Translations.t.general.search.searchShort} />
<SearchIcon class="h-6 w-6" />
</button>
</div>
</div>
</div>
<div class="links-as-button links-w-full m-2 flex flex-col gap-y-1">
<!-- bottom buttons, a bit hidden away: switch layout -->
<a class="flex" href={Utils.HomepageLink()}>
<img class="h-6 w-6" src="./assets/svg/add.svg" />
<Tr t={Translations.t.general.backToIndex} />
</a>
</div>
</div>

View file

@ -0,0 +1,60 @@
<script lang="ts">
import NoThemeResultButton from "./NoThemeResultButton.svelte"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { UIEventSource } from "../../Logic/UIEventSource"
import ThemeButton from "./ThemeButton.svelte"
import { LayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import MoreScreen from "./MoreScreen"
import themeOverview from "../../assets/generated/theme_overview.json"
export let search: UIEventSource<string>
export let themes: LayoutInformation[]
export let state: { osmConnection: OsmConnection }
export let isCustom: boolean = false
export let onMainScreen: boolean = true
export let hideThemes: boolean = true
// Filter theme based on search value
$: filteredThemes = themes.filter((theme) => MoreScreen.MatchesLayout(theme, $search))
// Determine which is the first theme, after the search, using all themes
$: allFilteredThemes = themeOverview.filter((theme) => MoreScreen.MatchesLayout(theme, $search))
$: firstTheme = allFilteredThemes[0]
</script>
<section class="w-full">
<slot name="title" />
{#if onMainScreen}
<div class="gap-4 md:grid md:grid-flow-row md:grid-cols-2 lg:grid-cols-3">
{#each filteredThemes as theme (theme.id)}
{#if theme !== undefined && !(hideThemes && theme?.hideFromOverview)}
<!-- TODO: doesn't work if first theme is hidden -->
{#if theme === firstTheme && !isCustom && $search !== "" && $search !== undefined}
<ThemeButton
{theme}
{isCustom}
userDetails={state.osmConnection.userDetails}
{state}
selected={true}
/>
{:else}
<ThemeButton {theme} {isCustom} userDetails={state.osmConnection.userDetails} {state} />
{/if}
{/if}
{/each}
</div>
{:else}
<div>
{#each filteredThemes as theme (theme.id)}
{#if theme !== undefined && !(hideThemes && theme?.hideFromOverview)}
<ThemeButton {theme} {isCustom} userDetails={state.osmConnection.userDetails} {state} />
{/if}
{/each}
</div>
{/if}
{#if filteredThemes.length === 0}
<NoThemeResultButton {search} />
{/if}
</section>

View file

@ -0,0 +1,37 @@
<script lang="ts">
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { Store, Stores, UIEventSource } from "../../Logic/UIEventSource"
import { Utils } from "../../Utils"
import ThemesList from "./ThemesList.svelte"
import Translations from "../i18n/Translations"
import UserRelatedState from "../../Logic/State/UserRelatedState"
export let search: UIEventSource<string>
export let state: UserRelatedState & {
osmConnection: OsmConnection
}
export let onMainScreen: boolean = true
const t = Translations.t.general
const currentIds: Store<string[]> = state.installedUserThemes
const stableIds = Stores.ListStabilized<string>(currentIds)
let customThemes
$: customThemes = Utils.NoNull($stableIds.map((id) => state.GetUnofficialTheme(id)))
$: console.log("Custom themes are", customThemes)
</script>
{#if customThemes.length > 0}
<ThemesList
{search}
{state}
{onMainScreen}
themes={customThemes}
isCustom={true}
hideThemes={false}
>
<svelte:fragment slot="title">
<!-- TODO: Change string to exclude html -->
{@html t.customThemeIntro.toString()}
</svelte:fragment>
</ThemesList>
{/if}

View file

@ -0,0 +1,151 @@
import Toggle from "../Input/Toggle"
import { RadioButton } from "../Input/RadioButton"
import { FixedInputElement } from "../Input/FixedInputElement"
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import { TextField } from "../Input/TextField"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Title from "../Base/Title"
import { SubtleButton } from "../Base/SubtleButton"
import Svg from "../../Svg"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { Translation } from "../i18n/Translation"
import { LoginToggle } from "../Popup/LoginButton"
export default class UploadTraceToOsmUI extends LoginToggle {
constructor(
trace: (title: string) => string,
state: {
layout: LayoutConfig
osmConnection: OsmConnection
readonly featureSwitchUserbadge: Store<boolean>
},
options?: {
whenUploaded?: () => void | Promise<void>
}
) {
const t = Translations.t.general.uploadGpx
const uploadFinished = new UIEventSource(false)
const traceVisibilities: {
key: "private" | "public"
name: Translation
docs: Translation
}[] = [
{
key: "private",
...t.modes.private,
},
{
key: "public",
...t.modes.public,
},
]
const dropdown = new RadioButton<"private" | "public">(
traceVisibilities.map(
(tv) =>
new FixedInputElement<"private" | "public">(
new Combine([
Translations.W(tv.name).SetClass("font-bold"),
tv.docs,
]).SetClass("flex flex-col"),
tv.key
)
),
{
value: <any>state?.osmConnection?.GetPreference("gps.trace.visibility"),
}
)
const description = new TextField({
placeholder: t.meta.descriptionPlaceHolder,
})
const title = new TextField({
placeholder: t.meta.titlePlaceholder,
})
const clicked = new UIEventSource<boolean>(false)
const confirmPanel = new Combine([
new Title(t.title),
t.intro0,
t.intro1,
t.choosePermission,
dropdown,
new Title(t.meta.title, 4),
t.meta.intro,
title,
t.meta.descriptionIntro,
description,
new Combine([
new SubtleButton(Svg.close_svg(), Translations.t.general.cancel)
.onClick(() => {
clicked.setData(false)
})
.SetClass(""),
new SubtleButton(Svg.upload_svg(), t.confirm).OnClickWithLoading(
t.uploading,
async () => {
const titleStr = UploadTraceToOsmUI.createDefault(
title.GetValue().data,
"Track with mapcomplete"
)
const descriptionStr = UploadTraceToOsmUI.createDefault(
description.GetValue().data,
"Track created with MapComplete with theme " + state?.layout?.id
)
await state?.osmConnection?.uploadGpxTrack(trace(title.GetValue().data), {
visibility: dropdown.GetValue().data,
description: descriptionStr,
filename: titleStr + ".gpx",
labels: ["MapComplete", state?.layout?.id],
})
if (options?.whenUploaded !== undefined) {
await options.whenUploaded()
}
uploadFinished.setData(true)
}
),
]).SetClass("flex flex-wrap flex-wrap-reverse justify-between items-stretch"),
]).SetClass("flex flex-col p-4 rounded border-2 m-2 border-subtle")
super(
new Toggle(
new Toggle(
new Combine([
Svg.confirm_svg().SetClass("w-12 h-12 mr-2"),
t.uploadFinished,
]).SetClass("flex p-2 rounded-xl border-2 subtle-border items-center"),
new Toggle(
confirmPanel,
new SubtleButton(Svg.upload_svg(), t.title).onClick(() =>
clicked.setData(true)
),
clicked
),
uploadFinished
),
new Combine([
Svg.invalid_svg().SetClass("w-8 h-8 m-2"),
t.gpxServiceOffline.SetClass("p-2"),
]).SetClass("flex border alert items-center"),
state.osmConnection.gpxServiceIsOnline.map(
(serviceState) => serviceState === "online"
)
),
undefined,
state
)
}
private static createDefault(s: string, defaultValue: string) {
if (defaultValue.length < 1) {
throw "Default value should have some characters"
}
if (s === undefined || s === null || s === "") {
return defaultValue
}
return s
}
}

View file

@ -0,0 +1,53 @@
<script lang="ts">
import UserDetails, { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { UIEventSource } from "../../Logic/UIEventSource"
import { PencilAltIcon, UserCircleIcon } from "@rgossiaux/svelte-heroicons/solid"
import { onDestroy } from "svelte"
import Showdown from "showdown"
import FromHtml from "../Base/FromHtml.svelte"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations.js"
/**
* This panel shows information about the logged-in user, showing account name, profile pick, description and an edit-button
*/
export let osmConnection: OsmConnection
let userdetails: UIEventSource<UserDetails> = osmConnection.userDetails
let description: string
onDestroy(
userdetails.addCallbackAndRunD((userdetails) => {
description = new Showdown.Converter()
.makeHtml(userdetails.description)
?.replace(/&gt;/g, ">")
?.replace(/&lt;/g, "<")
})
)
</script>
<div class="link-underline m-1 flex rounded-md border border-dashed border-gray-600 p-1">
{#if $userdetails.img}
<img src={$userdetails.img} class="m-4 h-12 w-12 rounded-full" />
{:else}
<UserCircleIcon class="h-12 w-12" />
{/if}
<div class="flex flex-col">
<h3>{$userdetails.name}</h3>
{#if description}
<FromHtml src={description} />
<a
href={osmConnection.Backend() + "/profile/edit"}
target="_blank"
class="link-no-underline flex items-center self-end"
>
<PencilAltIcon slot="image" class="h-8 w-8 p-2" />
<Tr slot="message" t={Translations.t.userinfo.editDescription} />
</a>
{:else}
<Tr t={Translations.t.userinfo.noDescription} />
<a href={osmConnection.Backend() + "/profile/edit"} target="_blank" class="flex items-center">
<PencilAltIcon slot="image" class="h-8 w-8 p-2" />
<Tr slot="message" t={Translations.t.userinfo.noDescriptionCallToAction} />
</a>
{/if}
</div>
</div>

View file

@ -0,0 +1,106 @@
<script lang="ts">
/**
* This component shows a map which focuses on a single OSM-Way (linestring) feature.
* Clicking the map will add a new 'scissor' point, projected on the linestring (and possible snapped to an already existing node within the linestring;
* clicking this point again will remove it.
* The bound 'value' will contain the location of these projected points.
* Points are not coalesced with already existing nodes within the way; it is up to the code actually splitting the way to decide to reuse an existing point or not
*
* This component is _not_ responsible for the rest of the flow, e.g. the confirm button
*/
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import type { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson"
import split_point from "../../../assets/layers/split_point/split_point.json"
import split_road from "../../../assets/layers/split_road/split_road.json"
import { UIEventSource } from "../../Logic/UIEventSource"
import { Map as MlMap } from "maplibre-gl"
import type { MapProperties } from "../../Models/MapProperties"
import { MapLibreAdaptor } from "../Map/MapLibreAdaptor"
import MaplibreMap from "../Map/MaplibreMap.svelte"
import { OsmWay } from "../../Logic/Osm/OsmObject"
import ShowDataLayer from "../Map/ShowDataLayer"
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"
import { GeoOperations } from "../../Logic/GeoOperations"
import { BBox } from "../../Logic/BBox"
import type { Feature, LineString, Point } from "geojson"
const splitpoint_style = new LayerConfig(
<LayerConfigJson>split_point,
"(BUILTIN) SplitRoadWizard.ts",
true
) as const
const splitroad_style = new LayerConfig(
<LayerConfigJson>split_road,
"(BUILTIN) SplitRoadWizard.ts",
true
) as const
/**
* The way to focus on
*/
export let osmWay: OsmWay
/**
* How to render this layer.
* A default is given
*/
export let layer: LayerConfig = splitroad_style
/**
* Optional: use these properties to set e.g. background layer
*/
export let mapProperties: undefined | Partial<MapProperties> = undefined
let map: UIEventSource<MlMap> = new UIEventSource<MlMap>(undefined)
let adaptor = new MapLibreAdaptor(map, mapProperties)
const wayGeojson: Feature<LineString> = GeoOperations.forceLineString(osmWay.asGeoJson())
adaptor.location.setData(GeoOperations.centerpointCoordinatesObj(wayGeojson))
adaptor.bounds.setData(BBox.get(wayGeojson).pad(2))
adaptor.maxbounds.setData(BBox.get(wayGeojson).pad(2))
new ShowDataLayer(map, {
features: new StaticFeatureSource([wayGeojson]),
drawMarkers: false,
layer: layer,
})
export let splitPoints: UIEventSource<
Feature<
Point,
{
id: number
index: number
dist: number
location: number
}
>[]
> = new UIEventSource([])
const splitPointsFS = new StaticFeatureSource(splitPoints)
new ShowDataLayer(map, {
layer: splitpoint_style,
features: splitPointsFS,
onClick: (clickedFeature: Feature) => {
console.log("Clicked feature is", clickedFeature, splitPoints.data)
const i = splitPoints.data.findIndex((f) => f === clickedFeature)
if (i < 0) {
return
}
splitPoints.data.splice(i, 1)
splitPoints.ping()
},
})
let id = 0
adaptor.lastClickLocation.addCallbackD(({ lon, lat }) => {
const projected = GeoOperations.nearestPoint(wayGeojson, [lon, lat])
projected.properties["id"] = id
id++
splitPoints.data.push(<any>projected)
splitPoints.ping()
})
</script>
<div class="h-full w-full">
<MaplibreMap {map} />
</div>

View file

@ -0,0 +1,95 @@
<script lang="ts">
import type { SpecialVisualizationState } from "../SpecialVisualization"
import { ArrowDownTrayIcon } from "@babeard/svelte-heroicons/mini"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
import type { FeatureCollection } from "geojson"
import Loading from "../Base/Loading.svelte"
import { Translation } from "../i18n/Translation"
import DownloadHelper from "./DownloadHelper"
import { Utils } from "../../Utils"
import type { PriviligedLayerType } from "../../Models/Constants"
import { UIEventSource } from "../../Logic/UIEventSource"
export let state: SpecialVisualizationState
export let extension: string
export let mimetype: string
export let construct: (
geojsonCleaned: FeatureCollection,
title: string,
status?: UIEventSource<string>
) => (Blob | string) | Promise<void>
export let mainText: Translation
export let helperText: Translation
export let metaIsIncluded: boolean
let downloadHelper: DownloadHelper = new DownloadHelper(state)
const t = Translations.t.general.download
let isExporting = false
let isError = false
let status: UIEventSource<string> = new UIEventSource<string>(undefined)
async function clicked() {
isExporting = true
const gpsLayer = state.layerState.filteredLayers.get(<PriviligedLayerType>"gps_location")
state.lastClickObject.features.setData([])
const gpsIsDisplayed = gpsLayer.isDisplayed.data
try {
gpsLayer.isDisplayed.setData(false)
const geojson: FeatureCollection = downloadHelper.getCleanGeoJson(metaIsIncluded)
const name = state.layout.id
const title = `MapComplete_${name}_export_${new Date()
.toISOString()
.substr(0, 19)}.${extension}`
const promise = construct(geojson, title, status)
let data: Blob | string
if (typeof promise === "string") {
data = promise
} else if (typeof promise["then"] === "function") {
data = await (<Promise<Blob | string>>promise)
} else {
data = <Blob>promise
}
if (!data) {
return
}
console.log("Got data", data)
Utils.offerContentsAsDownloadableFile(data, title, {
mimetype,
})
} catch (e) {
isError = true
console.error(e)
} finally {
isExporting = false
gpsLayer.isDisplayed.setData(gpsIsDisplayed)
}
}
</script>
{#if isError}
<Tr cls="alert" t={Translations.t.general.error} />
{:else if isExporting}
<Loading>
{#if $status}
{$status}
{:else}
<Tr t={t.exporting} />
{/if}
</Loading>
{:else}
<button class="flex w-full" on:click={clicked}>
<slot name="image">
<ArrowDownTrayIcon class="mr-2 h-12 w-12 shrink-0" />
</slot>
<span class="flex flex-col items-start">
<Tr t={mainText} />
<Tr t={helperText} cls="subtle" />
</span>
</button>
{/if}

View file

@ -0,0 +1,200 @@
import { SpecialVisualizationState } from "../SpecialVisualization"
import { Feature, FeatureCollection } from "geojson"
import { BBox } from "../../Logic/BBox"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { Utils } from "../../Utils"
import SimpleMetaTagger from "../../Logic/SimpleMetaTagger"
import geojson2svg from "geojson2svg"
/**
* Exposes the download-functionality
*/
export default class DownloadHelper {
private readonly _state: SpecialVisualizationState
constructor(state: SpecialVisualizationState) {
this._state = state
}
/**
* Returns a new feature of which all the metatags are deleted
*/
private static cleanFeature(f: Feature): Feature {
f = {
type: f.type,
geometry: { ...f.geometry },
properties: { ...f.properties },
}
for (const key in f.properties) {
if (key === "_lon" || key === "_lat") {
continue
}
if (key.startsWith("_")) {
delete f.properties[key]
}
}
const datedKeys = [].concat(
SimpleMetaTagger.metatags
.filter((tagging) => tagging.includesDates)
.map((tagging) => tagging.keys)
)
for (const key of datedKeys) {
delete f.properties[key]
}
return f
}
public getCleanGeoJson(includeMetaData: boolean): FeatureCollection {
const featuresPerLayer = this.getCleanGeoJsonPerLayer(includeMetaData)
const features = [].concat(...Array.from(featuresPerLayer.values()))
return {
type: "FeatureCollection",
features,
}
}
/**
* Converts a geojson to an SVG
*
*
* import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource";
*
* const feature: Feature = {
* "type": "Feature",
* "properties": {},
* "geometry": {
* "type": "LineString",
* "coordinates": [
* [-180, 80],
* [180, -80]
* ]
* }
* }
* const features = new StaticFeatureSource([feature])
* const perLayer = new Map<string, any>()
* perLayer.set("testlayer", features)
* new DownloadHelper(<any> {perLayer}).asSvg().replace(/\n/g, "") // => `<svg width="1000px" height="1000px" viewBox="0 0 1000 1000"> <g id="testlayer" inkscape:groupmode="layer" inkscape:label="testlayer"> <path d="M0,27.77777777777778 1000,472.22222222222223" style="fill:none;stroke-width:1" stroke="#ff0000"/> </g></svg>`
*/
public asSvg(options?: {
layers?: LayerConfig[]
width?: 1000 | number
height?: 1000 | number
mapExtent?: BBox
unit?: "px" | "mm" | string
}) {
const perLayer = this._state.perLayer
options = options ?? {}
const width = options.width ?? 1000
const height = options.height ?? 1000
if (width <= 0 || height <= 0) {
throw "Invalid width of height, they should be > 0"
}
const unit = options.unit ?? "px"
const mapExtent = { left: -180, bottom: -90, right: 180, top: 90 }
if (options.mapExtent !== undefined) {
const bbox = options.mapExtent
mapExtent.left = bbox.minLon
mapExtent.right = bbox.maxLon
mapExtent.bottom = bbox.minLat
mapExtent.top = bbox.maxLat
}
console.log("Generateing svg, extent:", { mapExtent, width, height })
const elements: string[] = []
for (const layer of Array.from(perLayer.keys())) {
const features = perLayer.get(layer).features.data
if (features.length === 0) {
continue
}
const layerDef = options?.layers?.find((l) => l.id === layer)
const rendering = layerDef?.lineRendering[0]
const converter = geojson2svg({
viewportSize: { width, height },
mapExtent,
output: "svg",
attributes: [
{
property: "style",
type: "static",
value: "fill:none;stroke-width:1",
},
{
property: "properties.stroke",
type: "dynamic",
key: "stroke",
},
],
})
for (const feature of features) {
const stroke =
rendering?.color?.GetRenderValue(feature.properties)?.txt ?? "#ff0000"
feature.properties.stroke = Utils.colorAsHex(Utils.color(stroke))
}
const groupPaths: string[] = converter.convert({ type: "FeatureCollection", features })
const group =
` <g id="${layer}" inkscape:groupmode="layer" inkscape:label="${layer}">\n` +
groupPaths.map((p) => " " + p).join("\n") +
"\n </g>"
elements.push(group)
}
const w = width
const h = height
const header = `<svg width="${w}${unit}" height="${h}${unit}" viewBox="0 0 ${w} ${h}">`
return header + "\n" + elements.join("\n") + "\n</svg>"
}
public getCleanGeoJsonPerLayer(includeMetaData: boolean): Map<string, Feature[]> {
const state = this._state
const featuresPerLayer = new Map<string, any[]>()
const neededLayers = state.layout.layers.filter((l) => l.source !== null).map((l) => l.id)
const bbox = state.mapProperties.bounds.data
for (const neededLayer of neededLayers) {
const indexedFeatureSource = state.perLayer.get(neededLayer)
let features = indexedFeatureSource.GetFeaturesWithin(bbox)
// The 'indexedFeatureSources' contains _all_ features, they are not filtered yet
const filter = state.layerState.filteredLayers.get(neededLayer)
features = features.filter((f) =>
filter.isShown(f.properties, state.layerState.globalFilters.data)
)
if (!includeMetaData) {
features = features.map((f) => DownloadHelper.cleanFeature(f))
}
featuresPerLayer.set(neededLayer, features)
}
return featuresPerLayer
}
/**
* Creates an image for the given key.
* @param key
* @param width (in mm)
* @param height (in mm)
*/
createImage(key: string, width: string, height: string): HTMLImageElement {
const img = document.createElement("img")
const sources = {
layouticon: this._state.layout.icon,
}
img.src = sources[key]
if (!img.src) {
throw (
"Invalid key for 'createImage': " +
key +
"; try one of: " +
Object.keys(sources).join(", ")
)
}
img.style.width = width
img.style.height = height
console.log("Fetching an image with src", img.src)
return img
}
}

View file

@ -0,0 +1,96 @@
<script lang="ts">
import Loading from "../Base/Loading.svelte"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
import DownloadHelper from "./DownloadHelper"
import DownloadButton from "./DownloadButton.svelte"
import { GeoOperations } from "../../Logic/GeoOperations"
import { SvgToPdf } from "../../Utils/svgToPdf"
import ThemeViewState from "../../Models/ThemeViewState"
import DownloadPdf from "./DownloadPdf.svelte"
export let state: ThemeViewState
let isLoading = state.dataIsLoading
const t = Translations.t.general.download
const downloadHelper = new DownloadHelper(state)
let metaIsIncluded = false
const name = state.layout.id
function offerSvg(): string {
const maindiv = document.getElementById("maindiv")
const layers = state.layout.layers.filter((l) => l.source !== null)
return downloadHelper.asSvg({
layers,
mapExtent: state.mapProperties.bounds.data,
width: maindiv.offsetWidth,
height: maindiv.offsetHeight,
})
}
</script>
{#if $isLoading}
<Loading />
{:else}
<div class="flex w-full flex-col" />
<h3>
<Tr t={t.title} />
</h3>
<DownloadButton
{state}
extension="geojson"
mimetype="application/vnd.geo+json"
construct={(geojson) => JSON.stringify(geojson)}
mainText={t.downloadGeojson}
helperText={t.downloadGeoJsonHelper}
{metaIsIncluded}
/>
<DownloadButton
{state}
extension="csv"
mimetype="text/csv"
construct={(geojson) => GeoOperations.toCSV(geojson)}
mainText={t.downloadCSV}
helperText={t.downloadCSVHelper}
{metaIsIncluded}
/>
<label class="mb-8 mt-2">
<input type="checkbox" bind:value={metaIsIncluded} />
<Tr t={t.includeMetaData} />
</label>
<DownloadButton
{state}
{metaIsIncluded}
extension="svg"
mimetype="image/svg+xml"
mainText={t.downloadAsSvg}
helperText={t.downloadAsSvgHelper}
construct={offerSvg}
/>
<DownloadButton
{state}
{metaIsIncluded}
extension="png"
mimetype="image/png"
mainText={t.downloadAsPng}
helperText={t.downloadAsPngHelper}
construct={(_) => state.mapProperties.exportAsPng(4)}
/>
<div class="flex flex-col">
{#each Object.keys(SvgToPdf.templates) as key}
{#if SvgToPdf.templates[key].isPublic}
<DownloadPdf {state} templateName={key} />
{/if}
{/each}
</div>
<Tr cls="link-underline" t={t.licenseInfo} />
{/if}

View file

@ -0,0 +1,67 @@
<script lang="ts">
import DownloadButton from "./DownloadButton.svelte"
import ThemeViewState from "../../Models/ThemeViewState"
import { SvgToPdf } from "../../Utils/svgToPdf"
import type { PdfTemplateInfo } from "../../Utils/svgToPdf"
import Translations from "../i18n/Translations"
import { Translation } from "../i18n/Translation"
import { Utils } from "../../Utils"
import { AvailableRasterLayers } from "../../Models/RasterLayers"
import Constants from "../../Models/Constants"
import Locale from "../i18n/Locale"
import { UIEventSource } from "../../Logic/UIEventSource"
import DownloadHelper from "./DownloadHelper"
export let templateName: string
export let state: ThemeViewState
const template: PdfTemplateInfo = SvgToPdf.templates[templateName]
console.log("template", template)
let mainText: Translation =
typeof template.description === "string"
? new Translation(template.description)
: template.description
let t = Translations.t.general.download
const downloadHelper = new DownloadHelper(state)
async function constructPdf(_, title: string, status: UIEventSource<string>) {
title =
title.substring(0, title.length - 4) + "_" + template.format + "_" + template.orientation
const templateUrls = SvgToPdf.templates[templateName].pages
const templates: string[] = await Promise.all(templateUrls.map((url) => Utils.download(url)))
console.log("Templates are", templates)
const bg = state.mapProperties.rasterLayer.data ?? AvailableRasterLayers.maplibre
const creator = new SvgToPdf(title, templates, {
state,
freeComponentId: "belowmap",
createImage: (key: string, width: string, height: string) =>
downloadHelper.createImage(key, width, height),
textSubstitutions: <Record<string, string>>{
"layout.title": state.layout.title,
layoutid: state.layout.id,
title: state.layout.title,
layoutImg: state.layout.icon,
version: Constants.vNumber,
date: new Date().toISOString().substring(0, 16),
background: new Translation(bg.properties.name).txt,
},
})
const unsub = creator.status.addCallbackAndRunD((s) => {
console.log("SVG creator status:", s)
status?.setData(s)
})
await creator.ExportPdf(Locale.language.data)
unsub()
return undefined
}
</script>
<DownloadButton
construct={constructPdf}
extension="pdf"
helperText={t.downloadAsPdfHelper}
metaIsIncluded={false}
{mainText}
mimetype="application/pdf"
{state}
/>

View file

@ -0,0 +1,29 @@
import Combine from "../Base/Combine"
import Attribution from "./Attribution"
import Img from "../Base/Img"
import ImageProvider from "../../Logic/ImageProviders/ImageProvider"
import BaseUIElement from "../BaseUIElement"
import { Mapillary } from "../../Logic/ImageProviders/Mapillary"
import { UIEventSource } from "../../Logic/UIEventSource"
export class AttributedImage extends Combine {
constructor(imageInfo: { url: string; provider?: ImageProvider; date?: Date }) {
let img: BaseUIElement
img = new Img(imageInfo.url, false, {
fallbackImage:
imageInfo.provider === Mapillary.singleton ? "./assets/svg/blocked.svg" : undefined,
})
let attr: BaseUIElement = undefined
if (imageInfo.provider !== undefined) {
attr = new Attribution(
UIEventSource.FromPromise(imageInfo.provider?.DownloadAttribution(imageInfo.url)),
imageInfo.provider?.SourceIcon(),
imageInfo.date
)
}
super([img, attr])
this.SetClass("block relative h-full")
}
}

View file

@ -0,0 +1,50 @@
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
import { VariableUiElement } from "../Base/VariableUIElement"
import { Store } from "../../Logic/UIEventSource"
import { LicenseInfo } from "../../Logic/ImageProviders/LicenseInfo"
import { FixedUiElement } from "../Base/FixedUiElement"
import Link from "../Base/Link"
/**
* Small box in the bottom left of an image, e.g. the image in a popup
*/
export default class Attribution extends VariableUiElement {
constructor(license: Store<LicenseInfo>, icon: BaseUIElement, date?: Date) {
if (license === undefined) {
throw "No license source given in the attribution element"
}
super(
license.map((license: LicenseInfo) => {
if (license === undefined) {
return undefined
}
let title = undefined
if (license?.title) {
title = Translations.W(license?.title).SetClass("block")
if (license.informationLocation) {
title = new Link(title, license.informationLocation.href, true)
}
}
return new Combine([
icon
?.SetClass("block left")
.SetStyle("height: 2em; width: 2em; padding-right: 0.5em;"),
new Combine([
title,
Translations.W(license?.artist ?? "").SetClass("block font-bold"),
Translations.W(license?.license ?? license?.licenseShortName),
date === undefined
? undefined
: new FixedUiElement(date.toLocaleDateString()),
]).SetClass("flex flex-col"),
]).SetClass(
"flex flex-row bg-black text-white text-sm absolute bottom-0 left-0 p-0.5 pl-5 pr-3 rounded-lg no-images"
)
})
)
}
}

View file

@ -0,0 +1,71 @@
import { Store } from "../../Logic/UIEventSource"
import Translations from "../i18n/Translations"
import Toggle, { ClickableToggle } from "../Input/Toggle"
import Combine from "../Base/Combine"
import Svg from "../../Svg"
import { Tag } from "../../Logic/Tags/Tag"
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"
import { Changes } from "../../Logic/Osm/Changes"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
export default class DeleteImage extends Toggle {
constructor(
key: string,
tags: Store<any>,
state: { layout: LayoutConfig; changes?: Changes; osmConnection?: OsmConnection }
) {
const oldValue = tags.data[key]
const isDeletedBadge = Translations.t.image.isDeleted
.Clone()
.SetClass("rounded-full p-1")
.SetStyle("color:white;background:#ff8c8c")
.onClick(async () => {
await state?.changes?.applyAction(
new ChangeTagAction(tags.data.id, new Tag(key, oldValue), tags.data, {
changeType: "delete-image",
theme: state.layout.id,
})
)
})
const deleteButton = Translations.t.image.doDelete
.Clone()
.SetClass("block w-full pl-4 pr-4")
.SetStyle(
"color:white;background:#ff8c8c; border-top-left-radius:30rem; border-top-right-radius: 30rem;"
)
.onClick(async () => {
await state?.changes?.applyAction(
new ChangeTagAction(tags.data.id, new Tag(key, ""), tags.data, {
changeType: "answer",
theme: state.layout.id,
})
)
})
const cancelButton = Translations.t.general.cancel
.Clone()
.SetClass("bg-white pl-4 pr-4")
.SetStyle("border-bottom-left-radius:30rem; border-bottom-right-radius: 30rem;")
const openDelete = Svg.delete_icon_svg().SetStyle("width: 2em; height: 2em; display:block;")
const deleteDialog = new ClickableToggle(
new Combine([deleteButton, cancelButton]).SetClass("flex flex-col background-black"),
openDelete
)
cancelButton.onClick(() => deleteDialog.isEnabled.setData(false))
openDelete.onClick(() => deleteDialog.isEnabled.setData(true))
super(
new Toggle(
deleteDialog,
isDeletedBadge,
tags.map((tags) => (tags[key] ?? "") !== "")
),
undefined /*Login (and thus editing) is disabled*/,
state?.osmConnection?.isLoggedIn
)
this.SetClass("cursor-pointer")
}
}

View file

@ -0,0 +1,53 @@
import { SlideShow } from "./SlideShow"
import { Store } from "../../Logic/UIEventSource"
import Combine from "../Base/Combine"
import DeleteImage from "./DeleteImage"
import { AttributedImage } from "./AttributedImage"
import BaseUIElement from "../BaseUIElement"
import Toggle from "../Input/Toggle"
import ImageProvider from "../../Logic/ImageProviders/ImageProvider"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { Changes } from "../../Logic/Osm/Changes"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
export class ImageCarousel extends Toggle {
constructor(
images: Store<{ key: string; url: string; provider: ImageProvider }[]>,
tags: Store<any>,
state: { osmConnection?: OsmConnection; changes?: Changes; layout: LayoutConfig }
) {
const uiElements = images.map(
(imageURLS: { key: string; url: string; provider: ImageProvider }[]) => {
const uiElements: BaseUIElement[] = []
for (const url of imageURLS) {
try {
let image = new AttributedImage(url)
if (url.key !== undefined) {
image = new Combine([
image,
new DeleteImage(url.key, tags, state).SetClass(
"delete-image-marker absolute top-0 left-0 pl-3"
),
]).SetClass("relative")
}
image
.SetClass("w-full block")
.SetStyle("min-width: 50px; background: grey;")
uiElements.push(image)
} catch (e) {
console.error("Could not generate image element for", url.url, "due to", e)
}
}
return uiElements
}
)
super(
new SlideShow(uiElements).SetClass("w-full"),
undefined,
uiElements.map((els) => els.length > 0)
)
this.SetClass("block w-full")
}
}

View file

@ -0,0 +1,202 @@
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import Svg from "../../Svg"
import { Tag } from "../../Logic/Tags/Tag"
import BaseUIElement from "../BaseUIElement"
import Toggle from "../Input/Toggle"
import FileSelectorButton from "../Input/FileSelectorButton"
import ImgurUploader from "../../Logic/ImageProviders/ImgurUploader"
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { FixedUiElement } from "../Base/FixedUiElement"
import { VariableUiElement } from "../Base/VariableUIElement"
import Loading from "../Base/Loading"
import { LoginToggle } from "../Popup/LoginButton"
import Constants from "../../Models/Constants"
import { SpecialVisualizationState } from "../SpecialVisualization"
export class ImageUploadFlow extends Toggle {
private static readonly uploadCountsPerId = new Map<string, UIEventSource<number>>()
constructor(
tagsSource: Store<any>,
state: SpecialVisualizationState,
imagePrefix: string = "image",
text: string = undefined
) {
const perId = ImageUploadFlow.uploadCountsPerId
const id = tagsSource.data.id
if (!perId.has(id)) {
perId.set(id, new UIEventSource<number>(0))
}
const uploadedCount = perId.get(id)
const uploader = new ImgurUploader(async (url) => {
// A file was uploaded - we add it to the tags of the object
const tags = tagsSource.data
let key = imagePrefix
if (tags[imagePrefix] !== undefined) {
let freeIndex = 0
while (tags[imagePrefix + ":" + freeIndex] !== undefined) {
freeIndex++
}
key = imagePrefix + ":" + freeIndex
}
await state.changes.applyAction(
new ChangeTagAction(tags.id, new Tag(key, url), tagsSource.data, {
changeType: "add-image",
theme: state.layout.id,
})
)
console.log("Adding image:" + key, url)
uploadedCount.data++
uploadedCount.ping()
})
const t = Translations.t.image
let labelContent: BaseUIElement
if (text === undefined) {
labelContent = Translations.t.image.addPicture
.Clone()
.SetClass("block align-middle mt-1 ml-3 text-4xl ")
} else {
labelContent = new FixedUiElement(text).SetClass(
"block align-middle mt-1 ml-3 text-2xl "
)
}
const label = new Combine([
Svg.camera_plus_svg().SetClass("block w-12 h-12 p-1 text-4xl "),
labelContent,
]).SetClass("w-full flex justify-center items-center")
const licenseStore = state?.osmConnection?.GetPreference(
Constants.OsmPreferenceKeyPicturesLicense,
"CC0"
)
const fileSelector = new FileSelectorButton(label, {
acceptType: "image/*",
allowMultiple: true,
labelClasses: "rounded-full border-2 border-black font-bold",
})
/* fileSelector.SetClass(
"p-2 border-4 border-detail rounded-full font-bold h-full align-middle w-full flex justify-center"
)
.SetStyle(" border-color: var(--foreground-color);")*/
fileSelector.GetValue().addCallback((filelist) => {
if (filelist === undefined || filelist.length === 0) {
return
}
for (var i = 0; i < filelist.length; i++) {
const sizeInBytes = filelist[i].size
console.log(filelist[i].name + " has a size of " + sizeInBytes + " Bytes")
if (sizeInBytes > uploader.maxFileSizeInMegabytes * 1000000) {
alert(
Translations.t.image.toBig.Subs({
actual_size: Math.floor(sizeInBytes / 1000000) + "MB",
max_size: uploader.maxFileSizeInMegabytes + "MB",
}).txt
)
return
}
}
const license = licenseStore?.data ?? "CC0"
const tags = tagsSource.data
const layout = state?.layout
let matchingLayer: LayerConfig = undefined
for (const layer of layout?.layers ?? []) {
if (layer.source.osmTags.matchesProperties(tags)) {
matchingLayer = layer
break
}
}
const title =
matchingLayer?.title?.GetRenderValue(tags)?.Subs(tags)?.ConstructElement()
?.textContent ??
tags.name ??
"https//osm.org/" + tags.id
const description = [
"author:" + state.osmConnection.userDetails.data.name,
"license:" + license,
"osmid:" + tags.id,
].join("\n")
uploader.uploadMany(title, description, filelist)
})
const uploadFlow: BaseUIElement = new Combine([
new VariableUiElement(
uploader.queue
.map((q) => q.length)
.map((l) => {
if (l == 0) {
return undefined
}
if (l == 1) {
return new Loading(t.uploadingPicture).SetClass("alert")
} else {
return new Loading(
t.uploadingMultiple.Subs({ count: "" + l })
).SetClass("alert")
}
})
),
new VariableUiElement(
uploader.failed
.map((q) => q.length)
.map((l) => {
if (l == 0) {
return undefined
}
console.log(l)
return t.uploadFailed.SetClass("block alert")
})
),
new VariableUiElement(
uploadedCount.map((l) => {
if (l == 0) {
return undefined
}
if (l == 1) {
return t.uploadDone.Clone().SetClass("thanks block")
}
return t.uploadMultipleDone.Subs({ count: l }).SetClass("thanks block")
})
),
fileSelector,
new Combine([
Translations.t.image.respectPrivacy,
new VariableUiElement(
licenseStore.map((license) =>
Translations.t.image.currentLicense.Subs({ license })
)
)
.onClick(() => {
console.log("Opening the license settings... ")
state.guistate.openUsersettings("picture-license")
})
.SetClass("underline"),
]).SetStyle("font-size:small;"),
]).SetClass("flex flex-col image-upload-flow mt-4 mb-8 text-center leading-none")
super(
new LoginToggle(
/*We can show the actual upload button!*/
uploadFlow,
/* User not logged in*/ t.pleaseLogin.Clone(),
state
),
undefined /* Nothing as the user badge is disabled*/,
state?.featureSwitchUserbadge
)
}
}

48
src/UI/Image/SlideShow.ts Normal file
View file

@ -0,0 +1,48 @@
import { Store } from "../../Logic/UIEventSource"
import BaseUIElement from "../BaseUIElement"
import { Utils } from "../../Utils"
import Combine from "../Base/Combine"
export class SlideShow extends BaseUIElement {
private readonly embeddedElements: Store<BaseUIElement[]>
constructor(embeddedElements: Store<BaseUIElement[]>) {
super()
this.embeddedElements = embeddedElements
this.SetStyle("scroll-snap-type: x mandatory; overflow-x: auto")
}
protected InnerConstructElement(): HTMLElement {
const el = document.createElement("div")
el.style.minWidth = "min-content"
el.style.display = "flex"
el.style.justifyContent = "center"
this.embeddedElements.addCallbackAndRun((elements) => {
if (elements.length > 1) {
el.style.justifyContent = "unset"
}
while (el.firstChild) {
el.removeChild(el.lastChild)
}
elements = Utils.NoNull(elements).map((el) =>
new Combine([el])
.SetClass("block relative ml-1 bg-gray-200 m-1 rounded slideshow-item")
.SetStyle(
"min-width: 150px; width: max-content; height: var(--image-carousel-height);max-height: var(--image-carousel-height);scroll-snap-align: start;"
)
)
for (const element of elements ?? []) {
el.appendChild(element.ConstructElement())
}
})
const wrapper = document.createElement("div")
wrapper.style.maxWidth = "100%"
wrapper.style.overflowX = "auto"
wrapper.appendChild(el)
return wrapper
}
}

View file

@ -0,0 +1,98 @@
import { InputElement } from "./InputElement"
import { UIEventSource } from "../../Logic/UIEventSource"
import { Utils } from "../../Utils"
import BaseUIElement from "../BaseUIElement"
import InputElementMap from "./InputElementMap"
import Translations from "../i18n/Translations"
/**
* @deprecated
*/
export class CheckBox extends InputElementMap<number[], boolean> {
constructor(el: BaseUIElement | string, defaultValue?: boolean) {
super(
new CheckBoxes([Translations.W(el)]),
(x0, x1) => x0 === x1,
(t) => t.length > 0,
(x) => (x ? [0] : [])
)
if (defaultValue !== undefined) {
this.GetValue().setData(defaultValue)
}
}
}
/**
* A list of individual checkboxes
* The value will contain the indexes of the selected checkboxes
*/
export default class CheckBoxes extends InputElement<number[]> {
private static _nextId = 0
private readonly value: UIEventSource<number[]>
private readonly _elements: BaseUIElement[]
constructor(elements: BaseUIElement[], value = new UIEventSource<number[]>([])) {
super()
this.value = value
this._elements = Utils.NoNull(elements)
this.SetClass("flex flex-col")
}
IsValid(ts: number[]): boolean {
return ts !== undefined
}
GetValue(): UIEventSource<number[]> {
return this.value
}
protected InnerConstructElement(): HTMLElement {
const formTag = document.createElement("form")
const value = this.value
const elements = this._elements
for (let i = 0; i < elements.length; i++) {
let inputI = elements[i]
const input = document.createElement("input")
const id = CheckBoxes._nextId
CheckBoxes._nextId++
input.id = "checkbox" + id
input.type = "checkbox"
input.classList.add("p-1", "cursor-pointer", "m-3", "pl-3", "mr-0")
const label = document.createElement("label")
label.htmlFor = input.id
label.appendChild(input)
label.appendChild(inputI.ConstructElement())
label.classList.add("block", "w-full", "p-2", "cursor-pointer")
formTag.appendChild(label)
value.addCallbackAndRunD((selectedValues) => {
input.checked = selectedValues.indexOf(i) >= 0
if (input.checked) {
label.classList.add("checked")
} else {
label.classList.remove("checked")
}
})
input.onchange = () => {
// Index = index in the list of already checked items
const index = value.data.indexOf(i)
if (input.checked && index < 0) {
value.data.push(i)
value.ping()
} else if (index >= 0) {
value.data.splice(index, 1)
value.ping()
}
}
}
return formTag
}
}

104
src/UI/Input/DropDown.ts Normal file
View file

@ -0,0 +1,104 @@
import { InputElement } from "./InputElement"
import Translations from "../i18n/Translations"
import { UIEventSource } from "../../Logic/UIEventSource"
import BaseUIElement from "../BaseUIElement"
/**
* @deprecated
*/
export class DropDown<T> extends InputElement<T> {
private static _nextDropdownId = 0
private readonly _element: HTMLElement
private readonly _value: UIEventSource<T>
private readonly _values: { value: T; shown: string | BaseUIElement }[]
/**
*
* const dropdown = new DropDown<number>("test",[{value: 42, shown: "the answer"}])
* dropdown.GetValue().data // => 42
*/
constructor(
label: string | BaseUIElement,
values: { value: T; shown: string | BaseUIElement }[],
value: UIEventSource<T> = undefined,
options?: {
select_class?: string
}
) {
super()
value = value ?? new UIEventSource<T>(values[0].value)
this._value = value
this._values = values
if (values.length <= 1) {
return
}
const id = DropDown._nextDropdownId
DropDown._nextDropdownId++
const el = document.createElement("form")
this._element = el
el.id = "dropdown" + id
{
const labelEl = Translations.W(label)?.ConstructElement()
if (labelEl !== undefined) {
const labelHtml = document.createElement("label")
labelHtml.appendChild(labelEl)
labelHtml.htmlFor = el.id
el.appendChild(labelHtml)
}
}
options = options ?? {}
options.select_class =
options.select_class ?? "w-full bg-indigo-100 p-1 rounded hover:bg-indigo-200"
{
const select = document.createElement("select")
select.classList.add(...(options.select_class.split(" ") ?? []))
for (let i = 0; i < values.length; i++) {
const option = document.createElement("option")
option.value = "" + i
option.appendChild(Translations.W(values[i].shown).ConstructElement())
select.appendChild(option)
}
el.appendChild(select)
select.onchange = () => {
const index = select.selectedIndex
value.setData(values[index].value)
}
value.addCallbackAndRun((selected) => {
for (let i = 0; i < values.length; i++) {
const value = values[i].value
if (value === selected) {
select.selectedIndex = i
}
}
})
}
this.onClick(() => {}) // by registering a click, the click event is consumed and doesn't bubble further to other elements, e.g. checkboxes
}
GetValue(): UIEventSource<T> {
return this._value
}
IsValid(t: T): boolean {
for (const value of this._values) {
if (value.value === t) {
return true
}
}
return false
}
protected InnerConstructElement(): HTMLElement {
return this._element
}
}

View file

@ -0,0 +1,115 @@
import BaseUIElement from "../BaseUIElement"
import {InputElement} from "./InputElement"
import {UIEventSource} from "../../Logic/UIEventSource"
/**
* @deprecated
*/
export default class FileSelectorButton extends InputElement<FileList> {
private static _nextid = 0
private readonly _value = new UIEventSource<FileList>(undefined)
private readonly _label: BaseUIElement
private readonly _acceptType: string
private readonly allowMultiple: boolean
private readonly _labelClasses: string
constructor(
label: BaseUIElement,
options?: {
acceptType: "image/*" | string
allowMultiple: true | boolean
labelClasses?: string
}
) {
super()
this._label = label
this._acceptType = options?.acceptType ?? "image/*"
this._labelClasses = options?.labelClasses ?? ""
this.SetClass("block cursor-pointer")
label.SetClass("cursor-pointer")
this.allowMultiple = options?.allowMultiple ?? true
}
GetValue(): UIEventSource<FileList> {
return this._value
}
IsValid(t: FileList): boolean {
return true
}
protected InnerConstructElement(): HTMLElement {
const self = this
const el = document.createElement("form")
const label = document.createElement("label")
label.appendChild(this._label.ConstructElement())
label.classList.add(...this._labelClasses.split(" ").filter((t) => t !== ""))
el.appendChild(label)
const actualInputElement = document.createElement("input")
actualInputElement.style.cssText = "display:none"
actualInputElement.type = "file"
actualInputElement.accept = this._acceptType
actualInputElement.name = "picField"
actualInputElement.multiple = this.allowMultiple
actualInputElement.id = "fileselector" + FileSelectorButton._nextid
FileSelectorButton._nextid++
label.htmlFor = actualInputElement.id
actualInputElement.onchange = () => {
if (actualInputElement.files !== null) {
self._value.setData(actualInputElement.files)
}
}
el.addEventListener("submit", (e) => {
if (actualInputElement.files !== null) {
self._value.setData(actualInputElement.files)
}
actualInputElement.classList.remove("glowing-shadow");
e.preventDefault()
})
el.appendChild(actualInputElement)
function setDrawAttention(isOn: boolean){
if(isOn){
label.classList.add("glowing-shadow")
}else{
label.classList.remove("glowing-shadow")
}
}
el.addEventListener("dragover", (event) => {
event.stopPropagation()
event.preventDefault()
setDrawAttention(true)
// Style the drag-and-drop as a "copy file" operation.
event.dataTransfer.dropEffect = "copy"
})
window.document.addEventListener("dragenter", () =>{
setDrawAttention(true)
})
window.document.addEventListener("dragend", () => {
setDrawAttention(false)
})
el.addEventListener("drop", (event) => {
event.stopPropagation()
event.preventDefault()
label.classList.remove("glowing-shadow")
const fileList = event.dataTransfer.files
this._value.setData(fileList)
})
return el
}
}

View file

@ -0,0 +1,46 @@
import { InputElement } from "./InputElement"
import { UIEventSource } from "../../Logic/UIEventSource"
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
/**
* @deprecated
*/
export class FixedInputElement<T> extends InputElement<T> {
private readonly value: UIEventSource<T>
private readonly _comparator: (t0: T, t1: T) => boolean
private readonly _el: HTMLElement
constructor(
rendering: BaseUIElement | string,
value: T | UIEventSource<T>,
comparator: (t0: T, t1: T) => boolean = undefined
) {
super()
this._comparator = comparator ?? ((t0, t1) => t0 == t1)
if (value instanceof UIEventSource) {
this.value = value
} else {
this.value = new UIEventSource<T>(value)
}
this._el = document.createElement("span")
const e = Translations.W(rendering)?.ConstructElement()
if (e) {
this._el.appendChild(e)
}
}
GetValue(): UIEventSource<T> {
return this.value
}
IsValid(t: T): boolean {
return this._comparator(t, this.value.data)
}
protected InnerConstructElement(): HTMLElement {
return this._el
}
}

View file

@ -0,0 +1,18 @@
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import BaseUIElement from "../BaseUIElement"
/**
* @deprecated
*/
export interface ReadonlyInputElement<T> extends BaseUIElement {
GetValue(): Store<T>
IsValid(t: T): boolean
}
/**
* @deprecated
*/
export abstract class InputElement<T> extends BaseUIElement implements ReadonlyInputElement<any> {
abstract GetValue(): UIEventSource<T>
abstract IsValid(t: T): boolean
}

View file

@ -0,0 +1,61 @@
import { InputElement } from "./InputElement"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
/**
* @deprecated
*/
export default class InputElementMap<T, X> extends InputElement<X> {
private readonly _inputElement: InputElement<T>
private isSame: (x0: X, x1: X) => boolean
private readonly fromX: (x: X) => T
private readonly toX: (t: T) => X
private readonly _value: UIEventSource<X>
constructor(
inputElement: InputElement<T>,
isSame: (x0: X, x1: X) => boolean,
toX: (t: T) => X,
fromX: (x: X) => T,
extraSources: Store<any>[] = []
) {
super()
this.isSame = isSame
this.fromX = fromX
this.toX = toX
this._inputElement = inputElement
const self = this
this._value = inputElement.GetValue().sync(
(t) => {
const newX = toX(t)
const currentX = self.GetValue()?.data
if (isSame(currentX, newX)) {
return currentX
}
return newX
},
extraSources,
(x) => {
return fromX(x)
}
)
}
GetValue(): UIEventSource<X> {
return this._value
}
IsValid(x: X): boolean {
if (x === undefined) {
return false
}
const t = this.fromX(x)
if (t === undefined) {
return false
}
return this._inputElement.IsValid(t)
}
protected InnerConstructElement(): HTMLElement {
return this._inputElement.ConstructElement()
}
}

1
src/UI/Input/README.md Normal file
View file

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

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