From dbcbf2787d7ad857b52cb458c931563b97df4b50 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Thu, 23 Jan 2025 05:01:55 +0100 Subject: [PATCH] Refactoring: split 'ThemeViewState' into many classes --- scripts/handleErrors.ts | 6 +- src/Logic/Actors/GeoLocationHandler.ts | 21 +- src/Logic/Actors/NoElementsInViewDetector.ts | 6 +- .../Actors/SelectedElementTagsUpdater.ts | 16 +- .../Sources/FavouritesFeatureSource.ts | 19 +- src/Logic/Osm/Changes.ts | 7 +- src/Logic/Search/OpenStreetMapIdSearch.ts | 18 +- src/Logic/State/SearchState.ts | 6 +- src/Logic/State/UserRelatedState.ts | 16 +- src/Logic/Web/ThemeViewStateHashActor.ts | 20 +- src/Models/ThemeViewState.ts | 1137 +---------------- .../UserMapFeatureswitchState.ts | 292 +++++ src/Models/ThemeViewState/WithChangesState.ts | 236 ++++ src/Models/ThemeViewState/WithGuiState.ts | 72 ++ src/Models/ThemeViewState/WithImageState.ts | 68 + .../ThemeViewState/WithLayoutSourceState.ts | 129 ++ src/Models/ThemeViewState/WithSearchState.ts | 72 ++ .../WithSelectedElementState.ts | 79 ++ .../ThemeViewState/WithSpecialLayers.ts | 227 ++++ .../ThemeViewState/WithUserRelatedState.ts | 107 ++ .../ThemeViewState/WithVisualFeedbackState.ts | 108 ++ .../NewPointLocationInput.svelte | 2 +- src/UI/Favourites/FavouriteSummary.svelte | 4 +- src/UI/Popup/AddNewPoint/AddNewPoint.svelte | 8 +- src/UI/Popup/ImportButtons/ImportFlow.svelte | 4 +- src/UI/Popup/ImportButtons/ImportFlow.ts | 10 +- .../ImportButtons/PointImportFlowState.ts | 4 +- src/UI/Popup/Notes/CreateNewNote.svelte | 4 +- src/UI/Reviews/SingleReview.svelte | 4 +- src/UI/Search/GeocodeResults.svelte | 3 +- src/UI/SpecialVisualization.ts | 10 +- src/UI/ThemeViewGUI.svelte | 9 +- src/index.ts | 4 +- src/index_theme.ts.template | 2 +- 34 files changed, 1503 insertions(+), 1227 deletions(-) create mode 100644 src/Models/ThemeViewState/UserMapFeatureswitchState.ts create mode 100644 src/Models/ThemeViewState/WithChangesState.ts create mode 100644 src/Models/ThemeViewState/WithGuiState.ts create mode 100644 src/Models/ThemeViewState/WithImageState.ts create mode 100644 src/Models/ThemeViewState/WithLayoutSourceState.ts create mode 100644 src/Models/ThemeViewState/WithSearchState.ts create mode 100644 src/Models/ThemeViewState/WithSelectedElementState.ts create mode 100644 src/Models/ThemeViewState/WithSpecialLayers.ts create mode 100644 src/Models/ThemeViewState/WithUserRelatedState.ts create mode 100644 src/Models/ThemeViewState/WithVisualFeedbackState.ts diff --git a/scripts/handleErrors.ts b/scripts/handleErrors.ts index ae5a43796..f0b46aa7b 100644 --- a/scripts/handleErrors.ts +++ b/scripts/handleErrors.ts @@ -1,11 +1,10 @@ import Script from "./Script" -import { appendFileSync, readFileSync, writeFile, writeFileSync } from "fs" +import { readFileSync, writeFileSync } from "fs" import { ChangeDescription } from "../src/Logic/Osm/Actions/ChangeDescription" import { Changes } from "../src/Logic/Osm/Changes" import { OsmObject } from "../src/Logic/Osm/OsmObject" import OsmObjectDownloader from "../src/Logic/Osm/OsmObjectDownloader" import { OsmConnection } from "../src/Logic/Osm/OsmConnection" -import { ImmutableStore } from "../src/Logic/UIEventSource" import Constants from "../src/Models/Constants" type ErrorMessage = { @@ -130,11 +129,10 @@ ${changeset}` const changesObj = new Changes( { - dryRun: new ImmutableStore(true), osmConnection, + reportError: (err) => console.error(err) }, false, - (err) => console.error(err) ) const all: ErrorMessage[] = [] diff --git a/src/Logic/Actors/GeoLocationHandler.ts b/src/Logic/Actors/GeoLocationHandler.ts index 3c0f7a374..20e076558 100644 --- a/src/Logic/Actors/GeoLocationHandler.ts +++ b/src/Logic/Actors/GeoLocationHandler.ts @@ -2,18 +2,17 @@ import { QueryParameters } from "../Web/QueryParameters" import { BBox } from "../BBox" import Constants from "../../Models/Constants" import { GeoLocationState } from "../State/GeoLocationState" -import { UIEventSource } from "../UIEventSource" +import { Store, UIEventSource } from "../UIEventSource" import { Feature, LineString, Point } from "geojson" import { FeatureSource, WritableFeatureSource } from "../FeatureSource/FeatureSource" import { LocalStorageSource } from "../Web/LocalStorageSource" import { GeoOperations } from "../GeoOperations" import { OsmTags } from "../../Models/OsmFeature" -import StaticFeatureSource, { - WritableStaticFeatureSource, -} from "../FeatureSource/Sources/StaticFeatureSource" +import StaticFeatureSource, { WritableStaticFeatureSource } from "../FeatureSource/Sources/StaticFeatureSource" import { MapProperties } from "../../Models/MapProperties" import { Orientation } from "../../Sensors/Orientation" -;("use strict") + +("use strict") /** * The geolocation-handler takes a map-location and a geolocation state. * It'll move the map as appropriate given the state of the geolocation-API @@ -45,14 +44,14 @@ export default class GeoLocationHandler { public readonly mapHasMoved: UIEventSource = new UIEventSource< Date | undefined >(undefined) - private readonly selectedElement: UIEventSource - private readonly mapProperties?: MapProperties + private readonly selectedElement: Store + private readonly mapProperties: MapProperties private readonly gpsLocationHistoryRetentionTime?: UIEventSource constructor( geolocationState: GeoLocationState, - selectedElement: UIEventSource, - mapProperties?: MapProperties, + selectedElement: Store, + mapProperties: MapProperties, gpsLocationHistoryRetentionTime?: UIEventSource ) { this.geolocationState = geolocationState @@ -62,7 +61,7 @@ export default class GeoLocationHandler { this.gpsLocationHistoryRetentionTime = gpsLocationHistoryRetentionTime // Did an interaction move the map? const initTime = new Date() - mapLocation.addCallbackD(() => { + mapLocation?.addCallbackD(() => { if (new Date().getTime() - initTime.getTime() < 250) { return } @@ -139,7 +138,7 @@ export default class GeoLocationHandler { } } - mapLocation.setData({ + mapLocation?.setData({ lon: newLocation.longitude, lat: newLocation.latitude, }) diff --git a/src/Logic/Actors/NoElementsInViewDetector.ts b/src/Logic/Actors/NoElementsInViewDetector.ts index 46e24e494..0cd738328 100644 --- a/src/Logic/Actors/NoElementsInViewDetector.ts +++ b/src/Logic/Actors/NoElementsInViewDetector.ts @@ -1,7 +1,7 @@ import { BBox } from "../BBox" import { Store } from "../UIEventSource" -import ThemeViewState from "../../Models/ThemeViewState" import Constants from "../../Models/Constants" +import { WithChangesState } from "../../Models/ThemeViewState/WithChangesState" export type FeatureViewState = | "no-data" @@ -11,7 +11,7 @@ export type FeatureViewState = export default class NoElementsInViewDetector { public readonly hasFeatureInView: Store - constructor(themeViewState: ThemeViewState) { + constructor(themeViewState: WithChangesState) { const state = themeViewState const minZoom = Math.min( ...themeViewState.theme.layers @@ -32,7 +32,6 @@ export default class NoElementsInViewDetector { return "zoom-to-low" } - let minzoomWithData = 9999 for (const [layerName, source] of themeViewState.perLayerFiltered) { if (priviliged.has(layerName)) { @@ -45,7 +44,6 @@ export default class NoElementsInViewDetector { } const layer = themeViewState.theme.getLayer(layerName) if (mapProperties.zoom.data < layer.minzoom) { - minzoomWithData = Math.min(layer.minzoom) continue } if (!state.layerState.filteredLayers.get(layerName).isDisplayed.data) { diff --git a/src/Logic/Actors/SelectedElementTagsUpdater.ts b/src/Logic/Actors/SelectedElementTagsUpdater.ts index 218b665e7..e5d8d04c7 100644 --- a/src/Logic/Actors/SelectedElementTagsUpdater.ts +++ b/src/Logic/Actors/SelectedElementTagsUpdater.ts @@ -4,10 +4,12 @@ import SimpleMetaTagger from "../SimpleMetaTagger" import { OsmTags } from "../../Models/OsmFeature" import { Utils } from "../../Utils" -import ThemeViewState from "../../Models/ThemeViewState" import { BBox } from "../BBox" import { Feature } from "geojson" -import { SpecialVisualizationState } from "../../UI/SpecialVisualization" +import { Changes } from "../Osm/Changes" +import ThemeConfig from "../../Models/ThemeConfig/ThemeConfig" +import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore" +import { WithChangesState } from "../../Models/ThemeViewState/WithChangesState" export default class SelectedElementTagsUpdater { private static readonly metatags = new Set([ @@ -18,9 +20,9 @@ export default class SelectedElementTagsUpdater { "uid", "id", ]) - private readonly state: ThemeViewState + private readonly state: WithChangesState - constructor(state: ThemeViewState) { + constructor(state: WithChangesState) { this.state = state state.osmConnection.isLoggedIn.addCallbackAndRun((isLoggedIn) => { if (!isLoggedIn && !Utils.runningFromConsole) { @@ -32,7 +34,11 @@ export default class SelectedElementTagsUpdater { }) } - public static applyUpdate(latestTags: OsmTags, id: string, state: SpecialVisualizationState) { + public static applyUpdate(latestTags: OsmTags, id: string, state: { + theme: ThemeConfig, + changes: Changes, + featureProperties: FeaturePropertiesStore + }) { try { const leftRightSensitive = state.theme.isLeftRightSensitive() diff --git a/src/Logic/FeatureSource/Sources/FavouritesFeatureSource.ts b/src/Logic/FeatureSource/Sources/FavouritesFeatureSource.ts index 5c48ac790..f91cfa835 100644 --- a/src/Logic/FeatureSource/Sources/FavouritesFeatureSource.ts +++ b/src/Logic/FeatureSource/Sources/FavouritesFeatureSource.ts @@ -6,8 +6,11 @@ import { OsmId } from "../../../Models/OsmFeature" import { GeoOperations } from "../../GeoOperations" import { IndexedFeatureSource } from "../FeatureSource" import OsmObjectDownloader from "../../Osm/OsmObjectDownloader" -import { SpecialVisualizationState } from "../../../UI/SpecialVisualization" import SelectedElementTagsUpdater from "../../Actors/SelectedElementTagsUpdater" +import FeaturePropertiesStore from "../Actors/FeaturePropertiesStore" +import ThemeConfig from "../../../Models/ThemeConfig/ThemeConfig" +import { Changes } from "../../Osm/Changes" +import { WithChangesState } from "../../../Models/ThemeViewState/WithChangesState" /** * Generates the favourites from the preferences and marks them as favourite @@ -22,7 +25,7 @@ export default class FavouritesFeatureSource extends StaticFeatureSource { */ public readonly allFavourites: Store - constructor(state: SpecialVisualizationState) { + constructor(state: WithChangesState) { const features: Store = Stores.ListStabilized( state.osmConnection.preferencesHandler.allPreferences.map((prefs) => { const feats: Feature[] = [] @@ -71,7 +74,7 @@ export default class FavouritesFeatureSource extends StaticFeatureSource { this.allFavourites.addCallbackD((features) => { for (const feature of features) { - this.updateFeature(feature, state.osmObjectDownloader, state) + this.updateFeature(feature, state) } return true @@ -80,11 +83,15 @@ export default class FavouritesFeatureSource extends StaticFeatureSource { private async updateFeature( feature: Feature, - osmObjectDownloader: OsmObjectDownloader, - state: SpecialVisualizationState + state: { + theme: ThemeConfig, + changes: Changes, + featureProperties: FeaturePropertiesStore, + osmObjectDownloader: OsmObjectDownloader, + } ) { const id = feature.properties.id - const upstream = await osmObjectDownloader.DownloadObjectAsync(id) + const upstream = await state.osmObjectDownloader.DownloadObjectAsync(id) if (upstream === "deleted") { this.removeFavourite(feature) return diff --git a/src/Logic/Osm/Changes.ts b/src/Logic/Osm/Changes.ts index d33c395f9..0d1573a26 100644 --- a/src/Logic/Osm/Changes.ts +++ b/src/Logic/Osm/Changes.ts @@ -46,7 +46,7 @@ export class Changes { constructor( state: { - featureSwitches: { + featureSwitches?: { featureSwitchMorePrivacy?: Store featureSwitchIsTesting?: Store } @@ -56,8 +56,7 @@ export class Changes { historicalUserLocations?: FeatureSource allElements?: IndexedFeatureSource }, - leftRightSensitive: boolean = false, - reportError?: (string: string | Error, extramessage?: string) => void + leftRightSensitive: boolean = false ) { this._leftRightSensitive = leftRightSensitive // We keep track of all changes just as well @@ -73,7 +72,7 @@ export class Changes { } this.state = state this.backend = state.osmConnection.Backend() - this._reportError = reportError + this._reportError = state.reportError this._changesetHandler = new ChangesetHandler( state.featureSwitches?.featureSwitchIsTesting ?? new ImmutableStore(false), state.osmConnection, diff --git a/src/Logic/Search/OpenStreetMapIdSearch.ts b/src/Logic/Search/OpenStreetMapIdSearch.ts index c48fbfd72..83a5cc606 100644 --- a/src/Logic/Search/OpenStreetMapIdSearch.ts +++ b/src/Logic/Search/OpenStreetMapIdSearch.ts @@ -1,8 +1,8 @@ import { Store, UIEventSource } from "../UIEventSource" -import GeocodingProvider, { GeocodingOptions, GeocodeResult } from "./GeocodingProvider" +import GeocodingProvider, { GeocodeResult, GeocodingOptions } from "./GeocodingProvider" import { OsmId } from "../../Models/OsmFeature" -import { SpecialVisualizationState } from "../../UI/SpecialVisualization" import { Utils } from "../../Utils" +import OsmObjectDownloader from "../Osm/OsmObjectDownloader" export default class OpenStreetMapIdSearch implements GeocodingProvider { private static readonly regex = @@ -13,11 +13,11 @@ export default class OpenStreetMapIdSearch implements GeocodingProvider { w: "way", r: "relation", } + private readonly _osmObjectDownloader: OsmObjectDownloader - private readonly _state: SpecialVisualizationState - constructor(state: SpecialVisualizationState) { - this._state = state + constructor(osmObjectDownloader: OsmObjectDownloader) { + this._osmObjectDownloader = osmObjectDownloader } /** @@ -49,7 +49,7 @@ export default class OpenStreetMapIdSearch implements GeocodingProvider { private async getInfoAbout(id: OsmId): Promise { const [osm_type, osm_id] = id.split("/") - const obj = await this._state.osmObjectDownloader.DownloadObjectAsync(id) + const obj = await this._osmObjectDownloader.DownloadObjectAsync(id) if (obj === "deleted") { return { display_name: id + " was deleted", @@ -74,13 +74,13 @@ export default class OpenStreetMapIdSearch implements GeocodingProvider { } } - async search(query: string, options?: GeocodingOptions): Promise { + async search(query: string, _: GeocodingOptions): Promise { if (!isNaN(Number(query))) { const n = Number(query) return Utils.NoNullInplace( await Promise.all([ - this.getInfoAbout(`node/${n}`).catch((x) => undefined), - this.getInfoAbout(`way/${n}`).catch((x) => undefined), + this.getInfoAbout(`node/${n}`).catch(() => undefined), + this.getInfoAbout(`way/${n}`).catch(() => undefined), this.getInfoAbout(`relation/${n}`).catch(() => undefined), ]) ) diff --git a/src/Logic/State/SearchState.ts b/src/Logic/State/SearchState.ts index 215ebe72e..42b735e59 100644 --- a/src/Logic/State/SearchState.ts +++ b/src/Logic/State/SearchState.ts @@ -39,9 +39,9 @@ export default class SearchState { new LocalElementSearch(state, 5), new CoordinateSearch(), new OpenLocationCodeSearch(), - new OpenStreetMapIdSearch(state), + new OpenStreetMapIdSearch(state.osmObjectDownloader), new PhotonSearch(true, 2), - new PhotonSearch(), + new PhotonSearch() // new NominatimGeocoding(), ] @@ -122,7 +122,7 @@ export default class SearchState { const layersToShow = payload.map((fsr) => fsr.layer.id) console.log("Layers to show are", layersToShow) - for (const [name, otherLayer] of state.layerState.filteredLayers) { + for (const otherLayer of state.layerState.filteredLayers.values()) { const layer = otherLayer.layerDef if (!layer.isNormal()) { continue diff --git a/src/Logic/State/UserRelatedState.ts b/src/Logic/State/UserRelatedState.ts index f58e66d4e..0c3d1373f 100644 --- a/src/Logic/State/UserRelatedState.ts +++ b/src/Logic/State/UserRelatedState.ts @@ -17,7 +17,6 @@ import FeatureSwitchState from "./FeatureSwitchState" import Constants from "../../Models/Constants" import { QueryParameters } from "../Web/QueryParameters" import { ThemeMetaTagging } from "./UserSettingsMetaTagging" -import { MapProperties } from "../../Models/MapProperties" import Showdown from "showdown" import { LocalStorageSource } from "../Web/LocalStorageSource" import { GeocodeResult } from "../Search/GeocodingProvider" @@ -53,7 +52,7 @@ export class OptionallySyncedHistory { )) this.syncPreference.addCallback((syncmode) => { if (syncmode === "sync") { - let list = [...thisSession.data, ...synced.data].slice(0, maxHistory) + const list = [...thisSession.data, ...synced.data].slice(0, maxHistory) if (this._isSame) { for (let i = 0; i < list.length; i++) { for (let j = i + 1; j < list.length; j++) { @@ -178,7 +177,6 @@ export default class UserRelatedState { * Note: these are linked via OsmConnection.preferences which exports all preferences as UIEventSource */ public readonly preferencesAsTags: UIEventSource> - private readonly _mapProperties: MapProperties public readonly recentlyVisitedThemes: OptionallySyncedHistory public readonly recentlyVisitedSearch: OptionallySyncedHistory @@ -187,10 +185,9 @@ export default class UserRelatedState { osmConnection: OsmConnection, layout?: ThemeConfig, featureSwitches?: FeatureSwitchState, - mapProperties?: MapProperties + currentRasterLayer?: Store<{ properties: { id: string } }> ) { this.osmConnection = osmConnection - this._mapProperties = mapProperties this.showAllQuestionsAtOnce = UIEventSource.asBoolean( this.osmConnection.getPreference("show-all-questions", "false") @@ -224,7 +221,7 @@ export default class UserRelatedState { this.translationMode = this.initTranslationMode() this.homeLocation = this.initHomeLocation() - this.preferencesAsTags = this.initAmendedPrefs(layout, featureSwitches) + this.preferencesAsTags = this.initAmendedPrefs(layout, featureSwitches, currentRasterLayer) this.recentlyVisitedThemes = new OptionallySyncedHistory( "theme", @@ -405,7 +402,8 @@ export default class UserRelatedState { * */ private initAmendedPrefs( layout?: ThemeConfig, - featureSwitches?: FeatureSwitchState + featureSwitches?: FeatureSwitchState, + currentRasterLayer?: Store<{ properties: { id: string } }> ): UIEventSource> { const amendedPrefs = new UIEventSource>({ _theme: layout?.id, @@ -541,7 +539,7 @@ export default class UserRelatedState { if (tags[key] === null) { continue } - let pref = this.osmConnection.GetPreference(key, undefined, { prefix: "" }) + const pref = this.osmConnection.GetPreference(key, undefined, { prefix: "" }) pref.set(tags[key]) } @@ -560,7 +558,7 @@ export default class UserRelatedState { } } - this._mapProperties?.rasterLayer?.addCallbackAndRun((l) => { + currentRasterLayer?.addCallbackAndRun((l) => { amendedPrefs.data["__current_background"] = l?.properties?.id amendedPrefs.ping() }) diff --git a/src/Logic/Web/ThemeViewStateHashActor.ts b/src/Logic/Web/ThemeViewStateHashActor.ts index 6142c0394..76e566e1b 100644 --- a/src/Logic/Web/ThemeViewStateHashActor.ts +++ b/src/Logic/Web/ThemeViewStateHashActor.ts @@ -1,9 +1,16 @@ -import ThemeViewState from "../../Models/ThemeViewState" import Hash from "./Hash" import { MenuState } from "../../Models/MenuState" +import { IndexedFeatureSource } from "../FeatureSource/FeatureSource" +import { Feature } from "geojson" +import { UIEventSource } from "../UIEventSource" export default class ThemeViewStateHashActor { - private readonly _state: ThemeViewState + private readonly _state: { + indexedFeatures: IndexedFeatureSource, + selectedElement: UIEventSource, + guistate: MenuState, + previewedImage: UIEventSource + } private isUpdatingHash = false public static readonly documentation = [ @@ -16,7 +23,7 @@ export default class ThemeViewStateHashActor { "", "The possible hashes are:", "", - MenuState.pageNames.map((tab) => "`" + tab + "`").join(","), + MenuState.pageNames.map((tab) => "`" + tab + "`").join(",") ] /** @@ -28,7 +35,12 @@ export default class ThemeViewStateHashActor { * As such, we use a change in the hash to close the appropriate windows * */ - constructor(state: ThemeViewState) { + constructor(state: { + indexedFeatures: IndexedFeatureSource, + selectedElement: UIEventSource, + guistate: MenuState, + previewedImage: UIEventSource + }) { this._state = state const hashOnLoad = Hash.hash.data diff --git a/src/Models/ThemeViewState.ts b/src/Models/ThemeViewState.ts index ef14c40da..aae9381ff 100644 --- a/src/Models/ThemeViewState.ts +++ b/src/Models/ThemeViewState.ts @@ -1,82 +1,5 @@ import ThemeConfig from "./ThemeConfig/ThemeConfig" -import { SpecialVisualizationState } from "../UI/SpecialVisualization" -import { Changes } from "../Logic/Osm/Changes" -import { Store, UIEventSource } from "../Logic/UIEventSource" -import { - FeatureSource, - IndexedFeatureSource, - WritableFeatureSource, -} from "../Logic/FeatureSource/FeatureSource" -import { OsmConnection } from "../Logic/Osm/OsmConnection" -import { ExportableMap, MapProperties } from "./MapProperties" -import LayerState from "../Logic/State/LayerState" -import { Feature, Point, Polygon } from "geojson" -import FullNodeDatabaseSource from "../Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource" -import { Map as MlMap } from "maplibre-gl" -import InitialMapPositioning from "../Logic/Actors/InitialMapPositioning" -import { MapLibreAdaptor } from "../UI/Map/MapLibreAdaptor" -import { GeoLocationState } from "../Logic/State/GeoLocationState" -import FeatureSwitchState from "../Logic/State/FeatureSwitchState" -import { QueryParameters } from "../Logic/Web/QueryParameters" -import UserRelatedState from "../Logic/State/UserRelatedState" -import LayerConfig from "./ThemeConfig/LayerConfig" -import GeoLocationHandler from "../Logic/Actors/GeoLocationHandler" -import { AvailableRasterLayers, RasterLayerPolygon, RasterLayerUtils } from "./RasterLayers" -import ThemeSource from "../Logic/FeatureSource/Sources/ThemeSource" -import StaticFeatureSource from "../Logic/FeatureSource/Sources/StaticFeatureSource" -import FeaturePropertiesStore from "../Logic/FeatureSource/Actors/FeaturePropertiesStore" -import PerLayerFeatureSourceSplitter from "../Logic/FeatureSource/PerLayerFeatureSourceSplitter" -import FilteringFeatureSource from "../Logic/FeatureSource/Sources/FilteringFeatureSource" -import ShowDataLayer from "../UI/Map/ShowDataLayer" -import TitleHandler from "../Logic/Actors/TitleHandler" -import ChangeToElementsActor from "../Logic/Actors/ChangeToElementsActor" -import PendingChangesUploader from "../Logic/Actors/PendingChangesUploader" -import SelectedElementTagsUpdater from "../Logic/Actors/SelectedElementTagsUpdater" -import { BBox } from "../Logic/BBox" -import Constants from "./Constants" -import Hotkeys from "../UI/Base/Hotkeys" -import Translations from "../UI/i18n/Translations" -import { GeoIndexedStoreForLayer } from "../Logic/FeatureSource/Actors/GeoIndexedStore" -import { LastClickFeatureSource } from "../Logic/FeatureSource/Sources/LastClickFeatureSource" -import { MenuState } from "./MenuState" -import MetaTagging from "../Logic/MetaTagging" -import ChangeGeometryApplicator from "../Logic/FeatureSource/Sources/ChangeGeometryApplicator" -import { NewGeometryFromChangesFeatureSource } from "../Logic/FeatureSource/Sources/NewGeometryFromChangesFeatureSource" -import OsmObjectDownloader from "../Logic/Osm/OsmObjectDownloader" -import ShowOverlayRasterLayer from "../UI/Map/ShowOverlayRasterLayer" -import { Utils } from "../Utils" -import { EliCategory } from "./RasterLayerProperties" -import BackgroundLayerResetter from "../Logic/Actors/BackgroundLayerResetter" -import SaveFeatureSourceToLocalStorage from "../Logic/FeatureSource/Actors/SaveFeatureSourceToLocalStorage" -import BBoxFeatureSource from "../Logic/FeatureSource/Sources/TouchesBboxFeatureSource" -import ThemeViewStateHashActor from "../Logic/Web/ThemeViewStateHashActor" -import NoElementsInViewDetector, { - FeatureViewState, -} from "../Logic/Actors/NoElementsInViewDetector" -import FilteredLayer from "./FilteredLayer" -import { PreferredRasterLayerSelector } from "../Logic/Actors/PreferredRasterLayerSelector" -import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager" -import NearbyFeatureSource from "../Logic/FeatureSource/Sources/NearbyFeatureSource" -import FavouritesFeatureSource from "../Logic/FeatureSource/Sources/FavouritesFeatureSource" -import { ProvidedImage } from "../Logic/ImageProviders/ImageProvider" -import { GeolocationControlState } from "../UI/BigComponents/GeolocationControl" -import Zoomcontrol from "../UI/Zoomcontrol" -import { - SummaryTileSource, - SummaryTileSourceRewriter, -} from "../Logic/FeatureSource/TiledFeatureSource/SummaryTileSource" -import summaryLayer from "../assets/generated/layers/summary.json" -import last_click_layerconfig from "../assets/generated/layers/last_click.json" - -import { LayerConfigJson } from "./ThemeConfig/Json/LayerConfigJson" -import Hash from "../Logic/Web/Hash" -import { GeoOperations } from "../Logic/GeoOperations" -import { CombinedFetcher } from "../Logic/Web/NearbyImagesSearch" -import { GeocodeResult, GeocodingUtils } from "../Logic/Search/GeocodingProvider" -import SearchState from "../Logic/State/SearchState" -import { ShowDataLayerOptions } from "../UI/Map/ShowDataLayerOptions" -import { PanoramaxUploader } from "../Logic/ImageProviders/Panoramax" -import { Tag } from "../Logic/Tags/Tag" +import { WithImageState } from "./ThemeViewState/WithImageState" /** * @@ -86,1062 +9,8 @@ import { Tag } from "../Logic/Tags/Tag" * * It ties up all the needed elements and starts some actors. */ -export default class ThemeViewState implements SpecialVisualizationState { - readonly theme: ThemeConfig - readonly map: UIEventSource - readonly changes: Changes - readonly featureSwitches: FeatureSwitchState - readonly featureSwitchIsTesting: Store - readonly featureSwitchUserbadge: Store - - readonly featureProperties: FeaturePropertiesStore - - readonly osmConnection: OsmConnection - readonly selectedElement: UIEventSource - readonly mapProperties: MapLibreAdaptor & MapProperties & ExportableMap - readonly osmObjectDownloader: OsmObjectDownloader - - readonly dataIsLoading: Store - /** - * Indicates if there is _some_ data in view, even if it is not shown due to the filters - */ - readonly hasDataInView: Store - - readonly guistate: MenuState - readonly fullNodeDatabase?: FullNodeDatabaseSource - - readonly historicalUserLocations: WritableFeatureSource> - readonly indexedFeatures: IndexedFeatureSource & ThemeSource - readonly currentView: FeatureSource> - readonly featuresInView: FeatureSource - readonly favourites: FavouritesFeatureSource - /** - * Contains a few (<10) >features that are near the center of the map. - */ - readonly closestFeatures: NearbyFeatureSource - readonly newFeatures: WritableFeatureSource - readonly layerState: LayerState - readonly perLayer: ReadonlyMap - readonly perLayerFiltered: ReadonlyMap - - readonly availableLayers: { store: Store } - readonly userRelatedState: UserRelatedState - readonly geolocation: GeoLocationHandler - readonly geolocationControl: GeolocationControlState - - readonly imageUploadManager: ImageUploadManager - readonly previewedImage = new UIEventSource(undefined) - - readonly addNewPoint: UIEventSource = new UIEventSource(false) - /** - * When using arrow keys to move, the accessibility mode is activated, which has a small rectangle set. - * This is the 'viewport' which 'closestFeatures' uses to filter wilt - */ - readonly visualFeedbackViewportBounds: UIEventSource = new UIEventSource(undefined) - - readonly lastClickObject: LastClickFeatureSource - readonly overlayLayerStates: ReadonlyMap< - string, - { readonly isDisplayed: UIEventSource } - > - /** - * All 'level'-tags that are available with the current features - */ - readonly floors: Store - /** - * 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 = new UIEventSource(false) - public readonly toCacheSavers: ReadonlyMap - - public readonly nearbyImageSearcher: CombinedFetcher - /** - * Geocoded images that should be shown on the main map; probably only the currently hovered image - */ - public readonly geocodedImages: UIEventSource = new UIEventSource([]) - - public readonly searchState: SearchState - /** - * Used to check in the download panel if used - */ - public readonly featureSummary: SummaryTileSourceRewriter - +export default class ThemeViewState extends WithImageState { constructor(layout: ThemeConfig, mvtAvailableLayers: Set) { - Utils.initDomPurify() - this.theme = layout - this.featureSwitches = new FeatureSwitchState(layout) - this.guistate = new MenuState( - this.featureSwitches.featureSwitchWelcomeMessage.data, - layout.id - ) - this.map = new UIEventSource(undefined) - const geolocationState = new GeoLocationState() - this.osmConnection = new OsmConnection({ - dryRun: this.featureSwitches.featureSwitchIsTesting, - fakeUser: this.featureSwitches.featureSwitchFakeUser.data, - oauth_token: QueryParameters.GetQueryParameter( - "oauth_token", - undefined, - "Used to complete the login" - ), - }) - const initial = new InitialMapPositioning(layout, geolocationState, this.osmConnection) - this.mapProperties = new MapLibreAdaptor(this.map, initial, { correctClick: 20 }) - - this.featureSwitchIsTesting = this.featureSwitches.featureSwitchIsTesting - this.featureSwitchUserbadge = this.featureSwitches.featureSwitchEnableLogin - - this.userRelatedState = new UserRelatedState( - this.osmConnection, - layout, - this.featureSwitches, - this.mapProperties - ) - this.userRelatedState.fixateNorth.addCallbackAndRunD((fixated) => { - this.mapProperties.allowRotating.setData(fixated !== "yes") - }) - this.selectedElement = new UIEventSource(undefined, "Selected element") - - this.geolocation = new GeoLocationHandler( - geolocationState, - this.selectedElement, - this.mapProperties, - this.userRelatedState.gpsLocationHistoryRetentionTime - ) - this.geolocationControl = new GeolocationControlState(this.geolocation, this.mapProperties) - - this.availableLayers = AvailableRasterLayers.layersAvailableAt( - this.mapProperties.location, - this.osmConnection.isLoggedIn - ) - - this.layerState = new LayerState( - this.osmConnection, - layout.layers, - layout.id, - this.featureSwitches.featureSwitchLayerDefault - ) - - { - const overlayLayerStates = new Map }>() - for (const rasterInfo of this.theme.tileLayerSources) { - const isDisplayed = QueryParameters.GetBooleanQueryParameter( - "overlay-" + rasterInfo.id, - rasterInfo.defaultState ?? true, - "Whether or not overlay layer " + rasterInfo.id + " is shown" - ) - const state = { isDisplayed } - overlayLayerStates.set(rasterInfo.id, state) - new ShowOverlayRasterLayer(rasterInfo, this.map, this.mapProperties, state) - } - this.overlayLayerStates = overlayLayerStates - } - - { - /* Set up the layout source - * A bit tricky, as this is heavily intertwined with the 'changes'-element, which generates a stream of new and changed features too - */ - - if (this.theme.layers.some((l) => l._needsFullNodeDatabase)) { - this.fullNodeDatabase = new FullNodeDatabaseSource() - } - - const layoutSource = new ThemeSource( - layout.layers, - this.featureSwitches, - this.mapProperties, - this.osmConnection.Backend(), - (id) => this.layerState.filteredLayers.get(id).isDisplayed, - mvtAvailableLayers, - this.fullNodeDatabase - ) - - let currentViewIndex = 0 - const empty = [] - this.currentView = new StaticFeatureSource( - this.mapProperties.bounds.map((bbox) => { - if (!bbox) { - return empty - } - currentViewIndex++ - return [ - bbox.asGeoJson({ - zoom: this.mapProperties.zoom.data, - ...this.mapProperties.location.data, - id: "current_view_" + currentViewIndex, - }), - ] - }) - ) - this.featuresInView = new BBoxFeatureSource(layoutSource, this.mapProperties.bounds) - - this.dataIsLoading = layoutSource.isLoading - this.indexedFeatures = layoutSource - this.featureProperties = new FeaturePropertiesStore(layoutSource) - - this.changes = new Changes( - this, - layout?.isLeftRightSensitive() ?? false, - (e, extraMsg) => this.reportError(e, extraMsg) - ) - this.historicalUserLocations = this.geolocation.historicalUserLocations - this.newFeatures = new NewGeometryFromChangesFeatureSource( - this.changes, - layoutSource, - this.featureProperties - ) - layoutSource.addSource(this.newFeatures) - - const perLayer = new PerLayerFeatureSourceSplitter( - Array.from(this.layerState.filteredLayers.values()).filter( - (l) => l.layerDef?.source !== null - ), - new ChangeGeometryApplicator(this.indexedFeatures, this.changes), - { - constructStore: (features, layer) => - new GeoIndexedStoreForLayer(features, layer), - handleLeftovers: (features) => { - console.warn( - "Got ", - features.length, - "leftover features, such as", - features[0].properties - ) - }, - } - ) - this.perLayer = perLayer.perLayer - } - - this.floors = this.featuresInView.features.stabilized(500).map((features) => { - if (!features) { - return [] - } - const floors = new Set() - for (const feature of features) { - const level = feature.properties["_level"] - if (level) { - const levels = level.split(";") - for (const l of levels) { - floors.add(l) - } - } else { - floors.add("0") // '0' is the default and is thus _always_ present - } - } - const sorted = Array.from(floors) - // Sort alphabetically first, to deal with floor "A", "B" and "C" - sorted.sort() - sorted.sort((a, b) => { - // We use the laxer 'parseInt' to deal with floor '1A' - const na = parseInt(a) - const nb = parseInt(b) - if (isNaN(na) || isNaN(nb)) { - return 0 - } - return na - nb - }) - sorted.reverse(/* new list, no side-effects */) - return sorted - }) - - this.lastClickObject = new LastClickFeatureSource( - this.theme, - this.mapProperties.lastClickLocation, - this.userRelatedState.addNewFeatureMode - ) - - this.osmObjectDownloader = new OsmObjectDownloader( - this.osmConnection.Backend(), - this.changes - ) - - this.perLayerFiltered = this.showNormalDataOn(this.map) - this.closestFeatures = new NearbyFeatureSource( - this.mapProperties.location, - this.perLayerFiltered, - { - currentZoom: this.mapProperties.zoom, - layerState: this.layerState, - bounds: this.visualFeedbackViewportBounds.map( - (bounds) => bounds ?? this.mapProperties.bounds?.data, - [this.mapProperties.bounds] - ), - } - ) - this.featureSummary = this.setupSummaryLayer() - this.hasDataInView = new NoElementsInViewDetector(this).hasFeatureInView - this.imageUploadManager = new ImageUploadManager( - layout, - new PanoramaxUploader( - Constants.panoramax.url, - Constants.panoramax.token, - this.featureSwitchIsTesting.map((t) => - t ? Constants.panoramax.testsequence : Constants.panoramax.sequence - ) - ), - this.featureProperties, - this.osmConnection, - this.changes, - this.geolocation.geolocationState.currentGPSLocation, - this.indexedFeatures, - this.reportError - ) - this.favourites = new FavouritesFeatureSource(this) - const longAgo = new Date() - longAgo.setTime(new Date().getTime() - 5 * 365 * 24 * 60 * 60 * 1000) - this.nearbyImageSearcher = new CombinedFetcher(50, longAgo, this.indexedFeatures) - - this.toCacheSavers = layout.enableCache ? this.initSaveToLocalStorage() : undefined - - this.searchState = new SearchState(this) - - this.initActors() - this.drawSpecialLayers() - this.initHotkeys() - this.miscSetup() - this.focusOnMap() - if (!Utils.runningFromConsole) { - console.log("State setup completed", this) - } - } - - /* By focussing on the map, the keyboard panning and zoom with '+' and '+' works */ - public focusOnMap() { - if (this.map.data) { - this.map.data.getCanvas().focus() - return - } - this.map.addCallbackAndRunD((map) => { - map.on("load", () => { - map.getCanvas().focus() - }) - return true - }) - } - - public initSaveToLocalStorage() { - const toLocalStorage = new Map() - this.perLayer.forEach((fs, layerId) => { - if (fs.layer.layerDef.source.geojsonSource !== undefined) { - return // We don't cache external data layers - } - const storage = new SaveFeatureSourceToLocalStorage( - this.osmConnection.Backend(), - fs.layer.layerDef.id, - ThemeSource.fromCacheZoomLevel, - fs, - this.featureProperties, - fs.layer.layerDef.maxAgeOfCache - ) - toLocalStorage.set(layerId, storage) - }) - return toLocalStorage - } - - public showNormalDataOn(map: Store): ReadonlyMap { - const filteringFeatureSource = new Map() - this.perLayer.forEach((fs, layerName) => { - const doShowLayer = this.mapProperties.zoom.map( - (z) => { - if ( - (fs.layer.isDisplayed?.data ?? true) && - z >= (fs.layer.layerDef?.minzoom ?? 0) - ) { - return true - } - if (this.layerState.globalFilters.data.some((f) => f.forceShowOnMatch)) { - return true - } - return false - }, - [fs.layer.isDisplayed, this.layerState.globalFilters] - ) - - if (!doShowLayer.data && this.featureSwitches.featureSwitchFilter.data === false) { - /* This layer is hidden and there is no way to enable it (filterview is disabled or this layer doesn't show up in the filter view as the name is not defined) - * - * This means that we don't have to filter it, nor do we have to display it - * - * Note: it is tempting to also permanently disable the layer if it is not visible _and_ the layer name is hidden. - * However, this is _not_ correct: the layer might be hidden because zoom is not enough. Zooming in more _will_ reveal the layer! - * */ - return - } - const filtered = new FilteringFeatureSource( - fs.layer, - fs, - (id) => this.featureProperties.getStore(id), - this.layerState.globalFilters, - undefined, - this.mapProperties.zoom, - this.selectedElement - ) - filteringFeatureSource.set(layerName, filtered) - - new ShowDataLayer(map, { - layer: fs.layer.layerDef, - features: filtered, - doShowLayer, - metaTags: this.userRelatedState.preferencesAsTags, - selectedElement: this.selectedElement, - fetchStore: (id) => this.featureProperties.getStore(id), - }) - }) - return filteringFeatureSource - } - - public openNewDialog() { - 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) - } - - public showCurrentLocationOn(map: Store): ShowDataLayer { - const id = "gps_location" - const flayerGps = this.layerState.filteredLayers.get(id) - if (flayerGps === undefined) { - return - } - const features = this.geolocation.currentUserLocation - return new ShowDataLayer(map, { - features, - doShowLayer: flayerGps.isDisplayed, - layer: flayerGps.layerDef, - metaTags: this.userRelatedState.preferencesAsTags, - selectedElement: this.selectedElement, - }) - } - - /** - * Various small methods that need to be called - */ - private miscSetup() { - this.userRelatedState.a11y.addCallbackAndRunD((a11y) => { - if (a11y === "always") { - this.visualFeedback.setData(true) - } else if (a11y === "never") { - this.visualFeedback.setData(false) - } - }) - this.mapProperties.onKeyNavigationEvent((keyEvent) => { - if (this.userRelatedState.a11y.data === "never") { - return - } - if (["north", "east", "south", "west"].indexOf(keyEvent.key) >= 0) { - this.visualFeedback.setData(true) - return true // Our job is done, unregister - } - }) - - this.userRelatedState.markLayoutAsVisited(this.theme) - - this.selectedElement.addCallback((selected) => { - if (selected === undefined) { - Zoomcontrol.resetzoom() - } - }) - - if (this.theme.customCss !== undefined && window.location.pathname.indexOf("theme") >= 0) { - Utils.LoadCustomCss(this.theme.customCss) - } - - Hash.hash.addCallbackAndRunD((hash) => { - if (hash === "current_view" || hash.match(/current_view_[0-9]+/)) { - this.selectCurrentView() - } - }) - } - - private setSelectedElement(feature: Feature) { - const current = this.selectedElement.data - if ( - current?.properties?.id !== undefined && - current.properties.id === feature.properties.id - ) { - console.log("Not setting selected, same id", current, feature) - return // already set - } - this.selectedElement.setData(feature) - } - - /** - * Selects the feature that is 'i' closest to the map center - */ - private selectClosestAtCenter(i: number = 0) { - console.log("Selecting closest", i) - if (this.userRelatedState.a11y.data !== "never") { - this.visualFeedback.setData(true) - } - - const toSelect = this.closestFeatures.features?.data?.[i] - if (!toSelect) { - window.requestAnimationFrame(() => { - const toSelect = this.closestFeatures.features?.data?.[i] - if (!toSelect) { - return - } - this.setSelectedElement(toSelect) - }) - return - } - this.setSelectedElement(toSelect) - } - - private initHotkeys() { - const docs = Translations.t.hotkeyDocumentation - Hotkeys.RegisterHotkey({ nomod: "Escape", onUp: true }, docs.closeSidebar, () => { - if (this.previewedImage.data !== undefined) { - this.previewedImage.setData(undefined) - return - } - if (this.selectedElement.data) { - this.selectedElement.setData(undefined) - return - } - if (this.searchState.showSearchDrawer.data) { - this.searchState.showSearchDrawer.set(false) - return - } - if (this.guistate.closeAll()) { - return - } - Zoomcontrol.resetzoom() - this.focusOnMap() - }) - - Hotkeys.RegisterHotkey({ nomod: "f" }, docs.selectFavourites, () => { - this.guistate.pageStates.favourites.set(true) - }) - - Hotkeys.RegisterHotkey( - { - nomod: " ", - onUp: true, - }, - docs.selectItem, - () => { - if (this.selectedElement.data !== undefined) { - return false - } - if (this.guistate.isSomethingOpen() || this.previewedImage.data !== undefined) { - return - } - if ( - document.activeElement.tagName === "button" || - document.activeElement.tagName === "input" - ) { - return - } - this.selectClosestAtCenter(0) - } - ) - - for (let i = 1; i < 9; i++) { - let doc = docs.selectItemI.Subs({ i }) - if (i === 1) { - doc = docs.selectItem - } else if (i === 2) { - doc = docs.selectItem2 - } else if (i === 3) { - doc = docs.selectItem3 - } - Hotkeys.RegisterHotkey( - { - nomod: "" + i, - onUp: true, - }, - doc, - () => this.selectClosestAtCenter(i - 1) - ) - } - - Hotkeys.RegisterHotkey( - { ctrl: "F" }, - Translations.t.hotkeyDocumentation.selectSearch, - () => { - this.searchState.feedback.set(undefined) - this.searchState.searchIsFocused.set(true) - } - ) - - this.featureSwitches.featureSwitchBackgroundSelection.addCallbackAndRun((enable) => { - if (!enable) { - return - } - Hotkeys.RegisterHotkey( - { - nomod: "b", - }, - docs.openLayersPanel, - () => { - if (this.featureSwitches.featureSwitchBackgroundSelection.data) { - this.guistate.pageStates.background.setData(true) - } - } - ) - Hotkeys.RegisterHotkey( - { - nomod: "s", - }, - Translations.t.hotkeyDocumentation.openFilterPanel, - () => { - if (this.featureSwitches.featureSwitchFilter.data) { - this.guistate.openFilterView() - } - } - ) - const setLayerCategory = (category: EliCategory, skipLayers: number = 0) => { - const timeOfCall = new Date() - this.availableLayers.store.addCallbackAndRunD((available) => { - const now = new Date() - const timeDiff = (now.getTime() - timeOfCall.getTime()) / 1000 - if (timeDiff > 3) { - return true // unregister - } - const current = this.mapProperties.rasterLayer - const best = RasterLayerUtils.SelectBestLayerAccordingTo( - available, - category, - current.data, - skipLayers - ) - if (!best) { - return - } - console.log("Best layer for category", category, "is", best?.properties?.id) - current.setData(best) - }) - } - - Hotkeys.RegisterHotkey( - { nomod: "O" }, - Translations.t.hotkeyDocumentation.selectOsmbasedmap, - () => setLayerCategory("osmbasedmap") - ) - - Hotkeys.RegisterHotkey( - { nomod: "M" }, - Translations.t.hotkeyDocumentation.selectMap, - () => setLayerCategory("map") - ) - - Hotkeys.RegisterHotkey( - { nomod: "P" }, - Translations.t.hotkeyDocumentation.selectAerial, - () => setLayerCategory("photo") - ) - Hotkeys.RegisterHotkey( - { shift: "O" }, - Translations.t.hotkeyDocumentation.selectOsmbasedmap, - () => setLayerCategory("osmbasedmap", 2) - ) - - Hotkeys.RegisterHotkey( - { shift: "M" }, - Translations.t.hotkeyDocumentation.selectMap, - () => setLayerCategory("map", 2) - ) - - Hotkeys.RegisterHotkey( - { shift: "P" }, - Translations.t.hotkeyDocumentation.selectAerial, - () => setLayerCategory("photo", 2) - ) - Hotkeys.RegisterHotkey( - { nomod: "L" }, - Translations.t.hotkeyDocumentation.geolocate, - () => { - this.geolocationControl.handleClick() - } - ) - return true - }) - - Hotkeys.RegisterHotkey( - { - shift: "T", - }, - Translations.t.hotkeyDocumentation.translationMode, - () => { - const tm = this.userRelatedState.translationMode - if (tm.data === "false") { - tm.setData("true") - } else { - tm.setData("false") - } - } - ) - } - - private setupSummaryLayer(): SummaryTileSourceRewriter | undefined { - /** - * MaxZoom for the summary layer - */ - const normalLayers = this.theme.layers.filter((l) => l.isNormal()) - - const maxzoom = Math.min(...normalLayers.map((l) => l.minzoom)) - - const layers = this.theme.layers.filter( - (l) => - ((Constants.priviliged_layers)).indexOf(l.id) < 0 && - l.source.geojsonSource === undefined && - l.doCount - ) - if (!Constants.SummaryServer || layers.length === 0) { - return undefined - } - const summaryTileSource = new SummaryTileSource( - Constants.SummaryServer, - layers.map((l) => l.id), - this.mapProperties.zoom.map((z) => Math.max(Math.floor(z), 0)), - this.mapProperties, - { - isActive: this.mapProperties.zoom.map((z) => z < maxzoom), - } - ) - - return new SummaryTileSourceRewriter(summaryTileSource, this.layerState.filteredLayers) - } - - /** - * Add the special layers to the map - */ - private drawSpecialLayers() { - type AddedByDefaultTypes = (typeof Constants.added_by_default)[number] - const empty = [] - /** - * A listing which maps the layerId onto the featureSource - */ - const specialLayers: Record = { - home_location: this.userRelatedState.homeLocation, - gps_location: this.geolocation.currentUserLocation, - gps_location_history: this.geolocation.historicalUserLocations, - gps_track: this.geolocation.historicalUserLocationsTrack, - geocoded_image: new StaticFeatureSource(this.geocodedImages), - selected_element: new StaticFeatureSource( - this.selectedElement.map((f) => (f === undefined ? empty : [f])) - ), - range: new StaticFeatureSource( - this.mapProperties.maxbounds.map((bbox) => - bbox === undefined ? empty : [bbox.asGeoJson({ id: "range" })] - ) - ), - current_view: this.currentView, - favourite: this.favourites, - summary: this.featureSummary, - last_click: this.lastClickObject, - search: this.searchState.locationResults, - } - - this.closestFeatures.registerSource(specialLayers.favourite, "favourite") - if (this.theme?.lockLocation) { - const bbox = new BBox(<[[number, number], [number, number]]>this.theme.lockLocation) - this.mapProperties.maxbounds.setData(bbox) - ShowDataLayer.showRange( - this.map, - new StaticFeatureSource([bbox.asGeoJson({ id: "range" })]), - this.featureSwitches.featureSwitchIsTesting - ) - } - const currentViewLayer = this.theme.layers.find((l) => l.id === "current_view") - if (currentViewLayer?.tagRenderings?.length > 0) { - const params = MetaTagging.createExtraFuncParams(this) - this.featureProperties.trackFeatureSource(specialLayers.current_view) - specialLayers.current_view.features.addCallbackAndRunD((features) => { - MetaTagging.addMetatags( - features, - params, - currentViewLayer, - this.theme, - this.osmObjectDownloader, - this.featureProperties - ) - }) - } - - const rangeFLayer: FilteredLayer = this.layerState.filteredLayers.get("range") - const rangeIsDisplayed = rangeFLayer?.isDisplayed - if ( - rangeFLayer && - !QueryParameters.wasInitialized(FilteredLayer.queryParameterKey(rangeFLayer.layerDef)) - ) { - rangeIsDisplayed?.syncWith(this.featureSwitches.featureSwitchIsTesting, true) - } - - // enumerate all 'normal' layers and match them with the appropriate 'special' layer - if applicable - this.layerState.filteredLayers.forEach((flayer) => { - const id = flayer.layerDef.id - const features: FeatureSource = specialLayers[id] - if (!features?.features) { - return - } - if (id === "summary" || id === "last_click") { - return - } - - this.featureProperties.trackFeatureSource(features) - const options: ShowDataLayerOptions & { layer: LayerConfig } = { - features, - doShowLayer: flayer.isDisplayed, - layer: flayer.layerDef, - metaTags: this.userRelatedState.preferencesAsTags, - selectedElement: this.selectedElement, - } - if (flayer.layerDef.id === "search") { - options.onClick = (feature) => { - this.searchState.clickedOnMap(feature) - } - delete options.selectedElement - } - new ShowDataLayer(this.map, options) - }) - - // last click - { - const lastClickLayerConfig = new LayerConfig( - last_click_layerconfig, - "last_click" - ) - const lastClickFiltered = - lastClickLayerConfig.isShown === undefined - ? specialLayers.last_click - : specialLayers.last_click.features.mapD((fs) => - fs.filter((f) => { - const matches = lastClickLayerConfig.isShown.matchesProperties( - f.properties - ) - console.debug("LastClick ", f, "matches", matches) - return matches - }) - ) - // show last click = new point/note marker - new ShowDataLayer(this.map, { - features: new StaticFeatureSource(lastClickFiltered), - layer: lastClickLayerConfig, - onClick: (feature) => { - if (this.mapProperties.zoom.data >= Constants.minZoomLevelToAddNewPoint) { - this.selectedElement.setData(feature) - return - } - this.map.data.flyTo({ - zoom: Constants.minZoomLevelToAddNewPoint, - center: GeoOperations.centerpointCoordinates(feature), - }) - }, - }) - } - - if (specialLayers.summary) { - new ShowDataLayer(this.map, { - features: specialLayers.summary, - layer: new LayerConfig(summaryLayer, "summaryLayer"), - // doShowLayer: this.mapProperties.zoom.map((z) => z < maxzoom), - selectedElement: this.selectedElement, - }) - } - } - - /** - * Setup various services for which no reference are needed - */ - private initActors() { - if (!this.theme.official) { - // Add custom themes to the "visited custom themes" - const th = this.theme - this.userRelatedState.addUnofficialTheme({ - id: th.id, - icon: th.icon, - title: th.title.translations, - shortDescription: th.shortDescription.translations, - layers: th.layers.filter((l) => l.isNormal()).map((l) => l.id), - }) - } - - this.selectedElement.addCallback((selected) => { - if (selected === undefined) { - this.focusOnMap() - this.geocodedImages.set([]) - } else { - this.lastClickObject.clear() - } - }) - Object.values(this.guistate.pageStates).forEach((toggle) => { - toggle.addCallbackD((isOpened) => { - if (!isOpened) { - if (!this.guistate.isSomethingOpen()) { - this.focusOnMap() - } - } - }) - }) - - // Add the selected element to the recently visited history - this.selectedElement.addCallbackD((selected) => { - const [osm_type, osm_id] = selected.properties.id.split("/") - const [lon, lat] = GeoOperations.centerpointCoordinates(selected) - const layer = this.theme.getMatchingLayer(selected.properties) - - const nameOptions = [ - selected?.properties?.name, - selected?.properties?.alt_name, - selected?.properties?.local_name, - layer?.title.GetRenderValue(selected?.properties ?? {}).txt, - selected.properties.display_name, - selected.properties.id, - ] - const r = { - feature: selected, - display_name: nameOptions.find((opt) => opt !== undefined), - osm_id, - osm_type, - lon, - lat, - } - this.userRelatedState.recentlyVisitedSearch.add(r) - }) - - this.mapProperties.lastClickLocation.addCallbackD((lastClick) => { - if (lastClick.mode !== "left" || !lastClick.nearestFeature) { - return - } - const f = lastClick.nearestFeature - this.setSelectedElement(f) - }) - - this.userRelatedState.showScale.addCallbackAndRun((showScale) => { - this.mapProperties.showScale.set(showScale) - }) - - this.layerState.filteredLayers - .get("favourite") - ?.isDisplayed?.addCallbackAndRunD((favouritesShown) => { - const oldGlobal = this.layerState.globalFilters.data - const key = "show-favourite" - if (favouritesShown) { - this.layerState.globalFilters.set([ - ...oldGlobal, - { - forceShowOnMatch: true, - id: key, - osmTags: new Tag("_favourite", "yes"), - state: 0, - onNewPoint: undefined, - }, - ]) - } else { - this.layerState.globalFilters.set(oldGlobal.filter((gl) => gl.id !== key)) - } - }) - - new ThemeViewStateHashActor(this) - new MetaTagging(this) - new TitleHandler(this.selectedElement, this) - new ChangeToElementsActor(this.changes, this.featureProperties) - new PendingChangesUploader(this.changes, this.selectedElement, this.imageUploadManager) - new SelectedElementTagsUpdater(this) - new BackgroundLayerResetter(this.mapProperties.rasterLayer, this.availableLayers) - new PreferredRasterLayerSelector( - this.mapProperties.rasterLayer, - this.availableLayers, - this.featureSwitches.backgroundLayerId, - this.userRelatedState.preferredBackgroundLayer - ) - } - - public selectCurrentView() { - this.guistate.closeAll() - this.selectedElement.setData(this.currentView.features?.data?.[0]) - } - - /** - * Searches the appropriate layer - will first try if a special layer matches; if not, a normal layer will be used by delegating to the theme - */ - public getMatchingLayer(properties: Record): LayerConfig | undefined { - const id = properties.id - - if (id.startsWith("summary_")) { - // We don't select 'summary'-objects - return undefined - } - - if (id === "settings") { - return UserRelatedState.usersettingsConfig - } - if (id.startsWith(LastClickFeatureSource.newPointElementId)) { - return this.theme.layers.find((l) => l.id === "last_click") - } - if (id.startsWith("search_result")) { - return GeocodingUtils.searchLayer - } - if (id === "location_track") { - return this.theme.layers.find((l) => l.id === "gps_track") - } - return this.theme.getMatchingLayer(properties) - } - - public async reportError(message: string | Error | XMLHttpRequest, extramessage: string = "") { - if (Utils.runningFromConsole) { - console.error("Got (in themeViewSTate.reportError):", message, extramessage) - return - } - const isTesting = this.featureSwitchIsTesting?.data - console.log( - isTesting - ? ">>> _Not_ reporting error to report server as testmode is on" - : ">>> Reporting error to", - Constants.ErrorReportServer, - message - ) - if (isTesting) { - return - } - - if ("" + message === "[object XMLHttpRequest]") { - const req = message - let body = "" - try { - body = req.responseText - } catch (e) { - // pass - } - message = - "XMLHttpRequest with status code " + - req.status + - ", " + - req.statusText + - ", received: " + - body - } - - if (extramessage) { - message += " (" + extramessage + ")" - } - - const stacktrace: string = new Error().stack - - try { - await fetch(Constants.ErrorReportServer, { - method: "POST", - body: JSON.stringify({ - stacktrace, - message: "" + message, - theme: this.theme.id, - version: Constants.vNumber, - language: this.userRelatedState.language.data, - username: this.osmConnection.userDetails.data?.name, - userid: this.osmConnection.userDetails.data?.uid, - pendingChanges: this.changes.pendingChanges.data, - previousChanges: this.changes.allChanges.data, - changeRewrites: Utils.MapToObj(this.changes._changesetHandler._remappings), - }), - }) - } catch (e) { - console.error("Could not upload an error report") - } + super(layout, mvtAvailableLayers) } } diff --git a/src/Models/ThemeViewState/UserMapFeatureswitchState.ts b/src/Models/ThemeViewState/UserMapFeatureswitchState.ts new file mode 100644 index 000000000..d623dd567 --- /dev/null +++ b/src/Models/ThemeViewState/UserMapFeatureswitchState.ts @@ -0,0 +1,292 @@ +import ThemeConfig from "../ThemeConfig/ThemeConfig" +import { Store, UIEventSource } from "../../Logic/UIEventSource" +import { Map as MlMap } from "maplibre-gl" +import { GeoLocationState } from "../../Logic/State/GeoLocationState" +import InitialMapPositioning from "../../Logic/Actors/InitialMapPositioning" +import { MapLibreAdaptor } from "../../UI/Map/MapLibreAdaptor" +import { ExportableMap, MapProperties } from "../MapProperties" +import { LastClickFeatureSource } from "../../Logic/FeatureSource/Sources/LastClickFeatureSource" +import { PreferredRasterLayerSelector } from "../../Logic/Actors/PreferredRasterLayerSelector" +import { AvailableRasterLayers, RasterLayerPolygon, RasterLayerUtils } from "../RasterLayers" +import BackgroundLayerResetter from "../../Logic/Actors/BackgroundLayerResetter" +import Hotkeys from "../../UI/Base/Hotkeys" +import Translations from "../../UI/i18n/Translations" +import { EliCategory } from "../RasterLayerProperties" +import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource" +import { Feature, Point, Polygon } from "geojson" +import { FeatureSource, WritableFeatureSource } from "../../Logic/FeatureSource/FeatureSource" +import FullNodeDatabaseSource from "../../Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource" +import { WithUserRelatedState } from "./WithUserRelatedState" +import GeoLocationHandler from "../../Logic/Actors/GeoLocationHandler" +import { GeolocationControlState } from "../../UI/BigComponents/GeolocationControl" +import ShowOverlayRasterLayer from "../../UI/Map/ShowOverlayRasterLayer" +import { BBox } from "../../Logic/BBox" +import ShowDataLayer from "../../UI/Map/ShowDataLayer" + +/** + * The first core of the state management; everything related to: + * - the OSM connection + * - setting up the basemap + * - the feature switches + * - the GPS-location + * + * Anything that handles editable elements is _not_ done on this level. + * Anything that handles the UI is not done on this level + */ + +export class UserMapFeatureswitchState extends WithUserRelatedState { + + readonly map: UIEventSource + + + readonly mapProperties: MapLibreAdaptor & MapProperties & ExportableMap + readonly lastClickObject: LastClickFeatureSource + + readonly geolocationState: GeoLocationState + readonly geolocation: GeoLocationHandler + readonly geolocationControl: GeolocationControlState + readonly historicalUserLocations: WritableFeatureSource> + + + readonly availableLayers: { store: Store } + readonly currentView: FeatureSource> + readonly fullNodeDatabase?: FullNodeDatabaseSource + + + constructor(theme: ThemeConfig, selectedElement: Store) { + const rasterLayer: UIEventSource = new UIEventSource(undefined) + super(theme, rasterLayer) + this.geolocationState = new GeoLocationState() + const initial = new InitialMapPositioning(theme, this.geolocationState, this.osmConnection) + this.map = new UIEventSource(undefined) + this.mapProperties = new MapLibreAdaptor(this.map, { rasterLayer, ...initial }, { correctClick: 20 }) + + this.geolocation = new GeoLocationHandler( + this.geolocationState, + selectedElement, + this.mapProperties, + this.userRelatedState.gpsLocationHistoryRetentionTime + ) + this.geolocationControl = new GeolocationControlState(this.geolocation, this.mapProperties) + this.historicalUserLocations = this.geolocation.historicalUserLocations + + + this.userRelatedState.fixateNorth.addCallbackAndRunD((fixated) => { + this.mapProperties.allowRotating.setData(fixated !== "yes") + }) + + this.availableLayers = AvailableRasterLayers.layersAvailableAt( + this.mapProperties.location, + this.osmConnection.isLoggedIn + ) + + this.userRelatedState.markLayoutAsVisited(this.theme) + + this.lastClickObject = new LastClickFeatureSource( + this.theme, + this.mapProperties.lastClickLocation, + this.userRelatedState.addNewFeatureMode + ) + { + let currentViewIndex = 0 + const empty = [] + this.currentView = new StaticFeatureSource( + this.mapProperties.bounds.map((bbox) => { + if (!bbox) { + return empty + } + currentViewIndex++ + return [ + bbox.asGeoJson({ + zoom: this.mapProperties.zoom.data, + ...this.mapProperties.location.data, + id: "current_view_" + currentViewIndex + }) + ] + }) + ) + } + + if (this.theme.layers.some((l) => l._needsFullNodeDatabase)) { + this.fullNodeDatabase = new FullNodeDatabaseSource() + } + + + ///////// Actors /////////////// + + new BackgroundLayerResetter(this.mapProperties.rasterLayer, this.availableLayers) + + + this.userRelatedState.showScale.addCallbackAndRun((showScale) => { + this.mapProperties.showScale.set(showScale) + }) + new PreferredRasterLayerSelector( + this.mapProperties.rasterLayer, + this.availableLayers, + this.featureSwitches.backgroundLayerId, + this.userRelatedState.preferredBackgroundLayer + ) + + + this.initHotkeys() + this.drawOverlayLayers() + this.drawLock() + + } + + + /** + * If the map is locked to a certain area _and_ we are in test mode, draw this on the map + * @private + */ + private drawLock() { + if (!this.theme?.lockLocation) { + return + } + const bbox = new BBox(<[[number, number], [number, number]]>this.theme.lockLocation) + this.mapProperties.maxbounds.setData(bbox) + ShowDataLayer.showRange( + this.map, + new StaticFeatureSource([bbox.asGeoJson({ id: "range" })]), + this.featureSwitches.featureSwitchIsTesting + ) + } + + private drawOverlayLayers() { + for (const rasterInfo of this.theme.tileLayerSources) { + const state = this.overlayLayerStates.get(rasterInfo.id) + new ShowOverlayRasterLayer(rasterInfo, this.map, this.mapProperties, state) + } + } + + + /* By focussing on the map, the keyboard panning and zoom with '+' and '+' works */ + public focusOnMap() { + if (this.map.data) { + this.map.data.getCanvas().focus() + return + } + this.map.addCallbackAndRunD((map) => { + map.on("load", () => { + map.getCanvas().focus() + }) + return true + }) + } + + private initHotkeys() { + const docs = Translations.t.hotkeyDocumentation + + this.featureSwitches.featureSwitchBackgroundSelection.addCallbackAndRun((enable) => { + if (!enable) { + return + } + + const setLayerCategory = (category: EliCategory, skipLayers: number = 0) => { + const timeOfCall = new Date() + this.availableLayers.store.addCallbackAndRunD((available) => { + const now = new Date() + const timeDiff = (now.getTime() - timeOfCall.getTime()) / 1000 + if (timeDiff > 3) { + return true // unregister + } + const current = this.mapProperties.rasterLayer + const best = RasterLayerUtils.SelectBestLayerAccordingTo( + available, + category, + current.data, + skipLayers + ) + if (!best) { + return + } + console.log("Best layer for category", category, "is", best?.properties?.id) + current.setData(best) + }) + } + + Hotkeys.RegisterHotkey( + { nomod: "O" }, + docs.selectOsmbasedmap, + () => setLayerCategory("osmbasedmap") + ) + + Hotkeys.RegisterHotkey( + { nomod: "M" }, + docs.selectMap, + () => setLayerCategory("map") + ) + + Hotkeys.RegisterHotkey( + { nomod: "P" }, + docs.selectAerial, + () => setLayerCategory("photo") + ) + Hotkeys.RegisterHotkey( + { shift: "O" }, + docs.selectOsmbasedmap, + () => setLayerCategory("osmbasedmap", 2) + ) + + Hotkeys.RegisterHotkey( + { shift: "M" }, + docs.selectMap, + () => setLayerCategory("map", 2) + ) + + Hotkeys.RegisterHotkey( + { shift: "P" }, + docs.selectAerial, + () => setLayerCategory("photo", 2) + ) + + return true + }) + + Hotkeys.RegisterHotkey( + { nomod: "L" }, + Translations.t.hotkeyDocumentation.geolocate, + () => { + this.geolocationControl.handleClick() + } + ) + + Hotkeys.RegisterHotkey( + { + shift: "T" + }, + docs.translationMode, + () => { + const tm = this.userRelatedState.translationMode + if (tm.data === "false") { + tm.setData("true") + } else { + tm.setData("false") + } + } + ) + } + + /** + * Shows the current GPS-location marker on the given map. + * This is used to show the location on _other_ maps, e.g. on the map to add a new feature. + * + * This is _NOT_ to be used on the main map! + */ + public showCurrentLocationOn(map: Store) { + const id = "gps_location" + const layer = this.theme.getLayer(id) + if (layer === undefined) { + return + } + if (map === this.map) { + throw "Invalid use of showCurrentLocationOn" + } + const features = this.geolocation.currentUserLocation + return new ShowDataLayer(map, { + features, + layer, + metaTags: this.userRelatedState.preferencesAsTags + }) + } +} diff --git a/src/Models/ThemeViewState/WithChangesState.ts b/src/Models/ThemeViewState/WithChangesState.ts new file mode 100644 index 000000000..29f9de993 --- /dev/null +++ b/src/Models/ThemeViewState/WithChangesState.ts @@ -0,0 +1,236 @@ +import { Changes } from "../../Logic/Osm/Changes" +import { + NewGeometryFromChangesFeatureSource +} from "../../Logic/FeatureSource/Sources/NewGeometryFromChangesFeatureSource" +import { WithLayoutSourceState } from "./WithLayoutSourceState" +import ThemeConfig from "../ThemeConfig/ThemeConfig" +import { Utils } from "../../Utils" +import Constants from "../Constants" +import { WritableFeatureSource } from "../../Logic/FeatureSource/FeatureSource" +import OsmObjectDownloader from "../../Logic/Osm/OsmObjectDownloader" +import ChangeToElementsActor from "../../Logic/Actors/ChangeToElementsActor" +import MetaTagging from "../../Logic/MetaTagging" +import { GeoIndexedStoreForLayer } from "../../Logic/FeatureSource/Actors/GeoIndexedStore" +import SaveFeatureSourceToLocalStorage from "../../Logic/FeatureSource/Actors/SaveFeatureSourceToLocalStorage" +import ThemeSource from "../../Logic/FeatureSource/Sources/ThemeSource" +import PerLayerFeatureSourceSplitter from "../../Logic/FeatureSource/PerLayerFeatureSourceSplitter" +import ChangeGeometryApplicator from "../../Logic/FeatureSource/Sources/ChangeGeometryApplicator" +import { Store } from "../../Logic/UIEventSource" +import { Map as MlMap } from "maplibre-gl" +import FilteringFeatureSource from "../../Logic/FeatureSource/Sources/FilteringFeatureSource" +import ShowDataLayer from "../../UI/Map/ShowDataLayer" +import SelectedElementTagsUpdater from "../../Logic/Actors/SelectedElementTagsUpdater" +import NoElementsInViewDetector, { FeatureViewState } from "../../Logic/Actors/NoElementsInViewDetector" + +export class WithChangesState extends WithLayoutSourceState { + + readonly changes: Changes + readonly newFeatures: WritableFeatureSource + readonly osmObjectDownloader: OsmObjectDownloader + readonly perLayer: ReadonlyMap + readonly perLayerFiltered: ReadonlyMap + readonly toCacheSavers: ReadonlyMap + /** + * Indicates if there is _some_ data in view, even if it is not shown due to the filters + */ + readonly hasDataInView: Store + + constructor(theme: ThemeConfig, mvtAvailableLayers: Set) { + super(theme, mvtAvailableLayers) + this.changes = new Changes( + { + featureSwitches: this.featureSwitches, + allElements: this.indexedFeatures, + osmConnection: this.osmConnection, + featureProperties: this.featureProperties, + historicalUserLocations: this.historicalUserLocations, + reportError: this.reportError + }, + theme?.isLeftRightSensitive() ?? false + ) + this.newFeatures = new NewGeometryFromChangesFeatureSource( + this.changes, + this.indexedFeatures, + this.featureProperties + ) + this.indexedFeatures.addSource(this.newFeatures) + + this.osmObjectDownloader = new OsmObjectDownloader( + this.osmConnection.Backend(), + this.changes + ) + + const perLayer = new PerLayerFeatureSourceSplitter( + Array.from(this.layerState.filteredLayers.values()).filter( + (l) => l.layerDef?.source !== null + ), + new ChangeGeometryApplicator(this.indexedFeatures, this.changes), + { + constructStore: (features, layer) => + new GeoIndexedStoreForLayer(features, layer), + handleLeftovers: (features) => { + console.warn( + "Got ", + features.length, + "leftover features, such as", + features[0].properties + ) + } + } + ) + this.perLayer = perLayer.perLayer + this.perLayerFiltered = this.showNormalDataOn(this.map) + + this.hasDataInView = new NoElementsInViewDetector(this).hasFeatureInView + + this.toCacheSavers = theme.enableCache ? this.initSaveToLocalStorage() : undefined + + + ////// ACTORS //////// + + new ChangeToElementsActor(this.changes, this.featureProperties) + new SelectedElementTagsUpdater(this) + + new MetaTagging({ + theme: this.theme, + selectedElement: this.selectedElement, + featureProperties: this.featureProperties, + indexedFeatures: this.indexedFeatures, + osmObjectDownloader: this.osmObjectDownloader, + perLayer: this.perLayer + }) + } + + public async reportError(message: string | Error | XMLHttpRequest, extramessage: string = "") { + if (Utils.runningFromConsole) { + console.error("Got (in themeViewSTate.reportError):", message, extramessage) + return + } + const isTesting = this.featureSwitchIsTesting?.data + console.log( + isTesting + ? ">>> _Not_ reporting error to report server as testmode is on" + : ">>> Reporting error to", + Constants.ErrorReportServer, + message + ) + if (isTesting) { + return + } + + if ("" + message === "[object XMLHttpRequest]") { + const req = message + let body = "" + try { + body = req.responseText + } catch (e) { + // pass + } + message = + "XMLHttpRequest with status code " + + req.status + + ", " + + req.statusText + + ", received: " + + body + } + + if (extramessage) { + message += " (" + extramessage + ")" + } + + const stacktrace: string = new Error().stack + + try { + await fetch(Constants.ErrorReportServer, { + method: "POST", + body: JSON.stringify({ + stacktrace, + message: "" + message, + theme: this.theme.id, + version: Constants.vNumber, + language: this.userRelatedState.language.data, + username: this.osmConnection.userDetails.data?.name, + userid: this.osmConnection.userDetails.data?.uid, + pendingChanges: this.changes.pendingChanges.data, + previousChanges: this.changes.allChanges.data, + changeRewrites: Utils.MapToObj(this.changes._changesetHandler._remappings) + }) + }) + } catch (e) { + console.error("Could not upload an error report") + } + } + + public initSaveToLocalStorage() { + const toLocalStorage = new Map() + this.perLayer.forEach((fs, layerId) => { + if (fs.layer.layerDef.source.geojsonSource !== undefined) { + return // We don't cache external data layers + } + const storage = new SaveFeatureSourceToLocalStorage( + this.osmConnection.Backend(), + fs.layer.layerDef.id, + ThemeSource.fromCacheZoomLevel, + fs, + this.featureProperties, + fs.layer.layerDef.maxAgeOfCache + ) + toLocalStorage.set(layerId, storage) + }) + return toLocalStorage + } + + public showNormalDataOn(map: Store): ReadonlyMap { + const filteringFeatureSource = new Map() + this.perLayer.forEach((fs, layerName) => { + const doShowLayer = this.mapProperties.zoom.map( + (z) => { + if ( + (fs.layer.isDisplayed?.data ?? true) && + z >= (fs.layer.layerDef?.minzoom ?? 0) + ) { + return true + } + if (this.layerState.globalFilters.data.some((f) => f.forceShowOnMatch)) { + return true + } + return false + }, + [fs.layer.isDisplayed, this.layerState.globalFilters] + ) + + if (!doShowLayer.data && this.featureSwitches.featureSwitchFilter.data === false) { + /* This layer is hidden and there is no way to enable it (filterview is disabled or this layer doesn't show up in the filter view as the name is not defined) + * + * This means that we don't have to filter it, nor do we have to display it + * + * Note: it is tempting to also permanently disable the layer if it is not visible _and_ the layer name is hidden. + * However, this is _not_ correct: the layer might be hidden because zoom is not enough. Zooming in more _will_ reveal the layer! + * */ + return + } + const filtered = new FilteringFeatureSource( + fs.layer, + fs, + (id) => this.featureProperties.getStore(id), + this.layerState.globalFilters, + undefined, + this.mapProperties.zoom, + this.selectedElement + ) + filteringFeatureSource.set(layerName, filtered) + + new ShowDataLayer(map, { + layer: fs.layer.layerDef, + features: filtered, + doShowLayer, + metaTags: this.userRelatedState.preferencesAsTags, + selectedElement: this.selectedElement, + fetchStore: (id) => this.featureProperties.getStore(id) + }) + }) + return filteringFeatureSource + } + +} diff --git a/src/Models/ThemeViewState/WithGuiState.ts b/src/Models/ThemeViewState/WithGuiState.ts new file mode 100644 index 000000000..93798a26d --- /dev/null +++ b/src/Models/ThemeViewState/WithGuiState.ts @@ -0,0 +1,72 @@ +import ThemeConfig from "../ThemeConfig/ThemeConfig" +import { MenuState } from "../MenuState" +import Hotkeys from "../../UI/Base/Hotkeys" +import Translations from "../../UI/i18n/Translations" +import { WithSpecialLayers } from "./WithSpecialLayers" + +/** + * Does all things related to: + * - The UI state + */ +export class WithGuiState extends WithSpecialLayers { + readonly guistate: MenuState + + constructor(theme: ThemeConfig, mvtAvailableLayers: Set) { + super(theme, mvtAvailableLayers) + this.guistate = new MenuState( + this.featureSwitches.featureSwitchWelcomeMessage.data, + theme.id + ) + + Object.values(this.guistate.pageStates).forEach((toggle) => { + toggle.addCallbackD((isOpened) => { + // When a panel is closed: focus on the map again + if (!isOpened) { + if (!this.guistate.isSomethingOpen()) { + this.focusOnMap() + } + } + }) + }) + + this.initHotkeysGui() + } + + + private initHotkeysGui() { + const docs = Translations.t.hotkeyDocumentation + + Hotkeys.RegisterHotkey({ nomod: "f" }, docs.selectFavourites, () => { + this.guistate.pageStates.favourites.set(true) + }) + + Hotkeys.RegisterHotkey( + { + nomod: "b" + }, + docs.openLayersPanel, + () => { + if (this.featureSwitches.featureSwitchBackgroundSelection.data) { + this.guistate.pageStates.background.setData(true) + } + } + ) + Hotkeys.RegisterHotkey( + { + nomod: "s" + }, + Translations.t.hotkeyDocumentation.openFilterPanel, + () => { + if (this.featureSwitches.featureSwitchFilter.data) { + this.guistate.openFilterView() + } + } + ) + } + + public selectCurrentView() { + this.guistate.closeAll() + this.selectedElement.setData(this.currentView.features?.data?.[0]) + } + +} diff --git a/src/Models/ThemeViewState/WithImageState.ts b/src/Models/ThemeViewState/WithImageState.ts new file mode 100644 index 000000000..f84e645f5 --- /dev/null +++ b/src/Models/ThemeViewState/WithImageState.ts @@ -0,0 +1,68 @@ +import { ImageUploadManager } from "../../Logic/ImageProviders/ImageUploadManager" +import { UIEventSource } from "../../Logic/UIEventSource" +import { ProvidedImage } from "../../Logic/ImageProviders/ImageProvider" +import { CombinedFetcher } from "../../Logic/Web/NearbyImagesSearch" +import ThemeConfig from "../ThemeConfig/ThemeConfig" +import { PanoramaxUploader } from "../../Logic/ImageProviders/Panoramax" +import Constants from "../Constants" +import Hash from "../../Logic/Web/Hash" +import ThemeViewStateHashActor from "../../Logic/Web/ThemeViewStateHashActor" +import PendingChangesUploader from "../../Logic/Actors/PendingChangesUploader" +import { WithGuiState } from "./WithGuiState" +import { SpecialVisualizationState } from "../../UI/SpecialVisualization" + +export class WithImageState extends WithGuiState implements SpecialVisualizationState { + + readonly imageUploadManager: ImageUploadManager + readonly previewedImage = new UIEventSource(undefined) + readonly nearbyImageSearcher: CombinedFetcher + + + constructor(layout: ThemeConfig, mvtAvailableLayers: Set) { + super(layout, mvtAvailableLayers) + this.imageUploadManager = new ImageUploadManager( + layout, + new PanoramaxUploader( + Constants.panoramax.url, + Constants.panoramax.token, + this.featureSwitchIsTesting.map((t) => + t ? Constants.panoramax.testsequence : Constants.panoramax.sequence + ) + ), + this.featureProperties, + this.osmConnection, + this.changes, + this.geolocation.geolocationState.currentGPSLocation, + this.indexedFeatures, + this.reportError + ) + const longAgo = new Date() + longAgo.setTime(new Date().getTime() - 5 * 365 * 24 * 60 * 60 * 1000) + this.nearbyImageSearcher = new CombinedFetcher(50, longAgo, this.indexedFeatures) + + + this.initActors() + Hash.hash.addCallbackAndRunD((hash) => { + if (hash === "current_view" || hash.match(/current_view_[0-9]+/)) { + this.selectCurrentView() + } + }) + + } + + /** + * Setup various services for which no reference are needed + */ + private initActors() { + + new ThemeViewStateHashActor({ + previewedImage: this.previewedImage, + selectedElement: this.selectedElement, + indexedFeatures: this.indexedFeatures, + guistate: this.guistate + }) + new PendingChangesUploader(this.changes, this.selectedElement, this.imageUploadManager) + + + } +} diff --git a/src/Models/ThemeViewState/WithLayoutSourceState.ts b/src/Models/ThemeViewState/WithLayoutSourceState.ts new file mode 100644 index 000000000..5be9d0787 --- /dev/null +++ b/src/Models/ThemeViewState/WithLayoutSourceState.ts @@ -0,0 +1,129 @@ +import { WithSelectedElementState } from "./WithSelectedElementState" +import ThemeConfig from "../ThemeConfig/ThemeConfig" +import ThemeSource from "../../Logic/FeatureSource/Sources/ThemeSource" +import BBoxFeatureSource from "../../Logic/FeatureSource/Sources/TouchesBboxFeatureSource" +import FeaturePropertiesStore from "../../Logic/FeatureSource/Actors/FeaturePropertiesStore" +import LayerState from "../../Logic/State/LayerState" +import { Store } from "../../Logic/UIEventSource" +import { FeatureSource, IndexedFeatureSource } from "../../Logic/FeatureSource/FeatureSource" +import { Tag } from "../../Logic/Tags/Tag" + +export class WithLayoutSourceState extends WithSelectedElementState { + + readonly layerState: LayerState + readonly dataIsLoading: Store + + readonly featureProperties: FeaturePropertiesStore + readonly indexedFeatures: IndexedFeatureSource & ThemeSource + readonly featuresInView: FeatureSource + /** + * All 'level'-tags that are available with the current features + */ + readonly floors: Store + + + constructor(theme: ThemeConfig, mvtAvailableLayers: Set) { + super(theme) + /* Set up the layout source + * A bit tricky, as this is heavily intertwined with the 'changes'-element, which generates a stream of new and changed features too + */ + this.layerState = new LayerState( + this.osmConnection, + theme.layers, + theme.id, + this.featureSwitches.featureSwitchLayerDefault + ) + + const layoutSource = new ThemeSource( + theme.layers, + this.featureSwitches, + this.mapProperties, + this.osmConnection.Backend(), + (id) => this.layerState.filteredLayers.get(id).isDisplayed, + mvtAvailableLayers, + this.fullNodeDatabase + ) + + this.featuresInView = new BBoxFeatureSource(layoutSource, this.mapProperties.bounds) + this.dataIsLoading = layoutSource.isLoading + this.indexedFeatures = layoutSource + this.featureProperties = new FeaturePropertiesStore(layoutSource) + + this.floors = WithLayoutSourceState.initFloors(this.featuresInView) + + this.initFilters() + } + + /** + * Special bypass: if "favourites" is set, we still show items marked as 'favourite' even though the main layer is disabled + * @private + */ + private initFilters() { + this.layerState.filteredLayers + .get("favourite") + ?.isDisplayed?.addCallbackAndRunD((favouritesShown) => { + const oldGlobal = this.layerState.globalFilters.data + const key = "show-favourite" + if (favouritesShown) { + this.layerState.globalFilters.set([ + ...oldGlobal, + { + forceShowOnMatch: true, + id: key, + osmTags: new Tag("_favourite", "yes"), + state: 0, + onNewPoint: undefined + } + ]) + } else { + this.layerState.globalFilters.set(oldGlobal.filter((gl) => gl.id !== key)) + } + }) + + } + + private static initFloors(features: FeatureSource): Store { + return features.features.stabilized(500).map((features) => { + if (!features) { + return [] + } + const floors = new Set() + for (const feature of features) { + const level = feature.properties["_level"] + if (level) { + const levels = level.split(";") + for (const l of levels) { + floors.add(l) + } + } else { + floors.add("0") // '0' is the default and is thus _always_ present + } + } + const sorted = Array.from(floors) + // Sort alphabetically first, to deal with floor "A", "B" and "C" + sorted.sort() + sorted.sort((a, b) => { + // We use the laxer 'parseInt' to deal with floor '1A' + const na = parseInt(a) + const nb = parseInt(b) + if (isNaN(na) || isNaN(nb)) { + return 0 + } + return na - nb + }) + sorted.reverse(/* new list, no side-effects */) + return sorted + }) + } + + public openNewDialog() { + 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) + } + + +} diff --git a/src/Models/ThemeViewState/WithSearchState.ts b/src/Models/ThemeViewState/WithSearchState.ts new file mode 100644 index 000000000..979628557 --- /dev/null +++ b/src/Models/ThemeViewState/WithSearchState.ts @@ -0,0 +1,72 @@ +import ThemeConfig from "../ThemeConfig/ThemeConfig" +import SearchState from "../../Logic/State/SearchState" +import Hotkeys from "../../UI/Base/Hotkeys" +import Translations from "../../UI/i18n/Translations" +import Zoomcontrol from "../../UI/Zoomcontrol" +import { WithVisualFeedbackState } from "./WithVisualFeedbackState" +import { ShowDataLayerOptions } from "../../UI/Map/ShowDataLayerOptions" +import LayerConfig from "../ThemeConfig/LayerConfig" +import ShowDataLayer from "../../UI/Map/ShowDataLayer" + +export class WithSearchState extends WithVisualFeedbackState { + public readonly searchState: SearchState + + constructor(theme: ThemeConfig, mvtAvailableLayers: Set) { + super(theme, mvtAvailableLayers) + this.searchState = new SearchState(this) + this.initHotkeysSearch() + + { + // Register the search layer on the map + + const source = this.searchState.locationResults + const flayer = this.layerState.filteredLayers.get("search") + this.featureProperties.trackFeatureSource(source) + const options: ShowDataLayerOptions & { layer: LayerConfig } = { + features: source, + doShowLayer: flayer.isDisplayed, + layer: flayer.layerDef, + metaTags: this.userRelatedState.preferencesAsTags, + onClick: (feature) => { + this.searchState.clickedOnMap(feature) + } + } + new ShowDataLayer(this.map, options) + } + } + + private initHotkeysSearch() { + const docs = Translations.t.hotkeyDocumentation + + Hotkeys.RegisterHotkey( + { ctrl: "F" }, + docs.selectSearch, + () => { + this.searchState.feedback.set(undefined) + this.searchState.searchIsFocused.set(true) + } + ) + + Hotkeys.RegisterHotkey({ nomod: "Escape", onUp: true }, docs.closeSidebar, () => { + if (this.previewedImage.data !== undefined) { + this.previewedImage.setData(undefined) + return + } + if (this.selectedElement.data) { + this.selectedElement.setData(undefined) + return + } + if (this.searchState.showSearchDrawer.data) { + this.searchState.showSearchDrawer.set(false) + return + } + if (this.guistate.closeAll()) { + return + } + Zoomcontrol.resetzoom() + this.focusOnMap() + }) + + } + +} diff --git a/src/Models/ThemeViewState/WithSelectedElementState.ts b/src/Models/ThemeViewState/WithSelectedElementState.ts new file mode 100644 index 000000000..b6f7ec85c --- /dev/null +++ b/src/Models/ThemeViewState/WithSelectedElementState.ts @@ -0,0 +1,79 @@ +import { UserMapFeatureswitchState } from "./UserMapFeatureswitchState" +import ThemeConfig from "../ThemeConfig/ThemeConfig" +import { UIEventSource } from "../../Logic/UIEventSource" +import { Feature } from "geojson" +import Zoomcontrol from "../../UI/Zoomcontrol" +import { GeoOperations } from "../../Logic/GeoOperations" +import { GeocodeResult } from "../../Logic/Search/GeocodingProvider" + +/** + * The state interactions with a selected element, but is blind to loading elements + * + * + * No GUI stuff + */ +export class WithSelectedElementState extends UserMapFeatureswitchState { + + + readonly selectedElement: UIEventSource + + constructor(theme: ThemeConfig) { + const selectedElement = new UIEventSource(undefined, "Selected element") + super(theme, selectedElement) + this.selectedElement = selectedElement + this.selectedElement.addCallback((selected) => { + if (selected === undefined) { + Zoomcontrol.resetzoom() + } + }) + + this.mapProperties.lastClickLocation.addCallbackD((lastClick) => { + if (lastClick.mode !== "left" || !lastClick.nearestFeature) { + return + } + const f = lastClick.nearestFeature + this.setSelectedElement(f) + }) + + + // Add the selected element to the recently visited history + this.selectedElement.addCallbackD((selected) => { + const [osm_type, osm_id] = selected.properties.id.split("/") + const [lon, lat] = GeoOperations.centerpointCoordinates(selected) + const layer = this.theme.getMatchingLayer(selected.properties) + + const nameOptions = [ + selected?.properties?.name, + selected?.properties?.alt_name, + selected?.properties?.local_name, + layer?.title.GetRenderValue(selected?.properties ?? {}).txt, + selected.properties.display_name, + selected.properties.id + ] + const r = { + feature: selected, + display_name: nameOptions.find((opt) => opt !== undefined), + osm_id, + osm_type, + lon, + lat + } + this.userRelatedState.recentlyVisitedSearch.add(r) + }) + + } + + protected setSelectedElement(feature: Feature) { + const current = this.selectedElement.data + if ( + current?.properties?.id !== undefined && + current.properties.id === feature.properties.id + ) { + console.log("Not setting selected, same id", current, feature) + return // already set + } + this.selectedElement.setData(feature) + } + + +} diff --git a/src/Models/ThemeViewState/WithSpecialLayers.ts b/src/Models/ThemeViewState/WithSpecialLayers.ts new file mode 100644 index 000000000..4207a2dd7 --- /dev/null +++ b/src/Models/ThemeViewState/WithSpecialLayers.ts @@ -0,0 +1,227 @@ +import ThemeConfig from "../ThemeConfig/ThemeConfig" +import { WithChangesState } from "./WithChangesState" +import FavouritesFeatureSource from "../../Logic/FeatureSource/Sources/FavouritesFeatureSource" +import Constants from "../Constants" +import { FeatureSource } from "../../Logic/FeatureSource/FeatureSource" +import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource" +import { Feature } from "geojson" +import { BBox } from "../../Logic/BBox" +import ShowDataLayer from "../../UI/Map/ShowDataLayer" +import MetaTagging from "../../Logic/MetaTagging" +import FilteredLayer from "../FilteredLayer" +import LayerConfig from "../ThemeConfig/LayerConfig" +import { LayerConfigJson } from "../ThemeConfig/Json/LayerConfigJson" +import last_click_layerconfig from "../../assets/generated/layers/last_click.json" +import { GeoOperations } from "../../Logic/GeoOperations" +import summaryLayer from "../../assets/generated/layers/summary.json" +import { UIEventSource } from "../../Logic/UIEventSource" +import NearbyFeatureSource from "../../Logic/FeatureSource/Sources/NearbyFeatureSource" +import { + SummaryTileSource, + SummaryTileSourceRewriter +} from "../../Logic/FeatureSource/TiledFeatureSource/SummaryTileSource" +import { ShowDataLayerOptions } from "../../UI/Map/ShowDataLayerOptions" + +export class WithSpecialLayers extends WithChangesState { + + readonly favourites: FavouritesFeatureSource + /** + * When hovering (in the popup) an image, the location of the image will be revealed on the main map. + * This store contains those images that should be shown, probably only the currently hovered image + */ + readonly geocodedImages: UIEventSource = new UIEventSource([]) + /** + * Contains a few (<10) >features that are near the center of the map. + */ + readonly closestFeatures: NearbyFeatureSource + + readonly featureSummary: SummaryTileSourceRewriter + /** + * When using arrow keys to move, the accessibility mode is activated, which has a small rectangle set. + * This is the 'viewport' which 'closestFeatures' uses to filter wilt + */ + readonly visualFeedbackViewportBounds: UIEventSource = new UIEventSource(undefined) + + + constructor(theme: ThemeConfig, mvtAvailableLayers: Set) { + super(theme, mvtAvailableLayers) + + this.favourites = new FavouritesFeatureSource(this) + + this.closestFeatures = new NearbyFeatureSource( + this.mapProperties.location, + this.perLayerFiltered, + { + currentZoom: this.mapProperties.zoom, + layerState: this.layerState, + bounds: this.visualFeedbackViewportBounds.map( + (bounds) => bounds ?? this.mapProperties.bounds?.data, + [this.mapProperties.bounds] + ) + } + ) + this.closestFeatures.registerSource(this.favourites, "favourite") + + this.featureSummary = this.setupSummaryLayer() + this.initActorsSpecialLayers() + this.drawSpecialLayers() + this.drawLastClick() + // Note: the lock-range is handled by UserMapFeatureSwitchState + { + // Activate metatagging for the 'current_view' layer + console.log(">>>", this.layerState.filteredLayers) + const currentViewLayer = this.layerState.filteredLayers.get("current_view")?.layerDef + if (currentViewLayer?.tagRenderings?.length > 0) { + const params = MetaTagging.createExtraFuncParams(this) + this.currentView.features.addCallbackAndRunD((features) => { + MetaTagging.addMetatags( + features, + params, + currentViewLayer, + this.theme, + this.osmObjectDownloader, + this.featureProperties + ) + }) + } + } + + + } + + + private setupSummaryLayer(): SummaryTileSourceRewriter | undefined { + /** + * MaxZoom for the summary layer + */ + const normalLayers = this.theme.layers.filter((l) => l.isNormal()) + + const maxzoom = Math.min(...normalLayers.map((l) => l.minzoom)) + + const layers = this.theme.layers.filter( + (l) => + ((Constants.priviliged_layers)).indexOf(l.id) < 0 && + l.source.geojsonSource === undefined && + l.doCount + ) + if (!Constants.SummaryServer || layers.length === 0) { + return undefined + } + const summaryTileSource = new SummaryTileSource( + Constants.SummaryServer, + layers.map((l) => l.id), + this.mapProperties.zoom.map((z) => Math.max(Math.floor(z), 0)), + this.mapProperties, + { + isActive: this.mapProperties.zoom.map((z) => z < maxzoom) + } + ) + + const source = new SummaryTileSourceRewriter(summaryTileSource, this.layerState.filteredLayers) + + new ShowDataLayer(this.map, { + features: source, + layer: new LayerConfig(summaryLayer, "summaryLayer"), + // doShowLayer: this.mapProperties.zoom.map((z) => z < maxzoom), + selectedElement: this.selectedElement + }) + return source + } + + protected registerSpecialLayer(flayer: FilteredLayer, source: FeatureSource) { + if (!source?.features) { + return + } + + this.featureProperties.trackFeatureSource(source) + const options: ShowDataLayerOptions & { layer: LayerConfig } = { + features: source, + doShowLayer: flayer.isDisplayed, + layer: flayer.layerDef, + metaTags: this.userRelatedState.preferencesAsTags, + selectedElement: this.selectedElement + } + new ShowDataLayer(this.map, options) + } + + private drawLastClick() { + const source = this.lastClickObject + const lastClickLayerConfig = new LayerConfig( + last_click_layerconfig, + "last_click" + ) + const lastClickFiltered = + lastClickLayerConfig.isShown === undefined + ? source + : source.features.mapD((fs) => + fs.filter((f) => { + const matches = lastClickLayerConfig.isShown.matchesProperties( + f.properties + ) + console.debug("LastClick ", f, "matches", matches) + return matches + }) + ) + // show last click = new point/note marker + new ShowDataLayer(this.map, { + features: new StaticFeatureSource(lastClickFiltered), + layer: lastClickLayerConfig, + onClick: (feature) => { + if (this.mapProperties.zoom.data >= Constants.minZoomLevelToAddNewPoint) { + this.selectedElement.setData(feature) + return + } + this.map.data.flyTo({ + zoom: Constants.minZoomLevelToAddNewPoint, + center: GeoOperations.centerpointCoordinates(feature) + }) + } + }) + } + + private drawSpecialLayers() { + + type AddedByDefaultTypes = (typeof Constants.added_by_default)[number] + type LayersToAdd = "current_view" | Exclude + const empty = [] + /** + * A listing which maps the layerId onto the featureSource + */ + const specialLayers: Record = { + home_location: this.userRelatedState.homeLocation, + gps_location: this.geolocation.currentUserLocation, + gps_location_history: this.geolocation.historicalUserLocations, + gps_track: this.geolocation.historicalUserLocationsTrack, + current_view: this.currentView, + favourite: this.favourites, + geocoded_image: new StaticFeatureSource(this.geocodedImages), + selected_element: new StaticFeatureSource( + this.selectedElement.map((f) => (f === undefined ? empty : [f])) + ) + } + + + // enumerate all 'normal' layers and match them with the appropriate 'special' layer - if applicable + this.layerState.filteredLayers.forEach((flayer) => { + this.registerSpecialLayer(flayer, specialLayers[flayer.layerDef.id]) + }) + + } + + private initActorsSpecialLayers() { + this.selectedElement.addCallback((selected) => { + if (selected === undefined) { + this.focusOnMap() + this.geocodedImages.set([]) + } else { + this.lastClickObject.clear() + } + }) + } + +} diff --git a/src/Models/ThemeViewState/WithUserRelatedState.ts b/src/Models/ThemeViewState/WithUserRelatedState.ts new file mode 100644 index 000000000..6f9dc8a2a --- /dev/null +++ b/src/Models/ThemeViewState/WithUserRelatedState.ts @@ -0,0 +1,107 @@ +import { Utils } from "../../Utils" +import ThemeConfig from "../ThemeConfig/ThemeConfig" +import { OsmConnection } from "../../Logic/Osm/OsmConnection" +import UserRelatedState from "../../Logic/State/UserRelatedState" +import { QueryParameters } from "../../Logic/Web/QueryParameters" +import FeatureSwitchState from "../../Logic/State/FeatureSwitchState" +import { Store, UIEventSource } from "../../Logic/UIEventSource" +import LayerConfig from "../ThemeConfig/LayerConfig" +import { LastClickFeatureSource } from "../../Logic/FeatureSource/Sources/LastClickFeatureSource" +import { GeocodingUtils } from "../../Logic/Search/GeocodingProvider" + +export class WithUserRelatedState { + readonly theme: ThemeConfig + + readonly featureSwitches: FeatureSwitchState + readonly featureSwitchIsTesting: Store + readonly featureSwitchUserbadge: Store + + readonly osmConnection: OsmConnection + readonly userRelatedState: UserRelatedState + readonly overlayLayerStates: ReadonlyMap< + string, + { readonly isDisplayed: UIEventSource } + > + + constructor(theme: ThemeConfig, rasterLayer: Store<{ properties: { id: string } }>) { + { + // Some weird setups + Utils.initDomPurify() + if (!Utils.runningFromConsole && theme.customCss !== undefined && window.location.pathname.indexOf("theme") >= 0) { + Utils.LoadCustomCss(theme.customCss) + } + } + this.theme = theme + this.featureSwitches = new FeatureSwitchState(theme) + this.osmConnection = new OsmConnection({ + dryRun: this.featureSwitches.featureSwitchIsTesting, + fakeUser: this.featureSwitches.featureSwitchFakeUser.data, + oauth_token: QueryParameters.GetQueryParameter( + "oauth_token", + undefined, + "Used to complete the login" + ) + }) + + this.featureSwitchIsTesting = this.featureSwitches.featureSwitchIsTesting + this.featureSwitchUserbadge = this.featureSwitches.featureSwitchEnableLogin + + this.userRelatedState = new UserRelatedState( + this.osmConnection, + theme, + this.featureSwitches, + rasterLayer + ) + + if (!this.theme.official) { + // Add custom themes to the "visited custom themes" + const th = this.theme + this.userRelatedState.addUnofficialTheme({ + id: th.id, + icon: th.icon, + title: th.title.translations, + shortDescription: th.shortDescription.translations, + layers: th.layers.filter((l) => l.isNormal()).map((l) => l.id) + }) + } + + const overlayLayerStates = new Map }>() + this.overlayLayerStates = overlayLayerStates + for (const rasterInfo of this.theme.tileLayerSources) { + const isDisplayed = QueryParameters.GetBooleanQueryParameter( + "overlay-" + rasterInfo.id, + rasterInfo.defaultState ?? true, + "Whether or not overlay layer " + rasterInfo.id + " is shown" + ) + const state = { isDisplayed } + overlayLayerStates.set(rasterInfo.id, state) + } + } + + /** + * Searches the appropriate layer - will first try if a special layer matches; if not, a normal layer will be used by delegating to the theme + */ + public getMatchingLayer(properties: Record): LayerConfig | undefined { + const id = properties.id + + if (id.startsWith("summary_")) { + // We don't select 'summary'-objects + return undefined + } + + if (id === "settings") { + return UserRelatedState.usersettingsConfig + } + if (id.startsWith(LastClickFeatureSource.newPointElementId)) { + return this.theme.layers.find((l) => l.id === "last_click") + } + if (id.startsWith("search_result")) { + return GeocodingUtils.searchLayer + } + if (id === "location_track") { + return this.theme.layers.find((l) => l.id === "gps_track") + } + return this.theme.getMatchingLayer(properties) + } + +} diff --git a/src/Models/ThemeViewState/WithVisualFeedbackState.ts b/src/Models/ThemeViewState/WithVisualFeedbackState.ts new file mode 100644 index 000000000..99a317c28 --- /dev/null +++ b/src/Models/ThemeViewState/WithVisualFeedbackState.ts @@ -0,0 +1,108 @@ +import ThemeConfig from "../ThemeConfig/ThemeConfig" +import { UIEventSource } from "../../Logic/UIEventSource" +import Hotkeys from "../../UI/Base/Hotkeys" +import Translations from "../../UI/i18n/Translations" +import ThemeViewState from "../ThemeViewState" + +export class WithVisualFeedbackState extends ThemeViewState { + /** + * 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 = new UIEventSource(false) + + constructor(theme: ThemeConfig, mvtAvailableLayers: Set) { + super(theme, mvtAvailableLayers) + this.initHotkeysVisualFeedback() + + ///// ACTORS ///// + + this.userRelatedState.a11y.addCallbackAndRunD((a11y) => { + if (a11y === "always") { + this.visualFeedback.setData(true) + } else if (a11y === "never") { + this.visualFeedback.setData(false) + } + }) + this.mapProperties.onKeyNavigationEvent((keyEvent) => { + if (this.userRelatedState.a11y.data === "never") { + return + } + if (["north", "east", "south", "west"].indexOf(keyEvent.key) >= 0) { + this.visualFeedback.setData(true) + return true // Our job is done, unregister + } + }) + } + + + /** + * Selects the feature that is 'i' closest to the map center + */ + private selectClosestAtCenter(i: number = 0) { + console.log("Selecting closest", i) + if (this.userRelatedState.a11y.data !== "never") { + this.visualFeedback.setData(true) + } + + const toSelect = this.closestFeatures.features?.data?.[i] + if (!toSelect) { + window.requestAnimationFrame(() => { + const toSelect = this.closestFeatures.features?.data?.[i] + if (!toSelect) { + return + } + this.setSelectedElement(toSelect) + }) + return + } + this.setSelectedElement(toSelect) + } + + private initHotkeysVisualFeedback() { + const docs = Translations.t.hotkeyDocumentation + + Hotkeys.RegisterHotkey( + { + nomod: " ", + onUp: true + }, + docs.selectItem, + () => { + if (this.selectedElement.data !== undefined) { + return false + } + if (this.guistate.isSomethingOpen() || this.previewedImage.data !== undefined) { + return + } + if ( + document.activeElement.tagName === "button" || + document.activeElement.tagName === "input" + ) { + return + } + this.selectClosestAtCenter(0) + } + ) + + for (let i = 1; i < 9; i++) { + let doc = docs.selectItemI.Subs({ i }) + if (i === 1) { + doc = docs.selectItem + } else if (i === 2) { + doc = docs.selectItem2 + } else if (i === 3) { + doc = docs.selectItem3 + } + Hotkeys.RegisterHotkey( + { + nomod: "" + i, + onUp: true + }, + doc, + () => this.selectClosestAtCenter(i - 1) + ) + } + + } +} diff --git a/src/UI/BigComponents/NewPointLocationInput.svelte b/src/UI/BigComponents/NewPointLocationInput.svelte index 4156acade..b7181299d 100644 --- a/src/UI/BigComponents/NewPointLocationInput.svelte +++ b/src/UI/BigComponents/NewPointLocationInput.svelte @@ -60,7 +60,7 @@ export let presetProperties: Tag[] = [] let presetPropertiesUnpacked = TagUtils.KVtoProperties(presetProperties) - export let snappedTo: UIEventSource + export let snappedTo: UIEventSource = new UIEventSource(undefined) const map: UIEventSource = new UIEventSource(undefined) export let mapProperties: Partial & { location } = { diff --git a/src/UI/Favourites/FavouriteSummary.svelte b/src/UI/Favourites/FavouriteSummary.svelte index e83af80e7..54edb80ee 100644 --- a/src/UI/Favourites/FavouriteSummary.svelte +++ b/src/UI/Favourites/FavouriteSummary.svelte @@ -1,13 +1,13 @@ diff --git a/src/UI/Popup/ImportButtons/ImportFlow.svelte b/src/UI/Popup/ImportButtons/ImportFlow.svelte index cb63dd8b9..c2b231920 100644 --- a/src/UI/Popup/ImportButtons/ImportFlow.svelte +++ b/src/UI/Popup/ImportButtons/ImportFlow.svelte @@ -20,9 +20,11 @@ import FilteredLayer from "../../../Models/FilteredLayer" import Confirm from "../../../assets/svg/Confirm.svelte" import Layers from "../../../assets/svg/Layers.svelte" + import { WithGuiState } from "../../../Models/ThemeViewState/WithGuiState" + import ThemeViewState from "../../../Models/ThemeViewState" export let importFlow: ImportFlow - let state = importFlow.state + let state: ThemeViewState = importFlow.state export let currentFlowStep: "start" | "confirm" | "importing" | "imported" = "start" diff --git a/src/UI/Popup/ImportButtons/ImportFlow.ts b/src/UI/Popup/ImportButtons/ImportFlow.ts index 0c9adf821..d06fb20b1 100644 --- a/src/UI/Popup/ImportButtons/ImportFlow.ts +++ b/src/UI/Popup/ImportButtons/ImportFlow.ts @@ -1,4 +1,3 @@ -import { SpecialVisualizationState } from "../../SpecialVisualization" import { Utils } from "../../../Utils" import { ImmutableStore, Store, UIEventSource } from "../../../Logic/UIEventSource" import { Tag } from "../../../Logic/Tags/Tag" @@ -10,6 +9,7 @@ import FilteredLayer from "../../../Models/FilteredLayer" import LayerConfig from "../../../Models/ThemeConfig/LayerConfig" import { LayerConfigJson } from "../../../Models/ThemeConfig/Json/LayerConfigJson" import conflation_json from "../../../../assets/layers/conflation/conflation.json" +import ThemeViewState from "../../../Models/ThemeViewState" export interface ImportFlowArguments { readonly text: string @@ -123,7 +123,7 @@ ${Utils.special_visualizations_importRequirementDocs} argsRaw: string[] ): string[] { const deps = ImportFlowUtils.getLayerDependencies(argsRaw, argSpec) - const argsParsed: PointImportFlowArguments = Utils.ParseVisArgs(argSpec, argsRaw) + const argsParsed: PointImportFlowArguments = Utils.ParseVisArgs (argSpec, argsRaw) const snapOntoLayers = argsParsed.snap_onto_layers?.split(";")?.map((l) => l.trim()) ?? [] deps.push(...snapOntoLayers) return deps @@ -136,14 +136,14 @@ ${Utils.special_visualizations_importRequirementDocs} * This class works together closely with ImportFlow.svelte */ export default abstract class ImportFlow { - public readonly state: SpecialVisualizationState + public readonly state: ThemeViewState public readonly args: ArgT public readonly targetLayer: FilteredLayer[] public readonly tagsToApply: Store protected readonly _originalFeatureTags: UIEventSource> constructor( - state: SpecialVisualizationState, + state: ThemeViewState, args: ArgT, tagsToApply: Store, originalTags: UIEventSource> @@ -153,7 +153,7 @@ export default abstract class ImportFlow { this.tagsToApply = tagsToApply this._originalFeatureTags = originalTags this.targetLayer = args.targetLayer.split(" ").map((tl) => { - let found = state.layerState.filteredLayers.get(tl) + const found = state.layerState.filteredLayers.get(tl) if (!found) { throw "Layer " + tl + " not found" } diff --git a/src/UI/Popup/ImportButtons/PointImportFlowState.ts b/src/UI/Popup/ImportButtons/PointImportFlowState.ts index 27a983a16..395b3472f 100644 --- a/src/UI/Popup/ImportButtons/PointImportFlowState.ts +++ b/src/UI/Popup/ImportButtons/PointImportFlowState.ts @@ -1,5 +1,4 @@ import ImportFlow, { ImportFlowArguments } from "./ImportFlow" -import { SpecialVisualizationState } from "../../SpecialVisualization" import { Store, UIEventSource } from "../../../Logic/UIEventSource" import { OsmObject, OsmWay } from "../../../Logic/Osm/OsmObject" import CreateNewNodeAction from "../../../Logic/Osm/Actions/CreateNewNodeAction" @@ -7,6 +6,7 @@ import { Feature, Point } from "geojson" import Maproulette from "../../../Logic/Maproulette" import { GeoOperations } from "../../../Logic/GeoOperations" import { Tag } from "../../../Logic/Tags/Tag" +import ThemeViewState from "../../../Models/ThemeViewState" export interface PointImportFlowArguments extends ImportFlowArguments { max_snap_distance?: string @@ -21,7 +21,7 @@ export class PointImportFlowState extends ImportFlow { public readonly startCoordinate: [number, number] constructor( - state: SpecialVisualizationState, + state: ThemeViewState, originalFeature: Feature, args: PointImportFlowArguments, tagsToApply: Store, diff --git a/src/UI/Popup/Notes/CreateNewNote.svelte b/src/UI/Popup/Notes/CreateNewNote.svelte index 4ae1975b0..c85a64572 100644 --- a/src/UI/Popup/Notes/CreateNewNote.svelte +++ b/src/UI/Popup/Notes/CreateNewNote.svelte @@ -20,9 +20,10 @@ import NextButton from "../../Base/NextButton.svelte" import Note from "../../../assets/svg/Note.svelte" import TitledPanel from "../../Base/TitledPanel.svelte" + import ThemeViewState from "../../../Models/ThemeViewState" export let coordinate: UIEventSource<{ lon: number; lat: number }> - export let state: SpecialVisualizationState + export let state: ThemeViewState let comment: UIEventSource = LocalStorageSource.get("note-text") let created = false @@ -33,7 +34,6 @@ let isDisplayed = notelayer?.isDisplayed let submitted = false - let textEntered = false function enableNoteLayer() { state.guistate.closeAll() diff --git a/src/UI/Reviews/SingleReview.svelte b/src/UI/Reviews/SingleReview.svelte index 4972caccd..484a5a6c0 100644 --- a/src/UI/Reviews/SingleReview.svelte +++ b/src/UI/Reviews/SingleReview.svelte @@ -5,9 +5,9 @@ import Translations from "../i18n/Translations" import Tr from "../Base/Tr.svelte" import { ariaLabel } from "../../Utils/ariaLabel" - import type { SpecialVisualizationState } from "../SpecialVisualization" + import ThemeViewState from "../../Models/ThemeViewState" - export let state: SpecialVisualizationState = undefined + export let state: ThemeViewState = undefined export let review: Review & { kid: string signature: string diff --git a/src/UI/Search/GeocodeResults.svelte b/src/UI/Search/GeocodeResults.svelte index b241dae69..ca1d3419a 100644 --- a/src/UI/Search/GeocodeResults.svelte +++ b/src/UI/Search/GeocodeResults.svelte @@ -13,8 +13,9 @@ import { CogIcon } from "@rgossiaux/svelte-heroicons/solid" import { TrashIcon } from "@babeard/svelte-heroicons/mini" import type { GeocodeResult } from "../../Logic/Search/GeocodingProvider" + import { WithSearchState } from "../../Models/ThemeViewState/WithSearchState" - export let state: SpecialVisualizationState + export let state: WithSearchState let searchTerm = state.searchState.searchTerm let results = state.searchState.suggestions diff --git a/src/UI/SpecialVisualization.ts b/src/UI/SpecialVisualization.ts index 80ce2c608..34e545f05 100644 --- a/src/UI/SpecialVisualization.ts +++ b/src/UI/SpecialVisualization.ts @@ -1,11 +1,7 @@ import { Store, UIEventSource } from "../Logic/UIEventSource" import BaseUIElement from "./BaseUIElement" import ThemeConfig from "../Models/ThemeConfig/ThemeConfig" -import { - FeatureSource, - IndexedFeatureSource, - WritableFeatureSource, -} from "../Logic/FeatureSource/FeatureSource" +import { FeatureSource, IndexedFeatureSource, WritableFeatureSource } from "../Logic/FeatureSource/FeatureSource" import { OsmConnection } from "../Logic/Osm/OsmConnection" import { Changes } from "../Logic/Osm/Changes" import { ExportableMap, MapProperties } from "../Models/MapProperties" @@ -15,7 +11,6 @@ import FullNodeDatabaseSource from "../Logic/FeatureSource/TiledFeatureSource/Fu import { GeoIndexedStoreForLayer } from "../Logic/FeatureSource/Actors/GeoIndexedStore" import LayerConfig from "../Models/ThemeConfig/LayerConfig" import FeatureSwitchState from "../Logic/State/FeatureSwitchState" -import { MenuState } from "../Models/MenuState" import OsmObjectDownloader from "../Logic/Osm/OsmObjectDownloader" import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager" import FavouritesFeatureSource from "../Logic/FeatureSource/Sources/FavouritesFeatureSource" @@ -25,7 +20,6 @@ import ThemeSource from "../Logic/FeatureSource/Sources/ThemeSource" import { Map as MlMap } from "maplibre-gl" import ShowDataLayer from "./Map/ShowDataLayer" import { CombinedFetcher } from "../Logic/Web/NearbyImagesSearch" -import SearchState from "../Logic/State/SearchState" import UserRelatedState from "../Logic/State/UserRelatedState" import FeaturePropertiesStore from "../Logic/FeatureSource/Actors/FeaturePropertiesStore" @@ -33,7 +27,6 @@ import FeaturePropertiesStore from "../Logic/FeatureSource/Actors/FeaturePropert * The state needed to render a special Visualisation. */ export interface SpecialVisualizationState { - readonly guistate: MenuState readonly theme: ThemeConfig readonly featureSwitches: FeatureSwitchState @@ -82,7 +75,6 @@ export interface SpecialVisualizationState { readonly nearbyImageSearcher: CombinedFetcher readonly geolocation: GeoLocationHandler readonly geocodedImages: UIEventSource - readonly searchState: SearchState getMatchingLayer(properties: Record): LayerConfig | undefined diff --git a/src/UI/ThemeViewGUI.svelte b/src/UI/ThemeViewGUI.svelte index 35f9e9688..f9b0ff82f 100644 --- a/src/UI/ThemeViewGUI.svelte +++ b/src/UI/ThemeViewGUI.svelte @@ -8,7 +8,6 @@ import type { Feature } from "geojson" import SelectedElementView from "./BigComponents/SelectedElementView.svelte" import LayerConfig from "../Models/ThemeConfig/LayerConfig" - import ThemeViewState from "../Models/ThemeViewState" import type { MapProperties } from "../Models/MapProperties" import Translations from "./i18n/Translations" import { MenuIcon } from "@rgossiaux/svelte-heroicons/solid" @@ -48,8 +47,14 @@ import { linear } from "svelte/easing" import DefaultIcon from "./Map/DefaultIcon.svelte" import Loading from "./Base/Loading.svelte" + import { WithSearchState } from "../Models/ThemeViewState/WithSearchState" + import TitleHandler from "../Logic/Actors/TitleHandler" - export let state: ThemeViewState + export let state: WithSearchState + new TitleHandler(state.selectedElement, state) + + console.log("The state is", state) + state.focusOnMap() let theme = state.theme let maplibremap: UIEventSource = state.map diff --git a/src/index.ts b/src/index.ts index b74554698..1b5928b88 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,4 @@ import DetermineTheme from "./Logic/DetermineTheme" -import ThemeViewState from "./Models/ThemeViewState" import SvelteUIElement from "./UI/Base/SvelteUIElement" import ThemeViewGUI from "./UI/ThemeViewGUI.svelte" import { FixedUiElement } from "./UI/Base/FixedUiElement" @@ -8,6 +7,7 @@ import { SubtleButton } from "./UI/Base/SubtleButton" import { Utils } from "./Utils" import Constants from "./Models/Constants" import ArrowDownTray from "@babeard/svelte-heroicons/mini/ArrowDownTray" +import { WithSearchState } from "./Models/ThemeViewState/WithSearchState" function webgl_support() { try { @@ -53,7 +53,7 @@ async function main() { await getAvailableLayers(), ]) console.log("The available layers on server are", Array.from(availableLayers)) - const state = new ThemeViewState(theme, availableLayers) + const state = new WithSearchState(theme, availableLayers) const target = document.getElementById("maindiv") const childs = Array.from(target.children) new ThemeViewGUI({ diff --git a/src/index_theme.ts.template b/src/index_theme.ts.template index 68b8ad946..d7ef5f1da 100644 --- a/src/index_theme.ts.template +++ b/src/index_theme.ts.template @@ -49,7 +49,7 @@ async function main() { MetaTagging.setThemeMetatagging(new ThemeMetaTagging()) // LAYOUT.ADD_LAYERS // LAYOUT.ADD_CONFIG - const state = new ThemeViewState(new ThemeConfig( layout), availableLayers) + const state = new WithSearchState(new ThemeConfig( layout), availableLayers) const target = document.getElementById("maindiv") const childs = Array.from(target.children) new ThemeViewGUI({