diff --git a/langs/en.json b/langs/en.json index 76a866620..51286265d 100644 --- a/langs/en.json +++ b/langs/en.json @@ -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)", diff --git a/src/Logic/FeatureSource/Sources/NearbyFeatureSource.ts b/src/Logic/FeatureSource/Sources/NearbyFeatureSource.ts index e69de29bb..669c86a11 100644 --- a/src/Logic/FeatureSource/Sources/NearbyFeatureSource.ts +++ b/src/Logic/FeatureSource/Sources/NearbyFeatureSource.ts @@ -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 + private readonly _targetPoint: Store<{ lon: number; lat: number }> + private readonly _numberOfNeededFeatures: number + private readonly _currentZoom: Store + + constructor( + targetPoint: Store<{ lon: number; lat: number }>, + sources: ReadonlyMap, + numberOfNeededFeatures?: number, + layerState?: LayerState, + currentZoom?: Store + ) { + 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(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, + minZoom: number, + isActive?: Store + ): 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] + ) + } +} diff --git a/src/Models/FilteredLayer.ts b/src/Models/FilteredLayer.ts index 9b9de7b99..8fbceef99 100644 --- a/src/Models/FilteredLayer.ts +++ b/src/Models/FilteredLayer.ts @@ -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 /** diff --git a/src/Models/MapProperties.ts b/src/Models/MapProperties.ts index 08580e4e3..6137b22a7 100644 --- a/src/Models/MapProperties.ts +++ b/src/Models/MapProperties.ts @@ -14,6 +14,7 @@ export interface MapProperties { readonly allowRotating: UIEventSource readonly lastClickLocation: Store<{ lon: number; lat: number }> readonly allowZooming: UIEventSource + readonly lastKeyNavigation: UIEventSource } export interface ExportableMap { diff --git a/src/Models/ThemeViewState.ts b/src/Models/ThemeViewState.ts index 765a7dbc7..0cc0119e6 100644 --- a/src/Models/ThemeViewState.ts +++ b/src/Models/ThemeViewState.ts @@ -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> 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 @@ -131,6 +136,7 @@ export default class ThemeViewState implements SpecialVisualizationState { this.map = new UIEventSource(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): ReadonlyMap { const filteringFeatureSource = new Map() 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 */ diff --git a/src/UI/BigComponents/SelectedElementTitle.svelte b/src/UI/BigComponents/SelectedElementTitle.svelte index 600087dc7..572377193 100644 --- a/src/UI/BigComponents/SelectedElementTitle.svelte +++ b/src/UI/BigComponents/SelectedElementTitle.svelte @@ -38,9 +38,9 @@

+

- + {#if $arrowKeysWereUsed !== undefined} +
+ {#each $centerFeatures as feat, i (feat.properties.id)} +
+ {i+1}. +
+ {/each} +
+ {/if}
f.length > 1)}> @@ -247,15 +263,13 @@
- s === "yes")}> - z >= 17)}> -
- -
-
-
+ {#if $showCrosshair === "yes" && ($currentZoom >= 17 || $arrowKeysWereUsed !== undefined) } +
+ +
+ {/if}