UX: add possibility to select map features by only using the keyboard, see #1181

This commit is contained in:
Pieter Vander Vennet 2023-11-16 03:32:04 +01:00
parent 7469a0d607
commit 48171d30f5
9 changed files with 304 additions and 104 deletions

View file

@ -404,6 +404,7 @@
"key": "Key combination",
"openLayersPanel": "Opens the layers and filters panel",
"selectAerial": "Set the background to aerial or satellite imagery. Toggles between the two best, available layers",
"selectItem": "Select the POI which is closest to the map center (crosshair). Only when in keyboard navigation is used",
"selectMap": "Set the background to a map from external sources. Toggles between the two best, available layers",
"selectMapnik": "Set the background layer to OpenStreetMap-carto",
"selectOsmbasedmap": "Set the background layer to on OpenStreetMap-based map (or disable the background raster layer)",

View file

@ -0,0 +1,94 @@
import { FeatureSource } from "../FeatureSource"
import { Store, Stores, UIEventSource } from "../../UIEventSource"
import { Feature } from "geojson"
import { GeoOperations } from "../../GeoOperations"
import FilteringFeatureSource from "./FilteringFeatureSource"
import LayerState from "../../State/LayerState"
export default class NearbyFeatureSource implements FeatureSource {
public readonly features: Store<Feature[]>
private readonly _targetPoint: Store<{ lon: number; lat: number }>
private readonly _numberOfNeededFeatures: number
private readonly _currentZoom: Store<number>
constructor(
targetPoint: Store<{ lon: number; lat: number }>,
sources: ReadonlyMap<string, FilteringFeatureSource>,
numberOfNeededFeatures?: number,
layerState?: LayerState,
currentZoom?: Store<number>
) {
this._targetPoint = targetPoint.stabilized(100)
this._numberOfNeededFeatures = numberOfNeededFeatures
this._currentZoom = currentZoom.stabilized(500)
const allSources: Store<{ feat: Feature; d: number }[]>[] = []
let minzoom = 999
const result = new UIEventSource<Feature[]>(undefined)
this.features = Stores.ListStabilized(result)
function update() {
let features: { feat: Feature; d: number }[] = []
for (const src of allSources) {
features.push(...src.data)
}
features.sort((a, b) => a.d - b.d)
if (numberOfNeededFeatures !== undefined) {
features = features.slice(0, numberOfNeededFeatures)
}
result.setData(features.map((f) => f.feat))
}
sources.forEach((source, layer) => {
const flayer = layerState?.filteredLayers.get(layer)
minzoom = Math.min(minzoom, flayer.layerDef.minzoom)
const calcSource = this.createSource(
source.features,
flayer.layerDef.minzoom,
flayer.isDisplayed
)
calcSource.addCallbackAndRunD((features) => {
update()
})
allSources.push(calcSource)
})
}
/**
* Sorts the given source by distance, slices down to the required number
*/
private createSource(
source: Store<Feature[]>,
minZoom: number,
isActive?: Store<boolean>
): Store<{ feat: Feature; d: number }[]> {
const empty = []
return source.stabilized(100).map(
(feats) => {
if (isActive && !isActive.data) {
return empty
}
if (this._currentZoom.data < minZoom) {
return empty
}
const point = this._targetPoint.data
const lonLat = <[number, number]>[point.lon, point.lat]
const withDistance = feats.map((feat) => ({
d: GeoOperations.distanceBetween(
lonLat,
GeoOperations.centerpointCoordinates(feat)
),
feat,
}))
withDistance.sort((a, b) => a.d - b.d)
if (this._numberOfNeededFeatures !== undefined) {
return withDistance.slice(0, this._numberOfNeededFeatures)
}
return withDistance
},
[this._targetPoint, isActive, this._currentZoom]
)
}
}

View file

