Refactoring: convert language input element into svelte,remove many obsolete classes

This commit is contained in:
Pieter Vander Vennet 2024-01-25 03:13:18 +01:00
parent e68b31e267
commit 2e8b44659a
36 changed files with 1038 additions and 1412 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,6 +13,8 @@
import { Utils } from "../../../Utils"
import { twMerge } from "tailwind-merge"
import { ariaLabel } from "../../../Utils/ariaLabel"
import EditButton from "./EditButton.svelte"
import EditItemButton from "../../Studio/EditItemButton.svelte"
export let config: TagRenderingConfig
export let tags: UIEventSource<Record<string, string>>
@ -102,16 +104,12 @@
{:else}
<div class="low-interaction flex items-center justify-between overflow-hidden rounded px-2">
<TagRenderingAnswer id={answerId} {config} {tags} {selectedElement} {state} {layer} />
<button
<EditButton
arialabel={config.editButtonAriaLabel}
ariaLabelledBy={answerId}
on:click={() => {
editMode = true
}}
class="secondary h-8 w-8 shrink-0 self-start rounded-full p-1"
aria-labelledby={config.editButtonAriaLabel === undefined ? answerId : undefined}
use:ariaLabel={config.editButtonAriaLabel}
>
<PencilAltIcon />
</button>
editMode = true
}}/>
</div>
{/if}
{:else}

View file

