forked from MapComplete/MapComplete
Merge branch 'develop' into RobinLinde-patch-10
This commit is contained in:
commit
ff8442f90b
654 changed files with 17365 additions and 15965 deletions
57
src/UI/AllThemesGui.ts
Normal file
57
src/UI/AllThemesGui.ts
Normal 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
26
src/UI/Base/AsyncLazy.ts
Normal 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()
|
||||
}
|
||||
}
|
21
src/UI/Base/BackButton.svelte
Normal file
21
src/UI/Base/BackButton.svelte
Normal 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
25
src/UI/Base/Button.ts
Normal 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
|
||||
}
|
||||
}
|
32
src/UI/Base/CenterFlexedElement.ts
Normal file
32
src/UI/Base/CenterFlexedElement.ts
Normal 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
38
src/UI/Base/ChartJs.ts
Normal 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
|
||||
}
|
||||
}
|
12
src/UI/Base/Checkbox.svelte
Normal file
12
src/UI/Base/Checkbox.svelte
Normal 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
71
src/UI/Base/Combine.ts
Normal 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
|
||||
}
|
||||
}
|
19
src/UI/Base/DivContainer.ts
Normal file
19
src/UI/Base/DivContainer.ts
Normal 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
|
||||
}
|
||||
}
|
89
src/UI/Base/DragInvitation.svelte
Normal file
89
src/UI/Base/DragInvitation.svelte
Normal 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>
|
14
src/UI/Base/Dropdown.svelte
Normal file
14
src/UI/Base/Dropdown.svelte
Normal 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>
|
50
src/UI/Base/FilteredCombine.ts
Normal file
50
src/UI/Base/FilteredCombine.ts
Normal 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]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
33
src/UI/Base/FixedUiElement.ts
Normal file
33
src/UI/Base/FixedUiElement.ts
Normal 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
|
||||
}
|
||||
}
|
38
src/UI/Base/FloatOver.svelte
Normal file
38
src/UI/Base/FloatOver.svelte
Normal 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>
|
19
src/UI/Base/FromHtml.svelte
Normal file
19
src/UI/Base/FromHtml.svelte
Normal 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
135
src/UI/Base/Hotkeys.ts
Normal 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
24
src/UI/Base/If.svelte
Normal 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}
|
34
src/UI/Base/IfHidden.svelte
Normal file
34
src/UI/Base/IfHidden.svelte
Normal 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
20
src/UI/Base/IfNot.svelte
Normal 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
78
src/UI/Base/Img.ts
Normal 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 ""
|
||||
}
|
||||
|
||||
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
15
src/UI/Base/Lazy.ts
Normal 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
64
src/UI/Base/Link.ts
Normal 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
|
||||
}
|
||||
}
|
78
src/UI/Base/LinkToWeblate.ts
Normal file
78
src/UI/Base/LinkToWeblate.ts
Normal 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
52
src/UI/Base/List.ts
Normal 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
|
||||
}
|
||||
}
|
13
src/UI/Base/Loading.svelte
Normal file
13
src/UI/Base/Loading.svelte
Normal 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
18
src/UI/Base/Loading.ts
Normal 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")
|
||||
}
|
||||
}
|
17
src/UI/Base/LoginButton.svelte
Normal file
17
src/UI/Base/LoginButton.svelte
Normal 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>
|
45
src/UI/Base/LoginToggle.svelte
Normal file
45
src/UI/Base/LoginToggle.svelte
Normal 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}
|
17
src/UI/Base/MapControlButton.svelte
Normal file
17
src/UI/Base/MapControlButton.svelte
Normal 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>
|
26
src/UI/Base/ModalRight.svelte
Normal file
26
src/UI/Base/ModalRight.svelte
Normal 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>
|
25
src/UI/Base/NextButton.svelte
Normal file
25
src/UI/Base/NextButton.svelte
Normal 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
30
src/UI/Base/Paragraph.ts
Normal 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
|
||||
}
|
||||
}
|
30
src/UI/Base/ShareButton.svelte
Normal file
30
src/UI/Base/ShareButton.svelte
Normal 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>
|
31
src/UI/Base/SubtleButton.svelte
Normal file
31
src/UI/Base/SubtleButton.svelte
Normal 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>
|
97
src/UI/Base/SubtleButton.ts
Normal file
97
src/UI/Base/SubtleButton.ts
Normal 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
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
49
src/UI/Base/SubtleLink.svelte
Normal file
49
src/UI/Base/SubtleLink.svelte
Normal 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>
|
44
src/UI/Base/SvelteUIElement.ts
Normal file
44
src/UI/Base/SvelteUIElement.ts
Normal 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
|
||||
}
|
||||
}
|
135
src/UI/Base/TabbedGroup.svelte
Normal file
135
src/UI/Base/TabbedGroup.svelte
Normal 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
135
src/UI/Base/Table.ts
Normal 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
|
||||
}
|
||||
}
|
120
src/UI/Base/TableOfContents.ts
Normal file
120
src/UI/Base/TableOfContents.ts
Normal 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
72
src/UI/Base/Title.ts
Normal 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
|
||||
}
|
||||
}
|
21
src/UI/Base/ToSvelte.svelte
Normal file
21
src/UI/Base/ToSvelte.svelte
Normal 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
38
src/UI/Base/Tr.svelte
Normal 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}
|
62
src/UI/Base/VariableUIElement.ts
Normal file
62
src/UI/Base/VariableUIElement.ts
Normal 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
|
||||
}
|
||||
}
|
33
src/UI/Base/WeblateLink.svelte
Normal file
33
src/UI/Base/WeblateLink.svelte
Normal 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
189
src/UI/BaseUIElement.ts
Normal 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
|
||||
}
|
88
src/UI/BigComponents/BackgroundSwitcher.svelte
Normal file
88
src/UI/BigComponents/BackgroundSwitcher.svelte
Normal 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>
|
45
src/UI/BigComponents/CommunityIndexView.svelte
Normal file
45
src/UI/BigComponents/CommunityIndexView.svelte
Normal 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>
|
50
src/UI/BigComponents/ContactLink.svelte
Normal file
50
src/UI/BigComponents/ContactLink.svelte
Normal 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>
|
209
src/UI/BigComponents/CopyrightPanel.ts
Normal file
209
src/UI/BigComponents/CopyrightPanel.ts
Normal 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
|
||||
}
|
||||
}
|
101
src/UI/BigComponents/ExtraLinkButton.ts
Normal file
101
src/UI/BigComponents/ExtraLinkButton.ts
Normal 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
|
||||
}
|
||||
}
|
110
src/UI/BigComponents/Filterview.svelte
Normal file
110
src/UI/BigComponents/Filterview.svelte
Normal 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}
|
61
src/UI/BigComponents/FilterviewWithFields.svelte
Normal file
61
src/UI/BigComponents/FilterviewWithFields.svelte
Normal 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>
|
143
src/UI/BigComponents/GeolocationControl.ts
Normal file
143
src/UI/BigComponents/GeolocationControl.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
116
src/UI/BigComponents/Geosearch.svelte
Normal file
116
src/UI/BigComponents/Geosearch.svelte
Normal 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>
|
50
src/UI/BigComponents/HiddenThemeList.svelte
Normal file
50
src/UI/BigComponents/HiddenThemeList.svelte
Normal 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>
|
154
src/UI/BigComponents/Histogram.ts
Normal file
154
src/UI/BigComponents/Histogram.ts
Normal 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]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
29
src/UI/BigComponents/IndexText.ts
Normal file
29
src/UI/BigComponents/IndexText.ts
Normal 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")
|
||||
}
|
||||
}
|
32
src/UI/BigComponents/LevelSelector.svelte
Normal file
32
src/UI/BigComponents/LevelSelector.svelte
Normal 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}
|
29
src/UI/BigComponents/MapillaryLink.svelte
Normal file
29
src/UI/BigComponents/MapillaryLink.svelte
Normal 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>
|
183
src/UI/BigComponents/MoreScreen.ts
Normal file
183
src/UI/BigComponents/MoreScreen.ts
Normal 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}`)
|
||||
}
|
||||
}
|
117
src/UI/BigComponents/NewPointLocationInput.svelte
Normal file
117
src/UI/BigComponents/NewPointLocationInput.svelte
Normal 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"
|
||||
/>
|
34
src/UI/BigComponents/NoThemeResultButton.svelte
Normal file
34
src/UI/BigComponents/NoThemeResultButton.svelte
Normal 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>
|
21
src/UI/BigComponents/OpenBackgroundSelectorButton.svelte
Normal file
21
src/UI/BigComponents/OpenBackgroundSelectorButton.svelte
Normal 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>
|
32
src/UI/BigComponents/OpenIdEditor.svelte
Normal file
32
src/UI/BigComponents/OpenIdEditor.svelte
Normal 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>
|
59
src/UI/BigComponents/OpenJosm.ts
Normal file
59
src/UI/BigComponents/OpenJosm.ts
Normal 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])
|
||||
}
|
||||
}
|
50
src/UI/BigComponents/OverlayToggle.svelte
Normal file
50
src/UI/BigComponents/OverlayToggle.svelte
Normal 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}
|
127
src/UI/BigComponents/PlantNetSpeciesSearch.ts
Normal file
127
src/UI/BigComponents/PlantNetSpeciesSearch.ts
Normal 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")
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
25
src/UI/BigComponents/PrivacyPolicy.ts
Normal file
25
src/UI/BigComponents/PrivacyPolicy.ts
Normal 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")
|
||||
}
|
||||
}
|
74
src/UI/BigComponents/SelectedElementTitle.svelte
Normal file
74
src/UI/BigComponents/SelectedElementTitle.svelte
Normal 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>
|
54
src/UI/BigComponents/SelectedElementView.svelte
Normal file
54
src/UI/BigComponents/SelectedElementView.svelte
Normal 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}
|
256
src/UI/BigComponents/ShareScreen.ts
Normal file
256
src/UI/BigComponents/ShareScreen.ts
Normal 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'>
|
||||
<iframe src="${url}" allow="geolocation" width="100%" height="100%" style="min-width: 250px; min-height: 250px" title="${
|
||||
layout.title?.txt ?? "MapComplete"
|
||||
} with MapComplete"></iframe>
|
||||
</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")
|
||||
}
|
||||
}
|
21
src/UI/BigComponents/SimpleAddUI.ts
Normal file
21
src/UI/BigComponents/SimpleAddUI.ts
Normal 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
|
||||
}
|
41
src/UI/BigComponents/StateIndicator.svelte
Normal file
41
src/UI/BigComponents/StateIndicator.svelte
Normal 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}
|
55
src/UI/BigComponents/StatisticsPanel.ts
Normal file
55
src/UI/BigComponents/StatisticsPanel.ts
Normal 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]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
381
src/UI/BigComponents/TagRenderingChart.ts
Normal file
381
src/UI/BigComponents/TagRenderingChart.ts
Normal 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 }
|
||||
}
|
||||
}
|
90
src/UI/BigComponents/ThemeButton.svelte
Normal file
90
src/UI/BigComponents/ThemeButton.svelte
Normal 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}
|
102
src/UI/BigComponents/ThemeIntroPanel.svelte
Normal file
102
src/UI/BigComponents/ThemeIntroPanel.svelte
Normal 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>
|
60
src/UI/BigComponents/ThemesList.svelte
Normal file
60
src/UI/BigComponents/ThemesList.svelte
Normal 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>
|
37
src/UI/BigComponents/UnofficialThemeList.svelte
Normal file
37
src/UI/BigComponents/UnofficialThemeList.svelte
Normal 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}
|
151
src/UI/BigComponents/UploadTraceToOsmUI.ts
Normal file
151
src/UI/BigComponents/UploadTraceToOsmUI.ts
Normal 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
|
||||
}
|
||||
}
|
53
src/UI/BigComponents/UserProfile.svelte
Normal file
53
src/UI/BigComponents/UserProfile.svelte
Normal 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(/>/g, ">")
|
||||
?.replace(/</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>
|
106
src/UI/BigComponents/WaySplitMap.svelte
Normal file
106
src/UI/BigComponents/WaySplitMap.svelte
Normal 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>
|
95
src/UI/DownloadFlow/DownloadButton.svelte
Normal file
95
src/UI/DownloadFlow/DownloadButton.svelte
Normal 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}
|
200
src/UI/DownloadFlow/DownloadHelper.ts
Normal file
200
src/UI/DownloadFlow/DownloadHelper.ts
Normal 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
|
||||
}
|
||||
}
|
96
src/UI/DownloadFlow/DownloadPanel.svelte
Normal file
96
src/UI/DownloadFlow/DownloadPanel.svelte
Normal 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}
|
67
src/UI/DownloadFlow/DownloadPdf.svelte
Normal file
67
src/UI/DownloadFlow/DownloadPdf.svelte
Normal 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}
|
||||
/>
|
29
src/UI/Image/AttributedImage.ts
Normal file
29
src/UI/Image/AttributedImage.ts
Normal 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")
|
||||
}
|
||||
}
|
50
src/UI/Image/Attribution.ts
Normal file
50
src/UI/Image/Attribution.ts
Normal 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"
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
71
src/UI/Image/DeleteImage.ts
Normal file
71
src/UI/Image/DeleteImage.ts
Normal 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")
|
||||
}
|
||||
}
|
53
src/UI/Image/ImageCarousel.ts
Normal file
53
src/UI/Image/ImageCarousel.ts
Normal 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")
|
||||
}
|
||||
}
|
202
src/UI/Image/ImageUploadFlow.ts
Normal file
202
src/UI/Image/ImageUploadFlow.ts
Normal 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
48
src/UI/Image/SlideShow.ts
Normal 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
|
||||
}
|
||||
}
|
98
src/UI/Input/Checkboxes.ts
Normal file
98
src/UI/Input/Checkboxes.ts
Normal 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
104
src/UI/Input/DropDown.ts
Normal 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
|
||||
}
|
||||
}
|
115
src/UI/Input/FileSelectorButton.ts
Normal file
115
src/UI/Input/FileSelectorButton.ts
Normal 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
|
||||
}
|
||||
}
|
46
src/UI/Input/FixedInputElement.ts
Normal file
46
src/UI/Input/FixedInputElement.ts
Normal 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
|
||||
}
|
||||
}
|
18
src/UI/Input/InputElement.ts
Normal file
18
src/UI/Input/InputElement.ts
Normal 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
|
||||
}
|
61
src/UI/Input/InputElementMap.ts
Normal file
61
src/UI/Input/InputElementMap.ts
Normal 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
1
src/UI/Input/README.md
Normal 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
Loading…
Add table
Add a link
Reference in a new issue