@ -12,7 +12,7 @@ import { GlobalFilter } from "./GlobalFilter"
export default class FilteredLayer {
/**
* Wether or not the specified layer is shown
* Whether or not the specified layer is enabled by the user
*/
readonly isDisplayed: UIEventSource<boolean>
/**

View file

@ -14,6 +14,7 @@ export interface MapProperties {
readonly allowRotating: UIEventSource<true | boolean>
readonly lastClickLocation: Store<{ lon: number; lat: number }>
readonly allowZooming: UIEventSource<true | boolean>
readonly lastKeyNavigation: UIEventSource<number>
}
export interface ExportableMap {

View file

@ -57,6 +57,7 @@ import FilteredLayer from "./FilteredLayer"
import { PreferredRasterLayerSelector } from "../Logic/Actors/PreferredRasterLayerSelector"
import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager"
import { Imgur } from "../Logic/ImageProviders/Imgur"
import NearbyFeatureSource from "../Logic/FeatureSource/Sources/NearbyFeatureSource"
/**
*
@ -95,6 +96,10 @@ export default class ThemeViewState implements SpecialVisualizationState {
readonly indexedFeatures: IndexedFeatureSource & LayoutSource
readonly currentView: FeatureSource<Feature<Polygon>>
readonly featuresInView: FeatureSource
/**
* Contains a few (<10) >features that are near the center of the map.
*/
readonly closestFeatures: FeatureSource
readonly newFeatures: WritableFeatureSource
readonly layerState: LayerState
readonly perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer>
@ -131,6 +136,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
this.map = new UIEventSource<MlMap>(undefined)
const initial = new InitialMapPositioning(layout)
this.mapProperties = new MapLibreAdaptor(this.map, initial)
const geolocationState = new GeoLocationState()
this.featureSwitchIsTesting = this.featureSwitches.featureSwitchIsTesting
@ -234,6 +240,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
})
)
this.featuresInView = new BBoxFeatureSource(layoutSource, this.mapProperties.bounds)
this.dataIsLoading = layoutSource.isLoading
const indexedElements = this.indexedFeatures
@ -331,7 +338,13 @@ export default class ThemeViewState implements SpecialVisualizationState {
)
this.perLayerFiltered = this.showNormalDataOn(this.map)
this.closestFeatures = new NearbyFeatureSource(
this.mapProperties.location,
this.perLayerFiltered,
3,
this.layerState,
this.mapProperties.zoom
)
this.hasDataInView = new NoElementsInViewDetector(this).hasFeatureInView
this.imageUploadManager = new ImageUploadManager(
layout,
@ -364,6 +377,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
return true
})
}
public showNormalDataOn(map: Store<MlMap>): ReadonlyMap<string, FilteringFeatureSource> {
const filteringFeatureSource = new Map<string, FilteringFeatureSource>()
this.perLayer.forEach((fs, layerName) => {
@ -404,6 +418,17 @@ export default class ThemeViewState implements SpecialVisualizationState {
return filteringFeatureSource
}
public openNewDialog() {
this.selectedLayer.setData(undefined)
this.selectedElement.setData(undefined)
const { lon, lat } = this.mapProperties.location.data
const feature = this.lastClickObject.createFeature(lon, lat)
this.featureProperties.trackFeature(feature)
this.selectedElement.setData(feature)
this.selectedLayer.setData(this.newPointDialog.layerDef)
}
/**
* Various small methods that need to be called
*/
@ -425,6 +450,21 @@ export default class ThemeViewState implements SpecialVisualizationState {
}
}
/**
* Selects the feature that is 'i' closest to the map center
* @param i
* @private
*/
private selectClosestAtCenter(i: number = 0) {
const toSelect = this.closestFeatures.features.data[i]
if (!toSelect) {
return
}
const layer = this.layout.getMatchingLayer(toSelect.properties)
this.selectedElement.setData(undefined)
this.selectedLayer.setData(layer)
this.selectedElement.setData(toSelect)
}
private initHotkeys() {
Hotkeys.RegisterHotkey(
{ nomod: "Escape", onUp: true },
@ -436,6 +476,36 @@ 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)
)
}
return true // unregister
})
this.featureSwitches.featureSwitchBackgroundSelection.addCallbackAndRun((enable) => {
if (!enable) {
return
@ -531,17 +601,6 @@ export default class ThemeViewState implements SpecialVisualizationState {
})
}
public openNewDialog() {
this.selectedLayer.setData(undefined)
this.selectedElement.setData(undefined)
const { lon, lat } = this.mapProperties.location.data
const feature = this.lastClickObject.createFeature(lon, lat)
this.featureProperties.trackFeature(feature)
this.selectedElement.setData(feature)
this.selectedLayer.setData(this.newPointDialog.layerDef)
}
/**
* Add the special layers to the map
*/

View file

@ -38,9 +38,9 @@
<div class="flex flex-col">
<!-- Title element-->
<h3>
<TagRenderingAnswer config={layer.title} {selectedElement} {state} {tags} {layer} />
</h3>
<div
class="no-weblate title-icons links-as-button mr-2 flex flex-row flex-wrap items-center gap-x-0.5 p-1 pt-0.5 sm:pt-1"
>

View file

@ -0,0 +1,16 @@
<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
let id = feature.properties.id
let tags = state.featureProperties.getStore(id);
let layer: LayerConfig = state.layout.getMatchingLayer(tags.data)
</script>
<TagRenderingAnswer config={layer.title} selectedElement={feature} {state} {tags} {layer} />

View file

@ -9,7 +9,6 @@ import SvelteUIElement from "../Base/SvelteUIElement"
import MaplibreMap from "./MaplibreMap.svelte"
import { RasterLayerProperties } from "../../Models/RasterLayerProperties"
import * as htmltoimage from "html-to-image"
import { draw } from "svelte/transition"
/**
* The 'MapLibreAdaptor' bridges 'MapLibre' with the various properties of the `MapProperties`
@ -41,6 +40,12 @@ 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
*/
readonly lastKeyNavigation: UIEventSource<number> = new UIEventSource<number>(undefined)
private readonly _maplibreMap: Store<MLMap>
/**
* Used for internal bookkeeping (to remove a rasterLayer when done loading)
@ -128,6 +133,16 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
map.on("dblclick", (e) => {
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)
}
})
})
this.rasterLayer.addCallbackAndRun((_) =>

View file

@ -1,120 +1,127 @@
<script lang="ts">
import { Store, UIEventSource } from "../Logic/UIEventSource"
import { Map as MlMap } from "maplibre-gl"
import MaplibreMap from "./Map/MaplibreMap.svelte"
import FeatureSwitchState from "../Logic/State/FeatureSwitchState"
import MapControlButton from "./Base/MapControlButton.svelte"
import ToSvelte from "./Base/ToSvelte.svelte"
import If from "./Base/If.svelte"
import { GeolocationControl } from "./BigComponents/GeolocationControl"
import type { Feature } from "geojson"
import SelectedElementView from "./BigComponents/SelectedElementView.svelte"
import LayerConfig from "../Models/ThemeConfig/LayerConfig"
import Filterview from "./BigComponents/Filterview.svelte"
import ThemeViewState from "../Models/ThemeViewState"
import type { MapProperties } from "../Models/MapProperties"
import Geosearch from "./BigComponents/Geosearch.svelte"
import Translations from "./i18n/Translations"
import { CogIcon, EyeIcon, MenuIcon, XCircleIcon } from "@rgossiaux/svelte-heroicons/solid"
import Tr from "./Base/Tr.svelte"
import CommunityIndexView from "./BigComponents/CommunityIndexView.svelte"
import FloatOver from "./Base/FloatOver.svelte"
import PrivacyPolicy from "./BigComponents/PrivacyPolicy"
import Constants from "../Models/Constants"
import TabbedGroup from "./Base/TabbedGroup.svelte"
import UserRelatedState from "../Logic/State/UserRelatedState"
import LoginToggle from "./Base/LoginToggle.svelte"
import LoginButton from "./Base/LoginButton.svelte"
import CopyrightPanel from "./BigComponents/CopyrightPanel"
import DownloadPanel from "./DownloadFlow/DownloadPanel.svelte"
import ModalRight from "./Base/ModalRight.svelte"
import { Utils } from "../Utils"
import Hotkeys from "./Base/Hotkeys"
import { VariableUiElement } from "./Base/VariableUIElement"
import SvelteUIElement from "./Base/SvelteUIElement"
import OverlayToggle from "./BigComponents/OverlayToggle.svelte"
import LevelSelector from "./BigComponents/LevelSelector.svelte"
import ExtraLinkButton from "./BigComponents/ExtraLinkButton"
import SelectedElementTitle from "./BigComponents/SelectedElementTitle.svelte"
import Svg from "../Svg"
import ThemeIntroPanel from "./BigComponents/ThemeIntroPanel.svelte"
import type { RasterLayerPolygon } from "../Models/RasterLayers"
import { AvailableRasterLayers } from "../Models/RasterLayers"
import RasterLayerOverview from "./Map/RasterLayerOverview.svelte"
import IfHidden from "./Base/IfHidden.svelte"
import { onDestroy } from "svelte"
import { OpenJosm } from "./BigComponents/OpenJosm"
import MapillaryLink from "./BigComponents/MapillaryLink.svelte"
import OpenIdEditor from "./BigComponents/OpenIdEditor.svelte"
import OpenBackgroundSelectorButton from "./BigComponents/OpenBackgroundSelectorButton.svelte"
import StateIndicator from "./BigComponents/StateIndicator.svelte"
import LanguagePicker from "./LanguagePicker"
import Locale from "./i18n/Locale"
import ShareScreen from "./BigComponents/ShareScreen.svelte"
import UploadingImageCounter from "./Image/UploadingImageCounter.svelte"
import PendingChangesIndicator from "./BigComponents/PendingChangesIndicator.svelte"
import { Store, UIEventSource } from "../Logic/UIEventSource";
import { Map as MlMap } from "maplibre-gl";
import MaplibreMap from "./Map/MaplibreMap.svelte";
import FeatureSwitchState from "../Logic/State/FeatureSwitchState";
import MapControlButton from "./Base/MapControlButton.svelte";
import ToSvelte from "./Base/ToSvelte.svelte";
import If from "./Base/If.svelte";
import { GeolocationControl } from "./BigComponents/GeolocationControl";
import type { Feature } from "geojson";
import SelectedElementView from "./BigComponents/SelectedElementView.svelte";
import LayerConfig from "../Models/ThemeConfig/LayerConfig";
import Filterview from "./BigComponents/Filterview.svelte";
import ThemeViewState from "../Models/ThemeViewState";
import type { MapProperties } from "../Models/MapProperties";
import Geosearch from "./BigComponents/Geosearch.svelte";
import Translations from "./i18n/Translations";
import { CogIcon, EyeIcon, MenuIcon, XCircleIcon } from "@rgossiaux/svelte-heroicons/solid";
import Tr from "./Base/Tr.svelte";
import CommunityIndexView from "./BigComponents/CommunityIndexView.svelte";
import FloatOver from "./Base/FloatOver.svelte";
import PrivacyPolicy from "./BigComponents/PrivacyPolicy";
import Constants from "../Models/Constants";
import TabbedGroup from "./Base/TabbedGroup.svelte";
import UserRelatedState from "../Logic/State/UserRelatedState";
import LoginToggle from "./Base/LoginToggle.svelte";
import LoginButton from "./Base/LoginButton.svelte";
import CopyrightPanel from "./BigComponents/CopyrightPanel";
import DownloadPanel from "./DownloadFlow/DownloadPanel.svelte";
import ModalRight from "./Base/ModalRight.svelte";
import { Utils } from "../Utils";
import Hotkeys from "./Base/Hotkeys";
import { VariableUiElement } from "./Base/VariableUIElement";
import SvelteUIElement from "./Base/SvelteUIElement";
import OverlayToggle from "./BigComponents/OverlayToggle.svelte";
import LevelSelector from "./BigComponents/LevelSelector.svelte";
import ExtraLinkButton from "./BigComponents/ExtraLinkButton";
import SelectedElementTitle from "./BigComponents/SelectedElementTitle.svelte";
import Svg from "../Svg";
import ThemeIntroPanel from "./BigComponents/ThemeIntroPanel.svelte";
import type { RasterLayerPolygon } from "../Models/RasterLayers";
import { AvailableRasterLayers } from "../Models/RasterLayers";
import RasterLayerOverview from "./Map/RasterLayerOverview.svelte";
import IfHidden from "./Base/IfHidden.svelte";
import { onDestroy } from "svelte";
import { OpenJosm } from "./BigComponents/OpenJosm";
import MapillaryLink from "./BigComponents/MapillaryLink.svelte";
import OpenIdEditor from "./BigComponents/OpenIdEditor.svelte";
import OpenBackgroundSelectorButton from "./BigComponents/OpenBackgroundSelectorButton.svelte";
import StateIndicator from "./BigComponents/StateIndicator.svelte";
import LanguagePicker from "./LanguagePicker";
import Locale from "./i18n/Locale";
import ShareScreen from "./BigComponents/ShareScreen.svelte";
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";
export let state: ThemeViewState
let layout = state.layout
export let state: ThemeViewState;
let layout = state.layout;
let maplibremap: UIEventSource<MlMap> = state.map
let selectedElement: UIEventSource<Feature> = state.selectedElement
let selectedLayer: UIEventSource<LayerConfig> = state.selectedLayer
let maplibremap: UIEventSource<MlMap> = state.map;
let selectedElement: UIEventSource<Feature> = state.selectedElement;
let selectedLayer: UIEventSource<LayerConfig> = state.selectedLayer;
let currentZoom = state.mapProperties.zoom;
let showCrosshair = state.userRelatedState.showCrosshair;
let arrowKeysWereUsed = state.mapProperties.lastKeyNavigation;
let centerFeatures = state.closestFeatures.features;
$: console.log("Centerfeatures are", $centerFeatures)
const selectedElementView = selectedElement.map(
(selectedElement) => {
// Svelte doesn't properly reload some of the legacy UI-elements
// As such, we _reconstruct_ the selectedElementView every time a new feature is selected
// This is a bit wasteful, but until everything is a svelte-component, this should do the trick
const layer = selectedLayer.data
const layer = selectedLayer.data;
if (selectedElement === undefined || layer === undefined) {
return undefined
return undefined;
}
if (!(layer.tagRenderings?.length > 0) || layer.title === undefined) {
return undefined
return undefined;
}
const tags = state.featureProperties.getStore(selectedElement.properties.id)
const tags = state.featureProperties.getStore(selectedElement.properties.id);
return new SvelteUIElement(SelectedElementView, {
state,
layer,
selectedElement,
tags,
}).SetClass("h-full w-full")
tags
}).SetClass("h-full w-full");
},
[selectedLayer]
)
);
const selectedElementTitle = selectedElement.map(
(selectedElement) => {
// Svelte doesn't properly reload some of the legacy UI-elements
// As such, we _reconstruct_ the selectedElementView every time a new feature is selected
// This is a bit wasteful, but until everything is a svelte-component, this should do the trick
const layer = selectedLayer.data
const layer = selectedLayer.data;
if (selectedElement === undefined || layer === undefined) {
return undefined
return undefined;
}
const tags = state.featureProperties.getStore(selectedElement.properties.id)
return new SvelteUIElement(SelectedElementTitle, { state, layer, selectedElement, tags })
const tags = state.featureProperties.getStore(selectedElement.properties.id);
return new SvelteUIElement(SelectedElementTitle, { state, layer, selectedElement, tags });
},
[selectedLayer]
)
);
let mapproperties: MapProperties = state.mapProperties
let featureSwitches: FeatureSwitchState = state.featureSwitches
let availableLayers = state.availableLayers
let userdetails = state.osmConnection.userDetails
let currentViewLayer = layout.layers.find((l) => l.id === "current_view")
let rasterLayer: Store<RasterLayerPolygon> = state.mapProperties.rasterLayer
let mapproperties: MapProperties = state.mapProperties;
let featureSwitches: FeatureSwitchState = state.featureSwitches;
let availableLayers = state.availableLayers;
let userdetails = state.osmConnection.userDetails;
let currentViewLayer = layout.layers.find((l) => l.id === "current_view");
let rasterLayer: Store<RasterLayerPolygon> = state.mapProperties.rasterLayer;
let rasterLayerName =
rasterLayer.data?.properties?.name ?? AvailableRasterLayers.maptilerDefaultLayer.properties.name
rasterLayer.data?.properties?.name ?? AvailableRasterLayers.maptilerDefaultLayer.properties.name;
onDestroy(
rasterLayer.addCallbackAndRunD((l) => {
rasterLayerName = l.properties.name
rasterLayerName = l.properties.name;
})
)
);
</script>
<div class="absolute top-0 left-0 h-screen w-screen overflow-hidden">
@ -216,6 +223,15 @@
</div>
</div>
{#if $arrowKeysWereUsed !== undefined}
<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)}>
@ -247,15 +263,13 @@
</div>
<LoginToggle ignoreLoading={true} {state}>
<If condition={state.userRelatedState.showCrosshair.map((s) => s === "yes")}>
<If condition={state.mapProperties.zoom.map((z) => z >= 17)}>
<div
class="pointer-events-none absolute top-0 left-0 flex h-full w-full items-center justify-center"
>
<ToSvelte construct={Svg.cross_svg()} />
</div>
</If>
</If>
{#if $showCrosshair === "yes" && ($currentZoom >= 17 || $arrowKeysWereUsed !== undefined) }
<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}
</LoginToggle>
<If