@ -31,11 +31,10 @@ import OpeningHoursVisualization from "./OpeningHours/OpeningHoursVisualization"
import { SubtleButton } from "./Base/SubtleButton"
import Svg from "../Svg"
import NoteCommentElement from "./Popup/Notes/NoteCommentElement"
import { SubstitutedTranslation } from "./SubstitutedTranslation"
import List from "./Base/List"
import StatisticsPanel from "./BigComponents/StatisticsPanel"
import AutoApplyButton from "./Popup/AutoApplyButton"
import { LanguageElement } from "./Popup/LanguageElement"
import { LanguageElement } from "./Popup/LanguageElement/LanguageElement"
import FeatureReviews from "../Logic/Web/MangroveReviews"
import Maproulette from "../Logic/Maproulette"
import SvelteUIElement from "./Base/SvelteUIElement"
@ -88,6 +87,7 @@ import MaprouletteSetStatus from "./MapRoulette/MaprouletteSetStatus.svelte"
import DirectionIndicator from "./Base/DirectionIndicator.svelte"
import Img from "./Base/Img"
import Qr from "../Utils/Qr"
import SpecialTranslation from "./Popup/TagRendering/SpecialTranslation.svelte"
class NearbyImageVis implements SpecialVisualization {
// Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests
@ -1044,20 +1044,28 @@ export default class SpecialVisualizations {
docs: "Shows the title of the popup. Useful for some cases, e.g. 'What is phone number of {title()}?'",
example:
"`What is the phone number of {title()}`, which might automatically become `What is the phone number of XYZ`.",
constr: (state, tagsSource) =>
constr: (
state: SpecialVisualizationState,
tagsSource: UIEventSource<Record<string, string>>,
_: string[],
feature: Feature,
layer: LayerConfig
) =>
new VariableUiElement(
tagsSource.map((tags) => {
if (state.layout === undefined) {
return "<feature title>"
}
const layer = state.layout?.getMatchingLayer(tags)
const title = layer?.title?.GetRenderValue(tags)
if (title === undefined) {
return undefined
}
return new SubstitutedTranslation(title, tagsSource, state).SetClass(
"px-1"
)
return new SvelteUIElement(SpecialTranslation, {
tags: tagsSource,
state,
feature,
layer,
}).SetClass("px-1")
})
),
},
@ -1311,7 +1319,13 @@ export default class SpecialVisualizations {
required: true,
},
],
constr(state, featureTags, args) {
constr(
state: SpecialVisualizationState,
featureTags: UIEventSource<Record<string, string>>,
args: string[],
feature: Feature,
layer: LayerConfig
) {
const [key, tr] = args
const translation = new Translation({ "*": tr })
return new VariableUiElement(
@ -1319,11 +1333,13 @@ export default class SpecialVisualizations {
const properties: object[] = JSON.parse(tags[key])
const elements = []
for (const property of properties) {
const subsTr = new SubstitutedTranslation(
translation,
new UIEventSource<any>(property),
state
)
const subsTr = new SvelteUIElement(SpecialTranslation, {
t: translation,
tags: properties,
state,
feature,
layer,
})
elements.push(subsTr)
}
return new List(elements)

View file

@ -1,118 +0,0 @@
import { UIEventSource } from "../Logic/UIEventSource"
import { Translation } from "./i18n/Translation"
import Locale from "./i18n/Locale"
import { FixedUiElement } from "./Base/FixedUiElement"
import { Utils } from "../Utils"
import { VariableUiElement } from "./Base/VariableUIElement"
import Combine from "./Base/Combine"
import BaseUIElement from "./BaseUIElement"
import LinkToWeblate from "./Base/LinkToWeblate"
import { SpecialVisualization, SpecialVisualizationState } from "./SpecialVisualization"
import SpecialVisualizations from "./SpecialVisualizations"
import { Feature } from "geojson"
import LayerConfig from "../Models/ThemeConfig/LayerConfig"
/**
* @deprecated - use 'SpecialTranslation' instead
*/
export class SubstitutedTranslation extends VariableUiElement {
public constructor(
translation: Translation,
tagsSource: UIEventSource<Record<string, string>>,
state: SpecialVisualizationState,
mapping: Map<
string,
| BaseUIElement
| ((
state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig
) => BaseUIElement)
> = undefined
) {
const extraMappings: SpecialVisualization[] = []
mapping?.forEach((value, key) => {
extraMappings.push({
funcName: key,
constr: typeof value === "function" ? value : () => value,
docs: "Dynamically injected input element",
args: [],
needsUrls: [],
example: "",
})
})
const linkToWeblate =
translation !== undefined
? new LinkToWeblate(translation.context, translation.translations)
: undefined
super(
Locale.language.map((language) => {
let txt = translation?.textFor(language)
if (txt === undefined) {
return undefined
}
mapping?.forEach((_, key) => {
txt = txt.replace(new RegExp(`{${key}}`, "g"), `{${key}()}`)
})
const allElements = SpecialVisualizations.constructSpecification(
txt,
extraMappings
).map((proto) => {
if (typeof proto === "string") {
if (tagsSource === undefined) {
return Utils.SubstituteKeys(proto, undefined)
}
return new VariableUiElement(
tagsSource.map((tags) => Utils.SubstituteKeys(proto, tags))
)
}
const viz: {
func: SpecialVisualization
args: string[]
style: string
} = proto
if (viz === undefined) {
console.error(
"SPECIALRENDERING UNDEFINED for",
tagsSource.data?.id,
"THIS IS REALLY WEIRD"
)
return undefined
}
try {
const feature = state.indexedFeatures.featuresById.data.get(
tagsSource.data.id
)
return viz.func
.constr(
state,
tagsSource,
proto.args.map((t) => SpecialVisualizations.undoEncoding(t)),
feature,
undefined
)
?.SetStyle(proto.style)
} catch (e) {
console.error("SPECIALRENDERING FAILED for", tagsSource.data?.id, e)
return new FixedUiElement(
`Could not generate special rendering for ${
viz.func.funcName
}(${viz.args.join(", ")}) ${e}`
).SetStyle("alert")
}
})
allElements.push(linkToWeblate)
return new Combine(allElements)
})
)
this.SetClass("w-full")
}
}

View file

@ -1,7 +1,16 @@
<script lang="ts">
// Testing grounds
import LanguageElement from "./Popup/LanguageElement/LanguageElement.svelte"
import { UIEventSource } from "../Logic/UIEventSource"
import Icon from "./Map/Icon.svelte"
let tags = new UIEventSource({_country: "Be"})
</script>
<Icon clss="h-16 w-16" icon="heart" color="#ff0000"/>
<LanguageElement
feature={undefined}
item_render={"{language()} is spoken here"}
key="language"
layer={undefined}
question="What languages are spoken here?"
render_all={"Following languages are spoken here: {list()}"} single_render={"Only {language()} is spoken here"} state={undefined} {tags} />

View file

@ -9,8 +9,6 @@ export class Translation extends BaseUIElement {
public readonly translations: Record<string, string>
public readonly context?: string
private _current: Store<string>
private onDestroy: () => void
constructor(translations: string | Record<string, string>, context?: string) {
@ -43,9 +41,11 @@ export class Translation extends BaseUIElement {
console.error(
"Non-string object at",
context,
"of type",
typeof translations[translationsKey],
`for language`,
translationsKey,
`in translation: `,
`. The offending object is: `,
translations[translationsKey],
"\n current translations are: ",
translations
@ -66,9 +66,7 @@ export class Translation extends BaseUIElement {
}
}
get txt(): string {
return this.textFor(Translation.forcedLanguage ?? Locale.language.data)
}
private _current: Store<string>
get current(): Store<string> {
if (!this._current) {
@ -82,6 +80,11 @@ export class Translation extends BaseUIElement {
}
return this._current
}
get txt(): string {
return this.textFor(Translation.forcedLanguage ?? Locale.language.data)
}
static ExtractAllTranslationsFrom(
object: any,
context = ""
@ -368,4 +371,32 @@ export class TypedTranslation<T extends Record<string, any>> extends Translation
return new TypedTranslation<Omit<T, X>>(newTranslations, this.context)
}
PartialSubsTr<K extends string>(
key: string,
replaceWith: Translation
): TypedTranslation<Omit<T, K>> {
const newTranslations: Record<string, string> = {}
const toSearch = "{" + key + "}"
const missingLanguages = new Set<string>(Object.keys(this.translations))
for (const lang in this.translations) {
missingLanguages.delete(lang)
const template = this.translations[lang]
if (lang === "_context") {
newTranslations[lang] = template
continue
}
const v = replaceWith.textFor(lang)
newTranslations[lang] = template.replaceAll(toSearch, v)
}
const baseTemplate = this.textFor("en")
for (const missingLanguage of missingLanguages) {
newTranslations[missingLanguage] = baseTemplate.replaceAll(
toSearch,
replaceWith.textFor(missingLanguage)
)
}
return new TypedTranslation<Omit<T, K>>(newTranslations, this.context)
}
}