A11y: screen navigation improvements, see #1181
This commit is contained in:
parent
66369ef0b4
commit
af4d9bb2bf
25 changed files with 483 additions and 325 deletions
|
@ -336,6 +336,7 @@
|
|||
"searchShort": "Search…",
|
||||
"searching": "Searching…"
|
||||
},
|
||||
"searchAnswer": "Search an option…",
|
||||
"share": "Share",
|
||||
"sharescreen": {
|
||||
"copiedToClipboard": "Link copied to clipboard",
|
||||
|
@ -382,6 +383,20 @@
|
|||
"uploadingChanges": "Uploading changes…",
|
||||
"useSearch": "Use the search above to see presets",
|
||||
"useSearchForMore": "Use the search function to search within {total} more values…",
|
||||
"visualFeedback": {
|
||||
"closestFeaturesAre": "Closest features are:",
|
||||
"east": "Moving east",
|
||||
"in": "Zooming in",
|
||||
"islocked": "View locked to your GPS-location, moving disabled. Press the geolocation button to unlock.",
|
||||
"locked": "View is now locked to your GPS-location, moving disabled.",
|
||||
"navigation": "Use arrow keys to move the map, press space to select the closest feature",
|
||||
"noCloseFeatures": "No features in view",
|
||||
"north": "Moving north",
|
||||
"out": "Zooming out",
|
||||
"south": "Moving south",
|
||||
"unlocked": "Moving enabled.",
|
||||
"west": "Moving west"
|
||||
},
|
||||
"waitingForGeopermission": "Waiting for your permission to use the geolocation…",
|
||||
"waitingForLocation": "Searching your current location…",
|
||||
"weekdays": {
|
||||
|
@ -449,6 +464,7 @@
|
|||
"dontDelete": "Cancel",
|
||||
"isDeleted": "Deleted",
|
||||
"nearby": {
|
||||
"close": "Collapse panel with nearby images",
|
||||
"link": "This picture shows the object",
|
||||
"noNearbyImages": "No nearby images were found",
|
||||
"seeNearby": "Browse and link nearby pictures",
|
||||
|
|
|
@ -3400,6 +3400,18 @@
|
|||
"question": "How wide is the gap between the cycleway and the road?",
|
||||
"render": "The buffer besides this cycleway is {cycleway:buffer} m"
|
||||
},
|
||||
"incline": {
|
||||
"mappings": {
|
||||
"0": {
|
||||
"then": "There is (probably) no incline here"
|
||||
},
|
||||
"1": {
|
||||
"then": "This road has a slope"
|
||||
}
|
||||
},
|
||||
"question": "Does {title()} have an incline?",
|
||||
"render": "This road has an slope of {incline}"
|
||||
},
|
||||
"is lit?": {
|
||||
"mappings": {
|
||||
"0": {
|
||||
|
|
|
@ -1342,6 +1342,10 @@ video {
|
|||
resize: both;
|
||||
}
|
||||
|
||||
.list-none {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.appearance-none {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
|
@ -2229,10 +2233,6 @@ body {
|
|||
font-family: "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.focusable {
|
||||
/* Not a 'real' class, but rather an indication to FloatOver and ModalRight to, when they open, grab the focus */
|
||||
}
|
||||
|
||||
svg,
|
||||
img {
|
||||
box-sizing: content-box;
|
||||
|
|
|
@ -267,7 +267,7 @@ export default class UserRelatedState {
|
|||
|
||||
/**
|
||||
* Initialize the 'amended preferences'.
|
||||
* This is inherently a dirty and chaotic method, as it shoves many properties into this EventSourcd
|
||||
* This is inherently a dirty and chaotic method, as it shoves many properties into this EventSource
|
||||
* */
|
||||
private initAmendedPrefs(
|
||||
layout?: LayoutConfig,
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
import { Store, UIEventSource } from "../Logic/UIEventSource"
|
||||
import { BBox } from "../Logic/BBox"
|
||||
import { RasterLayerPolygon } from "./RasterLayers"
|
||||
|
||||
import { B } from "vitest/dist/types-aac763a5"
|
||||
export interface KeyNavigationEvent {
|
||||
date: Date
|
||||
key: "north" | "east" | "south" | "west" | "in" | "out" | "islocked" | "locked" | "unlocked"
|
||||
}
|
||||
export interface MapProperties {
|
||||
readonly location: UIEventSource<{ lon: number; lat: number }>
|
||||
readonly zoom: UIEventSource<number>
|
||||
|
@ -14,7 +18,13 @@ export interface MapProperties {
|
|||
readonly allowRotating: UIEventSource<true | boolean>
|
||||
readonly lastClickLocation: Store<{ lon: number; lat: number }>
|
||||
readonly allowZooming: UIEventSource<true | boolean>
|
||||
readonly lastKeyNavigation: UIEventSource<number>
|
||||
|
||||
/**
|
||||
* Triggered when the user navigated by using the keyboard.
|
||||
* The callback might return 'true' if it wants to be unregistered
|
||||
* @param f
|
||||
*/
|
||||
onKeyNavigationEvent(f: (event: KeyNavigationEvent) => void | boolean): () => void
|
||||
}
|
||||
|
||||
export interface ExportableMap {
|
||||
|
|
|
@ -128,6 +128,11 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
* All 'level'-tags that are available with the current features
|
||||
*/
|
||||
readonly floors: Store<string[]>
|
||||
/**
|
||||
* If true, the user interface will toggle some extra aids for people using screenreaders and keyboard navigation
|
||||
* Triggered by navigating the map with arrows or by pressing 'space' or 'enter'
|
||||
*/
|
||||
public readonly visualFeedback: UIEventSource<boolean> = new UIEventSource<boolean>(false)
|
||||
private readonly newPointDialog: FilteredLayer
|
||||
|
||||
constructor(layout: LayoutConfig) {
|
||||
|
@ -372,6 +377,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
public focusOnMap() {
|
||||
if (this.map.data) {
|
||||
this.map.data.getCanvas().focus()
|
||||
console.log("Focused on map")
|
||||
return
|
||||
}
|
||||
this.map.addCallbackAndRunD((map) => {
|
||||
|
@ -437,6 +443,13 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
* Various small methods that need to be called
|
||||
*/
|
||||
private miscSetup() {
|
||||
this.mapProperties.onKeyNavigationEvent((keyEvent) => {
|
||||
if (["north", "east", "south", "west"].indexOf(keyEvent.key) >= 0) {
|
||||
this.visualFeedback.setData(true)
|
||||
return true // Our job is done, unregister
|
||||
}
|
||||
})
|
||||
|
||||
this.userRelatedState.markLayoutAsVisited(this.layout)
|
||||
|
||||
this.selectedElement.addCallbackAndRunD((feature) => {
|
||||
|
@ -460,7 +473,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
* @private
|
||||
*/
|
||||
private selectClosestAtCenter(i: number = 0) {
|
||||
this.mapProperties.lastKeyNavigation.setData(Date.now() / 1000)
|
||||
this.visualFeedback.setData(true)
|
||||
const toSelect = this.closestFeatures.features.data[i]
|
||||
if (!toSelect) {
|
||||
return
|
||||
|
@ -495,35 +508,30 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
}
|
||||
)
|
||||
|
||||
this.mapProperties.lastKeyNavigation.addCallbackAndRunD((_) => {
|
||||
Hotkeys.RegisterHotkey(
|
||||
{
|
||||
nomod: " ",
|
||||
onUp: true,
|
||||
},
|
||||
Translations.t.hotkeyDocumentation.selectItem,
|
||||
() => this.selectClosestAtCenter(0)
|
||||
)
|
||||
Hotkeys.RegisterHotkey(
|
||||
{
|
||||
nomod: "Spacebar",
|
||||
onUp: true,
|
||||
},
|
||||
Translations.t.hotkeyDocumentation.selectItem,
|
||||
() => this.selectClosestAtCenter(0)
|
||||
)
|
||||
for (let i = 1; i < 9; i++) {
|
||||
Hotkeys.RegisterHotkey(
|
||||
{
|
||||
nomod: "" + i,
|
||||
onUp: true,
|
||||
},
|
||||
Translations.t.hotkeyDocumentation.selectItem,
|
||||
() => this.selectClosestAtCenter(i - 1)
|
||||
)
|
||||
Hotkeys.RegisterHotkey(
|
||||
{
|
||||
nomod: " ",
|
||||
onUp: true,
|
||||
},
|
||||
Translations.t.hotkeyDocumentation.selectItem,
|
||||
() => {
|
||||
if (this.selectedElement.data !== undefined) {
|
||||
return false
|
||||
}
|
||||
this.selectClosestAtCenter(0)
|
||||
}
|
||||
return true // unregister
|
||||
})
|
||||
)
|
||||
|
||||
for (let i = 1; i < 9; i++) {
|
||||
Hotkeys.RegisterHotkey(
|
||||
{
|
||||
nomod: "" + i,
|
||||
onUp: true,
|
||||
},
|
||||
Translations.t.hotkeyDocumentation.selectItem,
|
||||
() => this.selectClosestAtCenter(i - 1)
|
||||
)
|
||||
}
|
||||
|
||||
this.featureSwitches.featureSwitchBackgroundSelection.addCallbackAndRun((enable) => {
|
||||
if (!enable) {
|
||||
|
|
|
@ -11,12 +11,6 @@
|
|||
|
||||
export let extraClasses = "p-4 md:p-6";
|
||||
|
||||
let mainContent: HTMLElement;
|
||||
onMount(() => {
|
||||
requestAnimationFrame(() => {
|
||||
Utils.focusOnFocusableChild(mainContent);
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
@ -31,7 +25,7 @@
|
|||
use:trapFocus
|
||||
style="z-index: 21"
|
||||
>
|
||||
<div bind:this={mainContent} class="content normal-background" on:click|stopPropagation={() => {}}>
|
||||
<div class="content normal-background" on:click|stopPropagation={() => {}}>
|
||||
<div class="h-full rounded-xl">
|
||||
<slot />
|
||||
</div>
|
||||
|
|
|
@ -22,16 +22,13 @@ export default class Hotkeys {
|
|||
}[]
|
||||
>([])
|
||||
|
||||
private static textElementSelected(event: KeyboardEvent): boolean {
|
||||
if (event.ctrlKey || event.altKey) {
|
||||
// This is an event with a modifier-key, lets not ignore it
|
||||
return false
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
return false // Another not-printable character that should not be ignored
|
||||
}
|
||||
return ["input", "textarea"].includes(document?.activeElement?.tagName?.toLowerCase())
|
||||
}
|
||||
/**
|
||||
* Register a hotkey
|
||||
* @param key
|
||||
* @param documentation
|
||||
* @param action the function to run. It might return 'false', indicating that it didn't do anything and gives control back to the default flow
|
||||
* @constructor
|
||||
*/
|
||||
public static RegisterHotkey(
|
||||
key: (
|
||||
| {
|
||||
|
@ -50,7 +47,7 @@ export default class Hotkeys {
|
|||
onUp?: boolean
|
||||
},
|
||||
documentation: string | Translation,
|
||||
action: () => void
|
||||
action: () => void | false
|
||||
) {
|
||||
const type = key["onUp"] ? "keyup" : "keypress"
|
||||
let keycode: string = key["ctrl"] ?? key["shift"] ?? key["alt"] ?? key["nomod"]
|
||||
|
@ -69,8 +66,9 @@ export default class Hotkeys {
|
|||
if (key["ctrl"] !== undefined) {
|
||||
document.addEventListener("keydown", function (event) {
|
||||
if (event.ctrlKey && event.key === keycode) {
|
||||
action()
|
||||
event.preventDefault()
|
||||
if (action() !== false) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (key["shift"] !== undefined) {
|
||||
|
@ -80,15 +78,17 @@ export default class Hotkeys {
|
|||
return
|
||||
}
|
||||
if (event.shiftKey && event.key === keycode) {
|
||||
action()
|
||||
event.preventDefault()
|
||||
if (action() !== false) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (key["alt"] !== undefined) {
|
||||
document.addEventListener(type, function (event) {
|
||||
if (event.altKey && event.key === keycode) {
|
||||
action()
|
||||
event.preventDefault()
|
||||
if (action() !== false) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (key["nomod"] !== undefined) {
|
||||
|
@ -98,8 +98,10 @@ export default class Hotkeys {
|
|||
return
|
||||
}
|
||||
if (event.key === keycode) {
|
||||
action()
|
||||
event.preventDefault()
|
||||
const result = action()
|
||||
if (result !== false) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -113,6 +115,9 @@ export default class Hotkeys {
|
|||
if (keycode.length == 1) {
|
||||
keycode = keycode.toUpperCase()
|
||||
}
|
||||
if (keycode === " ") {
|
||||
keycode = "Spacebar"
|
||||
}
|
||||
modifiers.push(keycode)
|
||||
return <[string, string | Translation]>[modifiers.join("+"), documentation]
|
||||
})
|
||||
|
@ -139,4 +144,15 @@ export default class Hotkeys {
|
|||
static generateDocumentationDynamic(): BaseUIElement {
|
||||
return new VariableUiElement(Hotkeys._docs.map((_) => Hotkeys.generateDocumentation()))
|
||||
}
|
||||
|
||||
private static textElementSelected(event: KeyboardEvent): boolean {
|
||||
if (event.ctrlKey || event.altKey) {
|
||||
// This is an event with a modifier-key, lets not ignore it
|
||||
return false
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
return false // Another not-printable character that should not be ignored
|
||||
}
|
||||
return ["input", "textarea"].includes(document?.activeElement?.tagName?.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<script lang="ts">
|
||||
import Loading from "./Loading.svelte"
|
||||
import type { OsmServiceState } from "../../Logic/Osm/OsmConnection"
|
||||
import { OsmConnection } 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"
|
||||
import Invalid from "../../assets/svg/Invalid.svelte"
|
||||
|
||||
|
@ -35,10 +35,12 @@
|
|||
<Loading />
|
||||
</slot>
|
||||
{:else if $loadingStatus === "error"}
|
||||
<div class="alert max-w-64 flex items-center">
|
||||
<Invalid class="m-2 h-8 w-8 shrink-0" />
|
||||
<Tr t={offlineModes[$apiState]} />
|
||||
</div>
|
||||
<slot name="error">
|
||||
<div class="alert max-w-64 flex items-center">
|
||||
<Invalid class="m-2 h-8 w-8 shrink-0" />
|
||||
<Tr t={offlineModes[$apiState]} />
|
||||
</div>
|
||||
</slot>
|
||||
{:else if $loadingStatus === "logged-in"}
|
||||
<slot />
|
||||
{:else if $loadingStatus === "not-attempted"}
|
||||
|
|
|
@ -1,38 +1,37 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from "svelte";
|
||||
import { XCircleIcon } from "@rgossiaux/svelte-heroicons/solid";
|
||||
import { Utils } from "../../Utils";
|
||||
import { trapFocus } from 'trap-focus-svelte'
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { XCircleIcon } from "@rgossiaux/svelte-heroicons/solid"
|
||||
import { trapFocus } from "trap-focus-svelte"
|
||||
import { Utils } from "../../Utils"
|
||||
|
||||
/**
|
||||
* The slotted element will be shown on the right side
|
||||
*/
|
||||
const dispatch = createEventDispatcher<{ close }>();
|
||||
let mainContent: HTMLElement;
|
||||
const dispatch = createEventDispatcher<{ close }>()
|
||||
let mainContent: HTMLElement
|
||||
|
||||
|
||||
onMount(() => {
|
||||
window.setTimeout(
|
||||
() => Utils.focusOnFocusableChild(mainContent), 250
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
autofocus
|
||||
bind:this={mainContent}
|
||||
use:trapFocus
|
||||
class="absolute top-0 right-0 h-screen w-full overflow-y-auto drop-shadow-2xl md:w-6/12 lg:w-5/12 xl:w-4/12"
|
||||
class="absolute top-0 right-0 h-screen w-full overflow-y-auto drop-shadow-2xl md:w-6/12 lg:w-5/12 xl:w-4/12 normal-background flex flex-col"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
aria-modal="true"
|
||||
style="max-width: 100vw; max-height: 100vh"
|
||||
use:trapFocus
|
||||
>
|
||||
<div class="normal-background m-0 flex flex-col">
|
||||
<slot name="close-button">
|
||||
<button
|
||||
class="absolute right-10 top-10 h-8 w-8 cursor-pointer"
|
||||
on:click={() => dispatch("close")}
|
||||
>
|
||||
<XCircleIcon />
|
||||
</button>
|
||||
</slot>
|
||||
<slot />
|
||||
<slot name="close-button">
|
||||
<button
|
||||
class="absolute right-10 top-10 h-8 w-8 cursor-pointer"
|
||||
on:click={() => dispatch("close")}
|
||||
>
|
||||
<XCircleIcon />
|
||||
</button>
|
||||
</slot>
|
||||
<div role="document" >
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="tabbedgroup flex h-full w-full focusable">
|
||||
<div class="tabbedgroup flex h-full w-full">
|
||||
<TabGroup
|
||||
class="flex h-full w-full flex-col"
|
||||
defaultIndex={1}
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
import { GeoIndexedStoreForLayer } from "../../Logic/FeatureSource/Actors/GeoIndexedStore"
|
||||
import { createEventDispatcher, onDestroy } from "svelte"
|
||||
import { placeholder } from "../../Utils/placeholder"
|
||||
import { SearchIcon } from "@rgossiaux/svelte-heroicons/solid"
|
||||
|
||||
export let perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer> | undefined = undefined
|
||||
export let bounds: UIEventSource<BBox>
|
||||
|
@ -117,7 +118,5 @@
|
|||
/>
|
||||
{/if}
|
||||
</form>
|
||||
<div class="h-6 w-6 self-end" on:click={performSearch}>
|
||||
<ToSvelte construct={Svg.search_svg} />
|
||||
</div>
|
||||
<SearchIcon class="h-6 w-6 self-end" aria-hidden="true" on:click={performSearch}/>
|
||||
</div>
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
<Tr t={Translations.t.general.returnToTheMap} />
|
||||
</button>
|
||||
{:else}
|
||||
<div class="flex h-full w-full flex-col gap-y-2 overflow-y-auto p-1 px-2 focusable" tabindex="-1">
|
||||
<div class="flex h-full w-full flex-col gap-y-2 overflow-y-auto p-1 px-2" tabindex="-1">
|
||||
{#each $knownTagRenderings as config (config.id)}
|
||||
<TagRenderingEditable
|
||||
{tags}
|
||||
|
|
|
@ -1,23 +1,26 @@
|
|||
<script lang="ts">
|
||||
import type { Feature } from "geojson"
|
||||
import type { SpecialVisualizationState } from "../SpecialVisualization"
|
||||
import SelectedElementTitle from "./SelectedElementTitle.svelte"
|
||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
|
||||
import TagRenderingAnswer from "../Popup/TagRendering/TagRenderingAnswer.svelte"
|
||||
|
||||
export let state: SpecialVisualizationState
|
||||
export let feature: Feature
|
||||
export let i: number = undefined
|
||||
let id = feature.properties.id
|
||||
let tags = state.featureProperties.getStore(id)
|
||||
let layer: LayerConfig = state.layout.getMatchingLayer(tags.data)
|
||||
|
||||
function select(){
|
||||
|
||||
function select() {
|
||||
state.selectedElement.setData(undefined)
|
||||
state.selectedLayer.setData(layer)
|
||||
state.selectedElement.setData(feature)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div on:click={() => select()} class="cursor-pointer">
|
||||
<TagRenderingAnswer config={layer.title} selectedElement={feature} {state} {tags} {layer} />
|
||||
</div>
|
||||
<button class="cursor-pointer small" on:click={() => select()}>
|
||||
{#if i !== undefined}
|
||||
<span class="font-bold">{i + 1}.</span>
|
||||
{/if}
|
||||
<TagRenderingAnswer config={layer.title} {layer} selectedElement={feature} {state} {tags} />
|
||||
</button>
|
||||
|
|
41
src/UI/BigComponents/VisualFeedbackPanel.svelte
Normal file
41
src/UI/BigComponents/VisualFeedbackPanel.svelte
Normal file
|
@ -0,0 +1,41 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* The visual feedback panel gives visual (and auditive) feedback on the main map view
|
||||
*/
|
||||
|
||||
import Translations from "../i18n/Translations"
|
||||
import ThemeViewState from "../../Models/ThemeViewState"
|
||||
import Summary from "./Summary.svelte"
|
||||
import Tr from "../Base/Tr.svelte"
|
||||
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||
import type { KeyNavigationEvent } from "../../Models/MapProperties"
|
||||
|
||||
export let state: ThemeViewState
|
||||
let centerFeatures = state.closestFeatures.features
|
||||
|
||||
let lastAction: UIEventSource<KeyNavigationEvent> = new UIEventSource<KeyNavigationEvent>(undefined)
|
||||
state.mapProperties.onKeyNavigationEvent((event) => {
|
||||
lastAction.setData(event)
|
||||
})
|
||||
lastAction.stabilized(750).addCallbackAndRunD(_ => lastAction.setData(undefined))
|
||||
</script>
|
||||
<div aria-live="assertive" class=" interactive p-1" role="alert">
|
||||
|
||||
{#if $lastAction !== undefined}
|
||||
<Tr t={Translations.t.general.visualFeedback[$lastAction.key]} />
|
||||
{:else if $centerFeatures.length === 0}
|
||||
<Tr t={Translations.t.general.visualFeedback.noCloseFeatures} />
|
||||
{:else}
|
||||
<div class="pointer-events-auto">
|
||||
<Tr t={Translations.t.general.visualFeedback.closestFeaturesAre} />
|
||||
<ol class="list-none">
|
||||
{#each $centerFeatures as feat, i (feat.properties.id)}
|
||||
<li class="flex">
|
||||
|
||||
<Summary {state} feature={feat} {i}/>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
|
@ -1,21 +1,21 @@
|
|||
<script lang="ts">
|
||||
import { Store } from "../../Logic/UIEventSource"
|
||||
import type { OsmTags } from "../../Models/OsmFeature"
|
||||
import type { SpecialVisualizationState } from "../SpecialVisualization"
|
||||
import type { P4CPicture } from "../../Logic/Web/NearbyImagesSearch"
|
||||
import AllImageProviders from "../../Logic/ImageProviders/AllImageProviders"
|
||||
import LinkImageAction from "../../Logic/Osm/Actions/LinkImageAction"
|
||||
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"
|
||||
import { Tag } from "../../Logic/Tags/Tag"
|
||||
import { GeoOperations } from "../../Logic/GeoOperations"
|
||||
import type { Feature } from "geojson"
|
||||
import Translations from "../i18n/Translations"
|
||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
|
||||
import type { ProvidedImage } from "../../Logic/ImageProviders/ImageProvider"
|
||||
import AttributedImage from "./AttributedImage.svelte"
|
||||
import SpecialTranslation from "../Popup/TagRendering/SpecialTranslation.svelte"
|
||||
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||
import type { OsmTags } from "../../Models/OsmFeature"
|
||||
import type { SpecialVisualizationState } from "../SpecialVisualization"
|
||||
import type { P4CPicture } from "../../Logic/Web/NearbyImagesSearch"
|
||||
import AllImageProviders from "../../Logic/ImageProviders/AllImageProviders"
|
||||
import LinkImageAction from "../../Logic/Osm/Actions/LinkImageAction"
|
||||
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"
|
||||
import { Tag } from "../../Logic/Tags/Tag"
|
||||
import { GeoOperations } from "../../Logic/GeoOperations"
|
||||
import type { Feature } from "geojson"
|
||||
import Translations from "../i18n/Translations"
|
||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
|
||||
import type { ProvidedImage } from "../../Logic/ImageProviders/ImageProvider"
|
||||
import AttributedImage from "./AttributedImage.svelte"
|
||||
import SpecialTranslation from "../Popup/TagRendering/SpecialTranslation.svelte"
|
||||
|
||||
export let tags: Store<OsmTags>
|
||||
export let tags: UIEventSource<OsmTags>
|
||||
export let lon: number
|
||||
export let lat: number
|
||||
export let state: SpecialVisualizationState
|
||||
|
@ -29,13 +29,13 @@
|
|||
const t = Translations.t.image.nearby
|
||||
const c = [lon, lat]
|
||||
const providedImage: ProvidedImage = {
|
||||
url: image.thumbUrl ?? image.pictureUrl,
|
||||
provider: AllImageProviders.byName(image.provider),
|
||||
date: new Date(image.date),
|
||||
id: Object.values(image.osmTags)[0]
|
||||
url: image.thumbUrl ?? image.pictureUrl,
|
||||
provider: AllImageProviders.byName(image.provider),
|
||||
date: new Date(image.date),
|
||||
id: Object.values(image.osmTags)[0],
|
||||
}
|
||||
let distance = Math.round(
|
||||
GeoOperations.distanceBetween([image.coordinates.lng, image.coordinates.lat], c)
|
||||
GeoOperations.distanceBetween([image.coordinates.lng, image.coordinates.lat], c),
|
||||
)
|
||||
|
||||
$: {
|
||||
|
@ -44,7 +44,7 @@
|
|||
const url = image.osmTags[key]
|
||||
if (isLinked) {
|
||||
const action = new LinkImageAction(currentTags.id, key, url, tags, {
|
||||
theme: tags.data._orig_theme ?? state.layout.id,
|
||||
theme: tags.data._orig_theme ?? state.layout.id,
|
||||
changeType: "link-image",
|
||||
})
|
||||
state.changes.applyAction(action)
|
||||
|
@ -65,7 +65,7 @@
|
|||
|
||||
<div class="flex w-fit shrink-0 flex-col">
|
||||
<div on:click={() => state.previewedImage.setData(providedImage)}>
|
||||
<AttributedImage image={providedImage} imgClass="max-h-64 w-auto"/>
|
||||
<AttributedImage image={providedImage} imgClass="max-h-64 w-auto" />
|
||||
</div>
|
||||
{#if linkable}
|
||||
<label>
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
import { XCircleIcon } from "@babeard/svelte-heroicons/solid";
|
||||
import Camera_plus from "../../assets/svg/Camera_plus.svelte";
|
||||
import LoginToggle from "../Base/LoginToggle.svelte";
|
||||
import { ariaLabel } from "../../Utils/ariaLabel"
|
||||
|
||||
export let tags: Store<OsmTags>;
|
||||
export let state: SpecialVisualizationState;
|
||||
|
@ -26,9 +27,11 @@
|
|||
<LoginToggle {state}>
|
||||
|
||||
{#if expanded}
|
||||
<NearbyImages {tags} {state} {lon} {lat} {feature} {linkable}>
|
||||
<NearbyImages {tags} {state} {lon} {lat} {feature} {linkable} {layer}>
|
||||
<button slot="corner"
|
||||
class="h-6 w-6 cursor-pointer no-image-background p-0 border-none"
|
||||
use:ariaLabel={t.close}
|
||||
|
||||
on:click={() => {
|
||||
expanded = false
|
||||
}}>
|
||||
|
|
|
@ -39,7 +39,6 @@
|
|||
|
||||
|
||||
function handleOrientation(event) {
|
||||
console.debug("Got gyro measurement")
|
||||
gotMeasurement.setData(true)
|
||||
// IF the phone is lying flat, then:
|
||||
// alpha is the compass direction (but not absolute)
|
||||
|
|
|
@ -4,11 +4,12 @@ import { Map as MlMap, SourceSpecification } from "maplibre-gl"
|
|||
import { AvailableRasterLayers, RasterLayerPolygon } from "../../Models/RasterLayers"
|
||||
import { Utils } from "../../Utils"
|
||||
import { BBox } from "../../Logic/BBox"
|
||||
import { ExportableMap, MapProperties } from "../../Models/MapProperties"
|
||||
import { ExportableMap, KeyNavigationEvent, MapProperties } from "../../Models/MapProperties"
|
||||
import SvelteUIElement from "../Base/SvelteUIElement"
|
||||
import MaplibreMap from "./MaplibreMap.svelte"
|
||||
import { RasterLayerProperties } from "../../Models/RasterLayerProperties"
|
||||
import * as htmltoimage from "html-to-image"
|
||||
import { ALL } from "node:dns"
|
||||
|
||||
/**
|
||||
* The 'MapLibreAdaptor' bridges 'MapLibre' with the various properties of the `MapProperties`
|
||||
|
@ -40,12 +41,13 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
readonly lastClickLocation: Store<undefined | { lon: number; lat: number }>
|
||||
readonly minzoom: UIEventSource<number>
|
||||
readonly maxzoom: UIEventSource<number>
|
||||
|
||||
/**
|
||||
* When was the last navigation by arrow keys?
|
||||
* If set, this is a hint to use arrow compatibility
|
||||
* Number of _seconds_ since epoch
|
||||
* Functions that are called when one of those actions has happened
|
||||
* @private
|
||||
*/
|
||||
readonly lastKeyNavigation: UIEventSource<number> = new UIEventSource<number>(undefined)
|
||||
private _onKeyNavigation: ((event: KeyNavigationEvent) => void | boolean)[] = []
|
||||
|
||||
private readonly _maplibreMap: Store<MLMap>
|
||||
/**
|
||||
* Used for internal bookkeeping (to remove a rasterLayer when done loading)
|
||||
|
@ -132,13 +134,32 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
handleClick(e)
|
||||
})
|
||||
map.getContainer().addEventListener("keydown", (event) => {
|
||||
if (
|
||||
event.key === "ArrowRight" ||
|
||||
event.key === "ArrowLeft" ||
|
||||
event.key === "ArrowUp" ||
|
||||
event.key === "ArrowDown"
|
||||
) {
|
||||
this.lastKeyNavigation.setData(Date.now() / 1000)
|
||||
let locked: "islocked" = undefined
|
||||
if (!this.allowMoving.data) {
|
||||
locked = "islocked"
|
||||
}
|
||||
switch (event.key) {
|
||||
case "ArrowUp":
|
||||
this.pingKeycodeEvent(locked ?? "north")
|
||||
break
|
||||
case "ArrowRight":
|
||||
this.pingKeycodeEvent(locked ?? "east")
|
||||
break
|
||||
case "ArrowDown":
|
||||
this.pingKeycodeEvent(locked ?? "south")
|
||||
break
|
||||
case "ArrowLeft":
|
||||
this.pingKeycodeEvent(locked ?? "west")
|
||||
break
|
||||
case "+":
|
||||
this.pingKeycodeEvent("in")
|
||||
break
|
||||
case "=":
|
||||
this.pingKeycodeEvent("in")
|
||||
break
|
||||
case "-":
|
||||
this.pingKeycodeEvent("out")
|
||||
break
|
||||
}
|
||||
})
|
||||
})
|
||||
|
@ -154,7 +175,10 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
})
|
||||
this.zoom.addCallbackAndRunD((z) => self.SetZoom(z))
|
||||
this.maxbounds.addCallbackAndRun((bbox) => self.setMaxBounds(bbox))
|
||||
this.allowMoving.addCallbackAndRun((allowMoving) => self.setAllowMoving(allowMoving))
|
||||
this.allowMoving.addCallbackAndRun((allowMoving) => {
|
||||
self.setAllowMoving(allowMoving)
|
||||
self.pingKeycodeEvent(allowMoving ? "unlocked" : "locked")
|
||||
})
|
||||
this.allowRotating.addCallbackAndRunD((allowRotating) =>
|
||||
self.setAllowRotating(allowRotating)
|
||||
)
|
||||
|
@ -240,6 +264,13 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
})
|
||||
}
|
||||
|
||||
public onKeyNavigationEvent(f: (event: KeyNavigationEvent) => void | boolean) {
|
||||
this._onKeyNavigation.push(f)
|
||||
return () => {
|
||||
this._onKeyNavigation.splice(this._onKeyNavigation.indexOf(f), 1)
|
||||
}
|
||||
}
|
||||
|
||||
public async exportAsPng(
|
||||
rescaleIcons: number = 1,
|
||||
progress: UIEventSource<{ current: number; total: number }> = undefined
|
||||
|
@ -268,6 +299,24 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
return await MapLibreAdaptor.toBlob(drawOn)
|
||||
}
|
||||
|
||||
private pingKeycodeEvent(
|
||||
key: "north" | "east" | "south" | "west" | "in" | "out" | "islocked" | "locked" | "unlocked"
|
||||
) {
|
||||
const event = {
|
||||
date: new Date(),
|
||||
key: key,
|
||||
}
|
||||
|
||||
for (let i = 0; i < this._onKeyNavigation.length; i++) {
|
||||
const f = this._onKeyNavigation[i]
|
||||
const unregister = f(event)
|
||||
if (unregister === true) {
|
||||
this._onKeyNavigation.splice(i, 1)
|
||||
i--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the background map and lines to PNG.
|
||||
* Markers are _not_ rendered
|
||||
|
@ -373,7 +422,6 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
|
||||
for (const label of labels) {
|
||||
if (isDisplayed(label)) {
|
||||
console.log("Exporting label", label)
|
||||
await this.drawElement(drawOn, <HTMLElement>label, rescaleIcons, pixelRatio)
|
||||
}
|
||||
}
|
||||
|
@ -565,16 +613,17 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
map.rotateTo(0, { duration: 0 })
|
||||
map.setPitch(0)
|
||||
map.dragRotate.disable()
|
||||
map.keyboard.disableRotation()
|
||||
map.touchZoomRotate.disableRotation()
|
||||
} else {
|
||||
map.dragRotate.enable()
|
||||
map.keyboard.enableRotation()
|
||||
map.touchZoomRotate.enableRotation()
|
||||
}
|
||||
}
|
||||
|
||||
private setAllowMoving(allow: true | boolean | undefined) {
|
||||
const map = this._maplibreMap.data
|
||||
console.log("Setting 'allowMoving' to", allow)
|
||||
if (!map) {
|
||||
return
|
||||
}
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
import { get, writable } from "svelte/store"
|
||||
import { AvailableRasterLayers } from "../../Models/RasterLayers"
|
||||
import { Utils } from "../../Utils"
|
||||
import { ariaLabel } from "../../Utils/ariaLabel"
|
||||
import Translations from "../i18n/Translations"
|
||||
|
||||
/**
|
||||
* The 'MaplibreMap' maps various event sources onto MapLibre.
|
||||
|
@ -19,7 +21,6 @@
|
|||
|
||||
let container: HTMLElement
|
||||
|
||||
export let attribution = false
|
||||
export let center: { lng: number; lat: number } | Readable<{ lng: number; lat: number }> =
|
||||
writable({ lng: 0, lat: 0 })
|
||||
export let zoom: Readable<number> = writable(1)
|
||||
|
@ -49,6 +50,9 @@
|
|||
})
|
||||
_map.on("load", function() {
|
||||
_map.resize()
|
||||
const canvas = _map.getCanvas()
|
||||
ariaLabel(canvas, Translations.t.general.visualFeedback.navigation)
|
||||
canvas.role="application"
|
||||
})
|
||||
map.set(_map)
|
||||
})
|
||||
|
@ -57,6 +61,8 @@
|
|||
if (_map) _map.remove()
|
||||
map = null
|
||||
})
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
|
@ -1,41 +1,42 @@
|
|||
<script lang="ts">
|
||||
import TagRenderingConfig from "../../../Models/ThemeConfig/TagRenderingConfig";
|
||||
import { Store, UIEventSource } from "../../../Logic/UIEventSource";
|
||||
import type { Feature } from "geojson";
|
||||
import type { SpecialVisualizationState } from "../../SpecialVisualization";
|
||||
import TagRenderingAnswer from "./TagRenderingAnswer.svelte";
|
||||
import { PencilAltIcon, XCircleIcon } from "@rgossiaux/svelte-heroicons/solid";
|
||||
import TagRenderingQuestion from "./TagRenderingQuestion.svelte";
|
||||
import { onDestroy } from "svelte";
|
||||
import Tr from "../../Base/Tr.svelte";
|
||||
import Translations from "../../i18n/Translations.js";
|
||||
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig";
|
||||
import { Utils } from "../../../Utils";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import TagRenderingConfig from "../../../Models/ThemeConfig/TagRenderingConfig"
|
||||
import { Store, UIEventSource } from "../../../Logic/UIEventSource"
|
||||
import type { Feature } from "geojson"
|
||||
import type { SpecialVisualizationState } from "../../SpecialVisualization"
|
||||
import TagRenderingAnswer from "./TagRenderingAnswer.svelte"
|
||||
import { PencilAltIcon, XCircleIcon } from "@rgossiaux/svelte-heroicons/solid"
|
||||
import TagRenderingQuestion from "./TagRenderingQuestion.svelte"
|
||||
import { onDestroy } from "svelte"
|
||||
import Tr from "../../Base/Tr.svelte"
|
||||
import Translations from "../../i18n/Translations.js"
|
||||
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
|
||||
import { Utils } from "../../../Utils"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { ariaLabel } from "../../../Utils/ariaLabel"
|
||||
|
||||
export let config: TagRenderingConfig;
|
||||
export let tags: UIEventSource<Record<string, string>>;
|
||||
export let selectedElement: Feature | undefined;
|
||||
export let state: SpecialVisualizationState;
|
||||
export let layer: LayerConfig = undefined;
|
||||
export let config: TagRenderingConfig
|
||||
export let tags: UIEventSource<Record<string, string>>
|
||||
export let selectedElement: Feature | undefined
|
||||
export let state: SpecialVisualizationState
|
||||
export let layer: LayerConfig = undefined
|
||||
|
||||
export let editingEnabled: Store<boolean> | undefined = state?.featureSwitchUserbadge;
|
||||
export let editingEnabled: Store<boolean> | undefined = state?.featureSwitchUserbadge
|
||||
|
||||
export let highlightedRendering: UIEventSource<string> = undefined;
|
||||
export let clss;
|
||||
export let highlightedRendering: UIEventSource<string> = undefined
|
||||
export let clss
|
||||
/**
|
||||
* Indicates if this tagRendering currently shows the attribute or asks the question to _change_ the property
|
||||
*/
|
||||
export let editMode = !config.IsKnown(tags.data); // || showQuestionIfUnknown;
|
||||
export let editMode = !config.IsKnown(tags.data) // || showQuestionIfUnknown;
|
||||
if (tags) {
|
||||
onDestroy(
|
||||
tags.addCallbackD((tags) => {
|
||||
editMode = !config.IsKnown(tags);
|
||||
})
|
||||
);
|
||||
editMode = !config.IsKnown(tags)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
let htmlElem: HTMLDivElement;
|
||||
let htmlElem: HTMLDivElement
|
||||
$: {
|
||||
if (editMode && htmlElem !== undefined && config.IsKnown(tags)) {
|
||||
// EditMode switched to true yet the answer is already known, so the person wants to make a change
|
||||
|
@ -43,43 +44,44 @@
|
|||
|
||||
// Some delay is applied to give Svelte the time to render the _question_
|
||||
window.setTimeout(() => {
|
||||
Utils.scrollIntoView(<any>htmlElem);
|
||||
}, 50);
|
||||
Utils.scrollIntoView(<any>htmlElem)
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
const _htmlElement = new UIEventSource<HTMLElement>(undefined);
|
||||
$: _htmlElement.setData(htmlElem);
|
||||
const _htmlElement = new UIEventSource<HTMLElement>(undefined)
|
||||
$: _htmlElement.setData(htmlElem)
|
||||
|
||||
function setHighlighting() {
|
||||
if (highlightedRendering === undefined) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
if (htmlElem === undefined) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
const highlighted = highlightedRendering.data;
|
||||
const highlighted = highlightedRendering.data
|
||||
if (config.id === highlighted) {
|
||||
htmlElem.classList.add("glowing-shadow");
|
||||
htmlElem.tabIndex = "-1";
|
||||
console.log("Scrolling to", htmlElem);
|
||||
htmlElem.scrollIntoView({ behavior: "smooth" });
|
||||
Utils.focusOnFocusableChild(htmlElem);
|
||||
htmlElem.classList.add("glowing-shadow")
|
||||
htmlElem.tabIndex = -1
|
||||
console.log("Scrolling to", htmlElem)
|
||||
htmlElem.scrollIntoView({ behavior: "smooth" })
|
||||
Utils.focusOnFocusableChild(htmlElem)
|
||||
} else {
|
||||
htmlElem.classList.remove("glowing-shadow");
|
||||
htmlElem.classList.remove("glowing-shadow")
|
||||
}
|
||||
}
|
||||
|
||||
if (highlightedRendering) {
|
||||
onDestroy(highlightedRendering?.addCallbackAndRun(() => setHighlighting()));
|
||||
onDestroy(_htmlElement.addCallbackAndRun(() => setHighlighting()));
|
||||
onDestroy(highlightedRendering?.addCallbackAndRun(() => setHighlighting()))
|
||||
onDestroy(_htmlElement.addCallbackAndRun(() => setHighlighting()))
|
||||
}
|
||||
let answerId = "answer-"+Utils.randomString(5)
|
||||
</script>
|
||||
|
||||
<div bind:this={htmlElem} class={twMerge(clss, "tr-"+config.id)}>
|
||||
{#if config.question && (!editingEnabled || $editingEnabled)}
|
||||
{#if editMode}
|
||||
<TagRenderingQuestion {config} {tags} {selectedElement} {state} {layer}>
|
||||
<TagRenderingQuestion {config} {tags} {selectedElement} {state} {layer} on:saved={() => editMode = false}>
|
||||
<button
|
||||
slot="cancel"
|
||||
class="secondary"
|
||||
|
@ -91,6 +93,7 @@
|
|||
</button>
|
||||
<button slot="upper-right"
|
||||
class="h-8 w-8 cursor-pointer border-none p-0"
|
||||
use:ariaLabel={Translations.t.general.cancel}
|
||||
on:click={() => {
|
||||
editMode = false
|
||||
}}>
|
||||
|
@ -99,12 +102,15 @@
|
|||
</TagRenderingQuestion>
|
||||
{:else}
|
||||
<div class="low-interaction flex items-center justify-between overflow-hidden rounded px-2">
|
||||
<TagRenderingAnswer {config} {tags} {selectedElement} {state} {layer} />
|
||||
<div id={answerId}>
|
||||
<TagRenderingAnswer {config} {tags} {selectedElement} {state} {layer} />
|
||||
</div>
|
||||
<button
|
||||
on:click={() => {
|
||||
editMode = true
|
||||
}}
|
||||
class="secondary h-8 w-8 shrink-0 self-start rounded-full p-1"
|
||||
aria-labelledby={answerId}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</button>
|
||||
|
|
|
@ -1,59 +1,60 @@
|
|||
<script lang="ts">
|
||||
import { ImmutableStore, UIEventSource } from "../../../Logic/UIEventSource";
|
||||
import type { SpecialVisualizationState } from "../../SpecialVisualization";
|
||||
import Tr from "../../Base/Tr.svelte";
|
||||
import type { Feature } from "geojson";
|
||||
import type { Mapping } from "../../../Models/ThemeConfig/TagRenderingConfig";
|
||||
import TagRenderingConfig from "../../../Models/ThemeConfig/TagRenderingConfig";
|
||||
import { TagsFilter } from "../../../Logic/Tags/TagsFilter";
|
||||
import FreeformInput from "./FreeformInput.svelte";
|
||||
import Translations from "../../i18n/Translations.js";
|
||||
import ChangeTagAction from "../../../Logic/Osm/Actions/ChangeTagAction";
|
||||
import { createEventDispatcher, onDestroy } from "svelte";
|
||||
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig";
|
||||
import SpecialTranslation from "./SpecialTranslation.svelte";
|
||||
import TagHint from "../TagHint.svelte";
|
||||
import LoginToggle from "../../Base/LoginToggle.svelte";
|
||||
import SubtleButton from "../../Base/SubtleButton.svelte";
|
||||
import Loading from "../../Base/Loading.svelte";
|
||||
import TagRenderingMappingInput from "./TagRenderingMappingInput.svelte";
|
||||
import { Translation } from "../../i18n/Translation";
|
||||
import Constants from "../../../Models/Constants";
|
||||
import { Unit } from "../../../Models/Unit";
|
||||
import UserRelatedState from "../../../Logic/State/UserRelatedState";
|
||||
import { twJoin } from "tailwind-merge";
|
||||
import { TagUtils } from "../../../Logic/Tags/TagUtils";
|
||||
import Search from "../../../assets/svg/Search.svelte";
|
||||
import Login from "../../../assets/svg/Login.svelte";
|
||||
import { ImmutableStore, UIEventSource } from "../../../Logic/UIEventSource"
|
||||
import type { SpecialVisualizationState } from "../../SpecialVisualization"
|
||||
import Tr from "../../Base/Tr.svelte"
|
||||
import type { Feature } from "geojson"
|
||||
import type { Mapping } from "../../../Models/ThemeConfig/TagRenderingConfig"
|
||||
import TagRenderingConfig from "../../../Models/ThemeConfig/TagRenderingConfig"
|
||||
import { TagsFilter } from "../../../Logic/Tags/TagsFilter"
|
||||
import FreeformInput from "./FreeformInput.svelte"
|
||||
import Translations from "../../i18n/Translations.js"
|
||||
import ChangeTagAction from "../../../Logic/Osm/Actions/ChangeTagAction"
|
||||
import { createEventDispatcher, onDestroy } from "svelte"
|
||||
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
|
||||
import SpecialTranslation from "./SpecialTranslation.svelte"
|
||||
import TagHint from "../TagHint.svelte"
|
||||
import LoginToggle from "../../Base/LoginToggle.svelte"
|
||||
import SubtleButton from "../../Base/SubtleButton.svelte"
|
||||
import Loading from "../../Base/Loading.svelte"
|
||||
import TagRenderingMappingInput from "./TagRenderingMappingInput.svelte"
|
||||
import { Translation } from "../../i18n/Translation"
|
||||
import Constants from "../../../Models/Constants"
|
||||
import { Unit } from "../../../Models/Unit"
|
||||
import UserRelatedState from "../../../Logic/State/UserRelatedState"
|
||||
import { twJoin } from "tailwind-merge"
|
||||
import { TagUtils } from "../../../Logic/Tags/TagUtils"
|
||||
import Search from "../../../assets/svg/Search.svelte"
|
||||
import Login from "../../../assets/svg/Login.svelte"
|
||||
import { placeholder } from "../../../Utils/placeholder"
|
||||
|
||||
export let config: TagRenderingConfig;
|
||||
export let tags: UIEventSource<Record<string, string>>;
|
||||
export let selectedElement: Feature;
|
||||
export let state: SpecialVisualizationState;
|
||||
export let layer: LayerConfig | undefined;
|
||||
export let selectedTags: TagsFilter = undefined;
|
||||
export let config: TagRenderingConfig
|
||||
export let tags: UIEventSource<Record<string, string>>
|
||||
export let selectedElement: Feature
|
||||
export let state: SpecialVisualizationState
|
||||
export let layer: LayerConfig | undefined
|
||||
export let selectedTags: TagsFilter = undefined
|
||||
|
||||
let feedback: UIEventSource<Translation> = new UIEventSource<Translation>(undefined);
|
||||
let feedback: UIEventSource<Translation> = new UIEventSource<Translation>(undefined)
|
||||
|
||||
let unit: Unit = layer?.units?.find((unit) => unit.appliesToKeys.has(config.freeform?.key));
|
||||
let unit: Unit = layer?.units?.find((unit) => unit.appliesToKeys.has(config.freeform?.key))
|
||||
|
||||
// Will be bound if a freeform is available
|
||||
let freeformInput = new UIEventSource<string>(tags?.[config.freeform?.key]);
|
||||
let selectedMapping: number = undefined;
|
||||
let freeformInput = new UIEventSource<string>(tags?.[config.freeform?.key])
|
||||
let selectedMapping: number = undefined
|
||||
/**
|
||||
* A list of booleans, used if multiAnswer is set
|
||||
*/
|
||||
let checkedMappings: boolean[];
|
||||
let checkedMappings: boolean[]
|
||||
|
||||
let mappings: Mapping[] = config?.mappings;
|
||||
let searchTerm: UIEventSource<string> = new UIEventSource("");
|
||||
let mappings: Mapping[] = config?.mappings
|
||||
let searchTerm: UIEventSource<string> = new UIEventSource("")
|
||||
|
||||
let dispatch = createEventDispatcher<{
|
||||
saved: {
|
||||
config: TagRenderingConfig
|
||||
applied: TagsFilter
|
||||
}
|
||||
}>();
|
||||
}>()
|
||||
|
||||
/**
|
||||
* Prepares and fills the checkedMappings
|
||||
|
@ -61,12 +62,12 @@
|
|||
function initialize(tgs: Record<string, string>, confg: TagRenderingConfig) {
|
||||
mappings = confg.mappings?.filter((m) => {
|
||||
if (typeof m.hideInAnswer === "boolean") {
|
||||
return !m.hideInAnswer;
|
||||
return !m.hideInAnswer
|
||||
}
|
||||
return !m.hideInAnswer.matchesProperties(tgs);
|
||||
});
|
||||
return !m.hideInAnswer.matchesProperties(tgs)
|
||||
})
|
||||
// We received a new config -> reinit
|
||||
unit = layer?.units?.find((unit) => unit.appliesToKeys.has(config.freeform?.key));
|
||||
unit = layer?.units?.find((unit) => unit.appliesToKeys.has(config.freeform?.key))
|
||||
|
||||
if (
|
||||
confg.mappings?.length > 0 &&
|
||||
|
@ -74,55 +75,55 @@
|
|||
(checkedMappings === undefined ||
|
||||
checkedMappings?.length < confg.mappings.length + (confg.freeform ? 1 : 0))
|
||||
) {
|
||||
const seenFreeforms = [];
|
||||
const seenFreeforms = []
|
||||
// Initial setup of the mappings
|
||||
checkedMappings = [
|
||||
...confg.mappings.map((mapping) => {
|
||||
if(mapping.hideInAnswer === true){
|
||||
if (mapping.hideInAnswer === true) {
|
||||
return false
|
||||
}
|
||||
const matches = TagUtils.MatchesMultiAnswer(mapping.if, tgs);
|
||||
const matches = TagUtils.MatchesMultiAnswer(mapping.if, tgs)
|
||||
if (matches && confg.freeform) {
|
||||
const newProps = TagUtils.changeAsProperties(mapping.if.asChange({}));
|
||||
seenFreeforms.push(newProps[confg.freeform.key]);
|
||||
const newProps = TagUtils.changeAsProperties(mapping.if.asChange({}))
|
||||
seenFreeforms.push(newProps[confg.freeform.key])
|
||||
}
|
||||
return matches;
|
||||
})
|
||||
];
|
||||
return matches
|
||||
}),
|
||||
]
|
||||
|
||||
if (tgs !== undefined && confg.freeform) {
|
||||
const unseenFreeformValues = tgs[confg.freeform.key]?.split(";") ?? [];
|
||||
const unseenFreeformValues = tgs[confg.freeform.key]?.split(";") ?? []
|
||||
for (const seenFreeform of seenFreeforms) {
|
||||
if (!seenFreeform) {
|
||||
continue;
|
||||
continue
|
||||
}
|
||||
const index = unseenFreeformValues.indexOf(seenFreeform);
|
||||
const index = unseenFreeformValues.indexOf(seenFreeform)
|
||||
if (index < 0) {
|
||||
continue;
|
||||
continue
|
||||
}
|
||||
unseenFreeformValues.splice(index, 1);
|
||||
unseenFreeformValues.splice(index, 1)
|
||||
}
|
||||
// TODO this has _to much_ values
|
||||
freeformInput.setData(unseenFreeformValues.join(";"));
|
||||
checkedMappings.push(unseenFreeformValues.length > 0);
|
||||
freeformInput.setData(unseenFreeformValues.join(";"))
|
||||
checkedMappings.push(unseenFreeformValues.length > 0)
|
||||
}
|
||||
}
|
||||
if (confg.freeform?.key) {
|
||||
if (!confg.multiAnswer) {
|
||||
// Somehow, setting multi-answer freeform values is broken if this is not set
|
||||
freeformInput.setData(tgs[confg.freeform.key]);
|
||||
freeformInput.setData(tgs[confg.freeform.key])
|
||||
}
|
||||
|
||||
} else {
|
||||
freeformInput.setData(undefined);
|
||||
freeformInput.setData(undefined)
|
||||
}
|
||||
feedback.setData(undefined);
|
||||
feedback.setData(undefined)
|
||||
}
|
||||
|
||||
$: {
|
||||
// Even though 'config' is not declared as a store, Svelte uses it as one to update the component
|
||||
// We want to (re)-initialize whenever the 'tags' or 'config' change - but not when 'checkedConfig' changes
|
||||
initialize($tags, config);
|
||||
initialize($tags, config)
|
||||
}
|
||||
|
||||
$: {
|
||||
|
@ -131,54 +132,55 @@
|
|||
$freeformInput,
|
||||
selectedMapping,
|
||||
checkedMappings,
|
||||
tags.data
|
||||
);
|
||||
tags.data,
|
||||
)
|
||||
} catch (e) {
|
||||
console.error("Could not calculate changeSpecification:", e);
|
||||
selectedTags = undefined;
|
||||
console.error("Could not calculate changeSpecification:", e)
|
||||
selectedTags = undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function onSave() {
|
||||
if (selectedTags === undefined) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
if (layer === undefined || (layer?.source === null && layer.id !== "favourite")) {
|
||||
/**
|
||||
* This is a special, priviliged layer.
|
||||
* We simply apply the tags onto the records
|
||||
*/
|
||||
const kv = selectedTags.asChange(tags.data);
|
||||
const kv = selectedTags.asChange(tags.data)
|
||||
for (const { k, v } of kv) {
|
||||
if (v === undefined || v === "") {
|
||||
delete tags.data[k];
|
||||
if (v === undefined) {
|
||||
// Note: we _only_ delete if it is undefined. We _leave_ the empty string and assign it, so that data consumers get correct information
|
||||
delete tags.data[k]
|
||||
} else {
|
||||
tags.data[k] = v
|
||||
}
|
||||
feedback.setData(undefined);
|
||||
feedback.setData(undefined)
|
||||
}
|
||||
tags.ping()
|
||||
return;
|
||||
return
|
||||
}
|
||||
dispatch("saved", { config, applied: selectedTags });
|
||||
dispatch("saved", { config, applied: selectedTags })
|
||||
const change = new ChangeTagAction(tags.data.id, selectedTags, tags.data, {
|
||||
theme: tags.data["_orig_theme"] ?? state.layout.id,
|
||||
changeType: "answer"
|
||||
});
|
||||
freeformInput.setData(undefined);
|
||||
selectedMapping = undefined;
|
||||
selectedTags = undefined;
|
||||
changeType: "answer",
|
||||
})
|
||||
freeformInput.setData(undefined)
|
||||
selectedMapping = undefined
|
||||
selectedTags = undefined
|
||||
|
||||
change
|
||||
.CreateChangeDescriptions()
|
||||
.then((changes) => state.changes.applyChanges(changes))
|
||||
.catch(console.error);
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
function onInputKeypress(e: Event) {
|
||||
function onInputKeypress(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
onSave();
|
||||
onSave()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -188,28 +190,28 @@
|
|||
$freeformInput,
|
||||
selectedMapping,
|
||||
checkedMappings,
|
||||
tags.data
|
||||
);
|
||||
tags.data,
|
||||
)
|
||||
} catch (e) {
|
||||
console.error("Could not calculate changeSpecification:", e);
|
||||
selectedTags = undefined;
|
||||
console.error("Could not calculate changeSpecification:", e)
|
||||
selectedTags = undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let featureSwitchIsTesting = state?.featureSwitchIsTesting ?? new ImmutableStore(false);
|
||||
let featureSwitchIsTesting = state?.featureSwitchIsTesting ?? new ImmutableStore(false)
|
||||
let featureSwitchIsDebugging =
|
||||
state?.featureSwitches?.featureSwitchIsDebugging ?? new ImmutableStore(false);
|
||||
let showTags = state?.userRelatedState?.showTags ?? new ImmutableStore(undefined);
|
||||
let numberOfCs = state?.osmConnection?.userDetails?.data?.csCount ?? 0;
|
||||
let question = config.question;
|
||||
$: question = config.question;
|
||||
state?.featureSwitches?.featureSwitchIsDebugging ?? new ImmutableStore(false)
|
||||
let showTags = state?.userRelatedState?.showTags ?? new ImmutableStore(undefined)
|
||||
let numberOfCs = state?.osmConnection?.userDetails?.data?.csCount ?? 0
|
||||
let question = config.question
|
||||
$: question = config.question
|
||||
if (state?.osmConnection) {
|
||||
onDestroy(
|
||||
state.osmConnection?.userDetails?.addCallbackAndRun((ud) => {
|
||||
numberOfCs = ud.csCount;
|
||||
})
|
||||
);
|
||||
numberOfCs = ud.csCount
|
||||
}),
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -219,7 +221,7 @@
|
|||
style="max-height: 75vh"
|
||||
>
|
||||
<div class="sticky top-0 interactive pt-1 flex justify-between" style="z-index: 11">
|
||||
<span class="font-bold">
|
||||
<span class="font-bold" aria-live="assertive">
|
||||
<SpecialTranslation t={question} {tags} {state} {layer} feature={selectedElement} />
|
||||
</span>
|
||||
<slot name="upper-right" />
|
||||
|
@ -238,9 +240,10 @@
|
|||
{/if}
|
||||
|
||||
{#if config.mappings?.length >= 8}
|
||||
<div class="sticky flex w-full">
|
||||
<div class="sticky flex w-full" aria-hidden="true">
|
||||
<Search class="h-6 w-6" />
|
||||
<input type="text" bind:value={$searchTerm} class="w-full" />
|
||||
<input type="text" bind:value={$searchTerm} class="w-full"
|
||||
use:placeholder={Translations.t.general.searchAnswer} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
@ -351,7 +354,7 @@
|
|||
<Tr t={Translations.t.general.loginToStart} slot="message" />
|
||||
</SubtleButton>
|
||||
{#if $feedback !== undefined}
|
||||
<div class="alert">
|
||||
<div class="alert" aria-live="assertive" role="alert">
|
||||
<Tr t={$feedback} />
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
@ -48,7 +48,6 @@
|
|||
import UploadingImageCounter from "./Image/UploadingImageCounter.svelte"
|
||||
import PendingChangesIndicator from "./BigComponents/PendingChangesIndicator.svelte"
|
||||
import Cross from "../assets/svg/Cross.svelte"
|
||||
import Summary from "./BigComponents/Summary.svelte"
|
||||
import LanguagePicker from "./InputElement/LanguagePicker.svelte"
|
||||
import Mastodon from "../assets/svg/Mastodon.svelte"
|
||||
import Bug from "../assets/svg/Bug.svelte"
|
||||
|
@ -64,7 +63,7 @@
|
|||
import Share from "../assets/svg/Share.svelte"
|
||||
import Favourites from "./Favourites/Favourites.svelte"
|
||||
import ImageOperations from "./Image/ImageOperations.svelte"
|
||||
import { ariaLabel } from "../Utils/ariaLabel"
|
||||
import VisualFeedbackPanel from "./BigComponents/VisualFeedbackPanel.svelte"
|
||||
|
||||
export let state: ThemeViewState
|
||||
let layout = state.layout
|
||||
|
@ -92,9 +91,7 @@
|
|||
|
||||
let currentZoom = state.mapProperties.zoom
|
||||
let showCrosshair = state.userRelatedState.showCrosshair
|
||||
let arrowKeysWereUsed = state.mapProperties.lastKeyNavigation
|
||||
let centerFeatures = state.closestFeatures.features
|
||||
|
||||
let arrowKeysWereUsed = state.visualFeedback
|
||||
|
||||
let mapproperties: MapProperties = state.mapProperties
|
||||
let featureSwitches: FeatureSwitchState = state.featureSwitches
|
||||
|
@ -111,13 +108,13 @@
|
|||
let previewedImage = state.previewedImage
|
||||
|
||||
let geolocationControl = new GeolocationControl(state.geolocation, mapproperties, state.lastGeolocationRequestMoment)
|
||||
|
||||
|
||||
function forwardEventToMap(e: KeyboardEvent) {
|
||||
const mlmap = state.map.data
|
||||
if(!mlmap){
|
||||
if (!mlmap) {
|
||||
return
|
||||
}
|
||||
if(!mlmap.keyboard.isEnabled()){
|
||||
if (!mlmap.keyboard.isEnabled()) {
|
||||
return
|
||||
}
|
||||
const animation = mlmap.keyboard?.keydown(e)
|
||||
|
@ -135,14 +132,14 @@
|
|||
<div class="pointer-events-auto float-right mt-1 px-1 max-[480px]:w-full sm:m-2">
|
||||
<Geosearch
|
||||
bounds={state.mapProperties.bounds}
|
||||
on:searchCompleted={() => {state.map?.data?.getCanvas()?.focus()}}
|
||||
perLayer={state.perLayer}
|
||||
selectedElement={state.selectedElement}
|
||||
on:searchCompleted={() => {state.map?.data?.getCanvas()?.focus()}}
|
||||
/>
|
||||
</div>
|
||||
</If>
|
||||
<div class="float-left m-1 flex flex-col sm:mt-2">
|
||||
<MapControlButton on:click={() => state.guistate.themeIsOpened.setData(true)}
|
||||
<MapControlButton on:click={() => state.guistate.themeIsOpened.setData(true)}
|
||||
on:keydown={forwardEventToMap}>
|
||||
<div class="m-0.5 mx-1 flex cursor-pointer items-center max-[480px]:w-full sm:mx-1 md:mx-2">
|
||||
<img class="mr-0.5 block h-6 w-6 sm:mr-1 md:mr-2 md:h-8 md:w-8" src={layout.icon} />
|
||||
|
@ -152,9 +149,9 @@
|
|||
</div>
|
||||
</MapControlButton>
|
||||
<MapControlButton
|
||||
on:click={() => state.guistate.menuIsOpened.setData(true)}
|
||||
on:keydown={forwardEventToMap}
|
||||
arialabel={Translations.t.general.labels.menu}
|
||||
on:click={() => state.guistate.menuIsOpened.setData(true)}
|
||||
on:keydown={forwardEventToMap}
|
||||
>
|
||||
<MenuIcon class="h-8 w-8 cursor-pointer" />
|
||||
</MapControlButton>
|
||||
|
@ -162,7 +159,7 @@
|
|||
<MapControlButton
|
||||
on:click={() => {
|
||||
selectedElement.setData(state.currentView.features?.data?.[0])
|
||||
}}
|
||||
}}
|
||||
on:keydown={forwardEventToMap}
|
||||
>
|
||||
<ToSvelte
|
||||
|
@ -211,8 +208,9 @@
|
|||
<div class="flex">
|
||||
<!-- bottom left elements -->
|
||||
<If condition={state.featureSwitches.featureSwitchFilter}>
|
||||
<MapControlButton on:click={() => state.guistate.openFilterView()} on:keydown={forwardEventToMap}
|
||||
arialabel={Translations.t.general.labels.filter}
|
||||
<MapControlButton arialabel={Translations.t.general.labels.filter}
|
||||
on:click={() => state.guistate.openFilterView()}
|
||||
on:keydown={forwardEventToMap}
|
||||
>
|
||||
<Filter class="h-6 w-6" />
|
||||
</MapControlButton>
|
||||
|
@ -231,17 +229,11 @@
|
|||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<If condition={state.visualFeedback}>
|
||||
<VisualFeedbackPanel {state} />
|
||||
</If>
|
||||
|
||||
|
||||
{#if $arrowKeysWereUsed !== undefined && $centerFeatures?.length > 0}
|
||||
<div class="pointer-events-auto interactive p-1">
|
||||
{#each $centerFeatures as feat, i (feat.properties.id)}
|
||||
<div class="flex">
|
||||
<b>{i + 1}.</b>
|
||||
<Summary {state} feature={feat} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col items-end">
|
||||
<!-- bottom right elements -->
|
||||
<If condition={state.floors.map((f) => f.length > 1)}>
|
||||
|
@ -253,20 +245,22 @@
|
|||
/>
|
||||
</div>
|
||||
</If>
|
||||
<MapControlButton on:click={() => mapproperties.zoom.update((z) => z + 1)}
|
||||
<MapControlButton arialabel={Translations.t.general.labels.zoomIn}
|
||||
on:click={() => mapproperties.zoom.update((z) => z + 1)}
|
||||
on:keydown={forwardEventToMap}
|
||||
arialabel={Translations.t.general.labels.zoomIn}
|
||||
>
|
||||
<Plus class="h-8 w-8" />
|
||||
</MapControlButton>
|
||||
<MapControlButton on:click={() => mapproperties.zoom.update((z) => z - 1)} on:keydown={forwardEventToMap}
|
||||
arialabel={Translations.t.general.labels.zoomOut}
|
||||
<MapControlButton arialabel={Translations.t.general.labels.zoomOut}
|
||||
on:click={() => mapproperties.zoom.update((z) => z - 1)}
|
||||
on:keydown={forwardEventToMap}
|
||||
>
|
||||
<Min class="h-8 w-8" />
|
||||
</MapControlButton>
|
||||
<If condition={featureSwitches.featureSwitchGeolocation}>
|
||||
<MapControlButton on:keydown={forwardEventToMap} on:click={() => geolocationControl.handleClick()}
|
||||
arialabel={Translations.t.general.labels.jumpToLocation}
|
||||
<MapControlButton arialabel={Translations.t.general.labels.jumpToLocation}
|
||||
on:click={() => geolocationControl.handleClick()}
|
||||
on:keydown={forwardEventToMap}
|
||||
>
|
||||
<ToSvelte
|
||||
construct={geolocationControl.SetClass("block w-8 h-8")}
|
||||
|
@ -277,14 +271,16 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<LoginToggle ignoreLoading={true} {state}>
|
||||
{#if ($showCrosshair === "yes" && $currentZoom >= 17) || $showCrosshair === "always" || $arrowKeysWereUsed !== undefined}
|
||||
{#if ($showCrosshair === "yes" && $currentZoom >= 17) || $showCrosshair === "always" || $arrowKeysWereUsed}
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 left-0 flex h-full w-full items-center justify-center"
|
||||
>
|
||||
<Cross class="h-4 w-4" />
|
||||
</div>
|
||||
{/if}
|
||||
<svelte:fragment slot="error" /> <!-- Add in an empty container to remove errors -->
|
||||
</LoginToggle>
|
||||
|
||||
<If condition={state.previewedImage.map(i => i!==undefined)}>
|
||||
|
@ -322,7 +318,7 @@
|
|||
selectedElement.setData(undefined)
|
||||
}}
|
||||
>
|
||||
<div class="h-full w-full flex focusable">
|
||||
<div class="h-full w-full flex">
|
||||
<SelectedElementView {state} layer={$selectedLayer} selectedElement={$selectedElement} />
|
||||
</div>
|
||||
</FloatOver>
|
||||
|
@ -410,7 +406,7 @@
|
|||
state.guistate.backgroundLayerSelectionIsOpened.setData(false)
|
||||
}}
|
||||
>
|
||||
<div class="h-full p-2 focusable">
|
||||
<div class="h-full p-2">
|
||||
<RasterLayerOverview
|
||||
{availableLayers}
|
||||
map={state.map}
|
||||
|
|
|
@ -69,10 +69,6 @@ body {
|
|||
color: var(--foreground-color);
|
||||
font-family: "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
.focusable {
|
||||
/* Not a 'real' class, but rather an indication to FloatOver and ModalRight to, when they open, grab the focus */
|
||||
}
|
||||
|
||||
|
||||
svg,
|
||||
img {
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
<!-- THEME-SPECIFIC-END-->
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<body tabindex="-1">
|
||||
|
||||
|
||||
<div class="h-screen" id="maindiv">
|
||||
|
@ -62,7 +62,7 @@
|
|||
<div class="flex justify-between items-start w-full">
|
||||
|
||||
<!-- IMAGE-START -->
|
||||
<img class="p-8 h-32 w-32 self-start" src="./assets/svg/add.svg">
|
||||
<img aria-hidden="true" class="p-8 h-32 w-32 self-start" src="./assets/svg/add.svg">
|
||||
<!-- IMAGE-END -->
|
||||
<div class="h-min subtle">
|
||||
Version
|
||||
|
@ -72,7 +72,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="belowmap" class="absolute top-0 left-0 -z-10">Below</div>
|
||||
<div aria-hidden="true" id="belowmap" class="absolute top-0 left-0 -z-10">Below</div>
|
||||
<script src="./src/UI/RemoveOtherLanguages.js"></script>
|
||||
<script async src="./src/InstallServiceWorker.ts" type="module"></script>
|
||||
<script defer src="./src/index.ts" type="module"></script>
|
||||
|
|
Loading…
Reference in a new issue