From 1b3609b13f7c739b7cd0a55852a5a4324e109636 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Thu, 23 Mar 2023 00:58:21 +0100 Subject: [PATCH] refactoring(maplibre): add pointRendering --- Logic/BBox.ts | 8 ++ Logic/State/MapState.ts | 6 - Models/RasterLayers.ts | 31 ++++++ Models/ThemeConfig/PointRenderingConfig.ts | 30 ++--- UI/BigComponents/BackgroundMapSwitch.ts | 2 - UI/Input/ValidatedTextField.ts | 2 - UI/Map/MapLibreAdaptor.ts | 98 +++++++++------- UI/Map/ShowDataLayer.ts | 108 ++++++++++++++++++ UI/ShowDataLayer/ShowDataLayerOptions.ts | 30 ++++- test.ts | 123 +++++++++++++-------- 10 files changed, 316 insertions(+), 122 deletions(-) create mode 100644 UI/Map/ShowDataLayer.ts diff --git a/Logic/BBox.ts b/Logic/BBox.ts index 9f1cbceab..8e8bc14c6 100644 --- a/Logic/BBox.ts +++ b/Logic/BBox.ts @@ -186,6 +186,14 @@ export class BBox { ] } + toLngLat(): [[number, number], [number, number]] { + return [ + [this.minLon, this.minLat], + [this.maxLon, this.maxLat], + ] + } + + public asGeoJson(properties: T): Feature { return { type: "Feature", diff --git a/Logic/State/MapState.ts b/Logic/State/MapState.ts index 121e82ff9..59b177044 100644 --- a/Logic/State/MapState.ts +++ b/Logic/State/MapState.ts @@ -1,8 +1,6 @@ import UserRelatedState from "./UserRelatedState" import { Store, Stores, UIEventSource } from "../UIEventSource" -import BaseLayer from "../../Models/BaseLayer" import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig" -import AvailableBaseLayers from "../Actors/AvailableBaseLayers" import Attribution from "../../UI/BigComponents/Attribution" import Minimap, { MinimapObj } from "../../UI/Base/Minimap" import { Tiles } from "../../Models/TileRange" @@ -43,10 +41,6 @@ export default class MapState extends UserRelatedState { The leaflet instance of the big basemap */ public leafletMap = new UIEventSource(undefined, "leafletmap") - /** - * A list of currently available background layers - */ - public availableBackgroundLayers: Store /** * The current background layer diff --git a/Models/RasterLayers.ts b/Models/RasterLayers.ts index 602af9c3b..84d9fc53e 100644 --- a/Models/RasterLayers.ts +++ b/Models/RasterLayers.ts @@ -2,6 +2,8 @@ import { Feature, Polygon } from "geojson" import * as editorlayerindex from "../assets/editor-layer-index.json" import * as globallayers from "../assets/global-raster-layers.json" import { BBox } from "../Logic/BBox" +import { Store, Stores } from "../Logic/UIEventSource" +import { GeoOperations } from "../Logic/GeoOperations" export class AvailableRasterLayers { public static EditorLayerIndex: (Feature & @@ -33,6 +35,35 @@ export class AvailableRasterLayers { properties: AvailableRasterLayers.osmCartoProperties, geometry: BBox.global.asGeometry(), } + + public static layersAvailableAt( + location: Store<{ lon: number; lat: number }> + ): Store { + const availableLayersBboxes = Stores.ListStabilized( + location.mapD((loc) => { + const lonlat: [number, number] = [loc.lon, loc.lat] + return AvailableRasterLayers.EditorLayerIndex.filter((eliPolygon) => + BBox.get(eliPolygon).contains(lonlat) + ) + }) + ) + const available = Stores.ListStabilized( + availableLayersBboxes.map((eliPolygons) => { + const loc = location.data + const lonlat: [number, number] = [loc.lon, loc.lat] + const matching: RasterLayerPolygon[] = eliPolygons.filter((eliPolygon) => { + if (eliPolygon.geometry === null) { + return true // global ELI-layer + } + return GeoOperations.inside(lonlat, eliPolygon) + }) + matching.unshift(AvailableRasterLayers.osmCarto) + matching.push(...AvailableRasterLayers.globalLayers) + return matching + }) + ) + return available + } } export class RasterLayerUtils { diff --git a/Models/ThemeConfig/PointRenderingConfig.ts b/Models/ThemeConfig/PointRenderingConfig.ts index 8c3712f1f..d6a3317df 100644 --- a/Models/ThemeConfig/PointRenderingConfig.ts +++ b/Models/ThemeConfig/PointRenderingConfig.ts @@ -5,12 +5,13 @@ import { TagUtils } from "../../Logic/Tags/TagUtils" import { Utils } from "../../Utils" import Svg from "../../Svg" import WithContextLoader from "./WithContextLoader" -import { UIEventSource } from "../../Logic/UIEventSource" +import { Store } from "../../Logic/UIEventSource" import BaseUIElement from "../../UI/BaseUIElement" import { FixedUiElement } from "../../UI/Base/FixedUiElement" import Img from "../../UI/Base/Img" import Combine from "../../UI/Base/Combine" import { VariableUiElement } from "../../UI/Base/VariableUIElement" +import { OsmTags } from "../OsmFeature" export default class PointRenderingConfig extends WithContextLoader { private static readonly allowed_location_codes = new Set([ @@ -164,7 +165,7 @@ export default class PointRenderingConfig extends WithContextLoader { return PointRenderingConfig.FromHtmlMulti(htmlDefs, rotation, false, defaultPin) } - public GetSimpleIcon(tags: UIEventSource): BaseUIElement { + public GetSimpleIcon(tags: Store): BaseUIElement { const self = this if (this.icon === undefined) { return undefined @@ -175,7 +176,7 @@ export default class PointRenderingConfig extends WithContextLoader { } public GenerateLeafletStyle( - tags: UIEventSource, + tags: Store, clickable: boolean, options?: { noSize?: false | boolean @@ -183,11 +184,7 @@ export default class PointRenderingConfig extends WithContextLoader { } ): { html: BaseUIElement - iconSize: [number, number] iconAnchor: [number, number] - popupAnchor: [number, number] - iconUrl: string - className: string } { function num(str, deflt = 40) { const n = Number(str) @@ -211,20 +208,21 @@ export default class PointRenderingConfig extends WithContextLoader { let iconH = num(iconSize[1]) const mode = iconSize[2]?.trim()?.toLowerCase() ?? "center" - let anchorW = iconW / 2 + // in MapLibre, the offset is relative to the _center_ of the object, with left = [-x, 0] and up = [0,-y] + let anchorW = 0 let anchorH = iconH / 2 if (mode === "left") { - anchorW = 0 + anchorW = -iconW / 2 } if (mode === "right") { - anchorW = iconW + anchorW = iconW / 2 } if (mode === "top") { - anchorH = 0 + anchorH = -iconH / 2 } if (mode === "bottom") { - anchorH = iconH + anchorH = iconH / 2 } const icon = this.GetSimpleIcon(tags) @@ -264,15 +262,11 @@ export default class PointRenderingConfig extends WithContextLoader { } return { html: htmlEl, - iconSize: [iconW, iconH], iconAnchor: [anchorW, anchorH], - popupAnchor: [0, 3 - anchorH], - iconUrl: undefined, - className: clickable ? "leaflet-div-icon" : "leaflet-div-icon unclickable", } } - private GetBadges(tags: UIEventSource): BaseUIElement { + private GetBadges(tags: Store): BaseUIElement { if (this.iconBadges.length === 0) { return undefined } @@ -304,7 +298,7 @@ export default class PointRenderingConfig extends WithContextLoader { ).SetClass("absolute bottom-0 right-1/3 h-1/2 w-0") } - private GetLabel(tags: UIEventSource): BaseUIElement { + private GetLabel(tags: Store): BaseUIElement { if (this.label === undefined) { return undefined } diff --git a/UI/BigComponents/BackgroundMapSwitch.ts b/UI/BigComponents/BackgroundMapSwitch.ts index 47ff1f0ee..0ee831773 100644 --- a/UI/BigComponents/BackgroundMapSwitch.ts +++ b/UI/BigComponents/BackgroundMapSwitch.ts @@ -3,8 +3,6 @@ import { UIEventSource } from "../../Logic/UIEventSource" import Loc from "../../Models/Loc" import Svg from "../../Svg" import Toggle from "../Input/Toggle" -import BaseLayer from "../../Models/BaseLayer" -import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers" import BaseUIElement from "../BaseUIElement" import { GeoOperations } from "../../Logic/GeoOperations" import Hotkeys from "../Base/Hotkeys" diff --git a/UI/Input/ValidatedTextField.ts b/UI/Input/ValidatedTextField.ts index f24d8548b..45c57fb3b 100644 --- a/UI/Input/ValidatedTextField.ts +++ b/UI/Input/ValidatedTextField.ts @@ -18,14 +18,12 @@ import { Unit } from "../../Models/Unit" import { FixedInputElement } from "./FixedInputElement" import WikidataSearchBox from "../Wikipedia/WikidataSearchBox" import Wikidata from "../../Logic/Web/Wikidata" -import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers" import Table from "../Base/Table" import Combine from "../Base/Combine" import Title from "../Base/Title" import InputElementMap from "./InputElementMap" import Translations from "../i18n/Translations" import { Translation } from "../i18n/Translation" -import BaseLayer from "../../Models/BaseLayer" import Locale from "../i18n/Locale" export class TextFieldDef { diff --git a/UI/Map/MapLibreAdaptor.ts b/UI/Map/MapLibreAdaptor.ts index f57b076b1..682ab9f70 100644 --- a/UI/Map/MapLibreAdaptor.ts +++ b/UI/Map/MapLibreAdaptor.ts @@ -1,65 +1,81 @@ import { Store, UIEventSource } from "../../Logic/UIEventSource" import type { Map as MLMap } from "maplibre-gl" -import { - EditorLayerIndexProperties, - RasterLayerPolygon, - RasterLayerProperties, -} from "../../Models/RasterLayers" +import { RasterLayerPolygon, RasterLayerProperties } from "../../Models/RasterLayers" import { Utils } from "../../Utils" -import Loc from "../../Models/Loc" +import { BBox } from "../../Logic/BBox" -export class MapLibreAdaptor { +export interface MapState { + readonly location: UIEventSource<{ lon: number; lat: number }> + readonly zoom: UIEventSource + readonly bounds: Store + readonly rasterLayer: UIEventSource +} +export class MapLibreAdaptor implements MapState { private readonly _maplibreMap: Store - private readonly _backgroundLayer?: Store - private _currentRasterLayer: string = undefined + readonly location: UIEventSource<{ lon: number; lat: number }> + readonly zoom: UIEventSource + readonly bounds: Store + readonly rasterLayer: UIEventSource + private readonly _bounds: UIEventSource - constructor( - maplibreMap: Store, - state?: { - // availableBackgroundLayers: Store - /** - * The current background layer - */ - readonly backgroundLayer?: Store - readonly locationControl?: UIEventSource - } - ) { + /** + * Used for internal bookkeeping (to remove a rasterLayer when done loading) + * @private + */ + private _currentRasterLayer: string + constructor(maplibreMap: Store, state?: Partial>) { this._maplibreMap = maplibreMap - this._backgroundLayer = state.backgroundLayer + + this.location = state?.location ?? new UIEventSource({ lon: 0, lat: 0 }) + this.zoom = state?.zoom ?? new UIEventSource(1) + this._bounds = new UIEventSource(BBox.global) + this.bounds = this._bounds + this.rasterLayer = + state?.rasterLayer ?? new UIEventSource(undefined) const self = this - this._backgroundLayer?.addCallback((_) => self.setBackground()) - maplibreMap.addCallbackAndRunD((map) => { map.on("load", () => { self.setBackground() }) - if (state.locationControl) { - self.MoveMapToCurrentLoc(state.locationControl.data) - map.on("moveend", () => { - const dt = state.locationControl.data - dt.lon = map.getCenter().lng - dt.lat = map.getCenter().lat - dt.zoom = map.getZoom() - state.locationControl.ping() - }) - } + self.MoveMapToCurrentLoc(this.location.data) + self.SetZoom(this.zoom.data) + map.on("moveend", () => { + const dt = this.location.data + dt.lon = map.getCenter().lng + dt.lat = map.getCenter().lat + this.location.ping() + this.zoom.setData(map.getZoom()) + }) }) - state.locationControl.addCallbackAndRunD((loc) => { + this.rasterLayer.addCallback((_) => + self.setBackground().catch((e) => { + console.error("Could not set background") + }) + ) + + this.location.addCallbackAndRunD((loc) => { self.MoveMapToCurrentLoc(loc) }) + this.zoom.addCallbackAndRunD((z) => self.SetZoom(z)) } - - private MoveMapToCurrentLoc(loc: Loc) { + private SetZoom(z: number) { + const map = this._maplibreMap.data + if (map === undefined || z === undefined) { + return + } + if (map.getZoom() !== z) { + map.setZoom(z) + } + } + private MoveMapToCurrentLoc(loc: { lat: number; lon: number }) { const map = this._maplibreMap.data if (map === undefined || loc === undefined) { return } - if (map.getZoom() !== loc.zoom) { - map.setZoom(loc.zoom) - } + const center = map.getCenter() if (center.lng !== loc.lon || center.lat !== loc.lat) { map.setCenter({ lng: loc.lon, lat: loc.lat }) @@ -120,14 +136,14 @@ export class MapLibreAdaptor { if (map === undefined) { return } - const background: RasterLayerProperties = this._backgroundLayer?.data?.properties + const background: RasterLayerProperties = this.rasterLayer?.data?.properties if (background !== undefined && this._currentRasterLayer === background.id) { // already the correct background layer, nothing to do return } await this.awaitStyleIsLoaded() - if (background !== this._backgroundLayer?.data?.properties) { + if (background !== this.rasterLayer?.data?.properties) { // User selected another background in the meantime... abort return } diff --git a/UI/Map/ShowDataLayer.ts b/UI/Map/ShowDataLayer.ts new file mode 100644 index 000000000..904fa59d0 --- /dev/null +++ b/UI/Map/ShowDataLayer.ts @@ -0,0 +1,108 @@ +import { ImmutableStore, Store } from "../../Logic/UIEventSource" +import type { Map as MlMap } from "maplibre-gl" +import { Marker } from "maplibre-gl" +import { ShowDataLayerOptions } from "../ShowDataLayer/ShowDataLayerOptions" +import { GeoOperations } from "../../Logic/GeoOperations" +import LayerConfig from "../../Models/ThemeConfig/LayerConfig" +import PointRenderingConfig from "../../Models/ThemeConfig/PointRenderingConfig" +import { OsmFeature, OsmTags } from "../../Models/OsmFeature" +import FeatureSource from "../../Logic/FeatureSource/FeatureSource" +import { BBox } from "../../Logic/BBox" + +class PointRenderingLayer { + private readonly _config: PointRenderingConfig + private readonly _fetchStore?: (id: string) => Store + private readonly _map: MlMap + + constructor( + map: MlMap, + features: FeatureSource, + config: PointRenderingConfig, + fetchStore?: (id: string) => Store + ) { + this._config = config + this._map = map + this._fetchStore = fetchStore + const cache: Map = new Map() + const self = this + features.features.addCallbackAndRunD((features) => { + const unseenKeys = new Set(cache.keys()) + for (const { feature } of features) { + const id = feature.properties.id + unseenKeys.delete(id) + const loc = GeoOperations.centerpointCoordinates(feature) + if (cache.has(id)) { + console.log("Not creating a marker for ", id) + const cached = cache.get(id) + const oldLoc = cached.getLngLat() + console.log("OldLoc vs newLoc", oldLoc, loc) + if (loc[0] !== oldLoc.lng && loc[1] !== oldLoc.lat) { + cached.setLngLat(loc) + console.log("MOVED") + } + continue + } + + console.log("Creating a marker for ", id) + const marker = self.addPoint(feature) + cache.set(id, marker) + } + + for (const unseenKey of unseenKeys) { + cache.get(unseenKey).remove() + cache.delete(unseenKey) + } + }) + } + + private addPoint(feature: OsmFeature): Marker { + let store: Store + if (this._fetchStore) { + store = this._fetchStore(feature.properties.id) + } else { + store = new ImmutableStore(feature.properties) + } + const { html, iconAnchor } = this._config.GenerateLeafletStyle(store, true) + html.SetClass("marker") + const el = html.ConstructElement() + + el.addEventListener("click", function () { + window.alert("Hello world!") + }) + + return new Marker(el) + .setLngLat(GeoOperations.centerpointCoordinates(feature)) + .setOffset(iconAnchor) + .addTo(this._map) + } +} + +export class ShowDataLayer { + private readonly _map: Store + private _options: ShowDataLayerOptions & { layer: LayerConfig } + + constructor(map: Store, options: ShowDataLayerOptions & { layer: LayerConfig }) { + this._map = map + this._options = options + const self = this + map.addCallbackAndRunD((map) => self.initDrawFeatures(map)) + } + + private initDrawFeatures(map: MlMap) { + for (const pointRenderingConfig of this._options.layer.mapRendering) { + new PointRenderingLayer( + map, + this._options.features, + pointRenderingConfig, + this._options.fetchStore + ) + } + if (this._options.zoomToFeatures) { + const features = this._options.features.features.data + const bbox = BBox.bboxAroundAll(features.map((f) => BBox.get(f.feature))) + map.fitBounds(bbox.toLngLat(), { + padding: { top: 10, bottom: 10, left: 10, right: 10 }, + }) + } + } +} diff --git a/UI/ShowDataLayer/ShowDataLayerOptions.ts b/UI/ShowDataLayer/ShowDataLayerOptions.ts index 1b5832d2c..9a66d0d49 100644 --- a/UI/ShowDataLayer/ShowDataLayerOptions.ts +++ b/UI/ShowDataLayer/ShowDataLayerOptions.ts @@ -3,13 +3,35 @@ import { Store, UIEventSource } from "../../Logic/UIEventSource" import { ElementStorage } from "../../Logic/ElementStorage" import LayerConfig from "../../Models/ThemeConfig/LayerConfig" import ScrollableFullScreen from "../Base/ScrollableFullScreen" +import { OsmTags } from "../../Models/OsmFeature" export interface ShowDataLayerOptions { + /** + * Features to show + */ features: FeatureSource + /** + * Indication of the current selected element; overrides some filters + */ selectedElement?: UIEventSource - leafletMap: Store - popup?: undefined | ((tags: UIEventSource, layer: LayerConfig) => ScrollableFullScreen) + /** + * What popup to build when a feature is selected + */ + buildPopup?: + | undefined + | ((tags: UIEventSource, layer: LayerConfig) => ScrollableFullScreen) + + /** + * If set, zoom to the features when initially loaded and when they are changed + */ zoomToFeatures?: false | boolean - doShowLayer?: Store - state?: { allElements?: ElementStorage } + /** + * Toggles the layer on/off + */ + doShowLayer?: Store + + /** + * Function which fetches the relevant store + */ + fetchStore?: (id: string) => Store } diff --git a/test.ts b/test.ts index 80b3de758..23dde7df0 100644 --- a/test.ts +++ b/test.ts @@ -1,24 +1,23 @@ import SvelteUIElement from "./UI/Base/SvelteUIElement" import MaplibreMap from "./UI/Map/MaplibreMap.svelte" -import { Store, Stores, UIEventSource } from "./Logic/UIEventSource" +import { ImmutableStore, UIEventSource } from "./Logic/UIEventSource" import { MapLibreAdaptor } from "./UI/Map/MapLibreAdaptor" -import { - EditorLayerIndexProperties, - RasterLayerPolygon, - RasterLayerProperties, -} from "./Models/RasterLayers" +import { AvailableRasterLayers, RasterLayerPolygon } from "./Models/RasterLayers" import type { Map as MlMap } from "maplibre-gl" -import { AvailableRasterLayers } from "./Models/RasterLayers" -import Loc from "./Models/Loc" -import { BBox } from "./Logic/BBox" -import { GeoOperations } from "./Logic/GeoOperations" import RasterLayerPicker from "./UI/Map/RasterLayerPicker.svelte" import BackgroundLayerResetter from "./Logic/Actors/BackgroundLayerResetter" - +import { ShowDataLayer } from "./UI/Map/ShowDataLayer" +import StaticFeatureSource from "./Logic/FeatureSource/Sources/StaticFeatureSource" +import { Layer } from "leaflet" +import LayerConfig from "./Models/ThemeConfig/LayerConfig" +import * as bench from "./assets/generated/layers/bench.json" +import { Utils } from "./Utils" +import SimpleFeatureSource from "./Logic/FeatureSource/Sources/SimpleFeatureSource" +import { FilterState } from "./Models/FilteredLayer" +import { FixedUiElement } from "./UI/Base/FixedUiElement" async function main() { const mlmap = new UIEventSource(undefined) - const locationControl = new UIEventSource({ - zoom: 14, + const location = new UIEventSource<{ lon: number; lat: number }>({ lat: 51.1, lon: 3.1, }) @@ -29,44 +28,70 @@ async function main() { .SetStyle("height: 50vh; width: 90%; margin: 1%") .AttachTo("maindiv") const bg = new UIEventSource(undefined) - new MapLibreAdaptor(mlmap, { - backgroundLayer: bg, - locationControl, + const mla = new MapLibreAdaptor(mlmap, { + rasterLayer: bg, + location, }) - const availableLayersBboxes = Stores.ListStabilized( - locationControl.mapD((loc) => { - const lonlat: [number, number] = [loc.lon, loc.lat] - return AvailableRasterLayers.EditorLayerIndex.filter((eliPolygon) => - BBox.get(eliPolygon).contains(lonlat) - ) - }) - ) - const availableLayers: Store = Stores.ListStabilized( - availableLayersBboxes.map((eliPolygons) => { - const loc = locationControl.data - const lonlat: [number, number] = [loc.lon, loc.lat] - const matching: RasterLayerPolygon[] = eliPolygons.filter((eliPolygon) => { - if (eliPolygon.geometry === null) { - return true // global ELI-layer - } - return GeoOperations.inside(lonlat, eliPolygon) - }) - matching.unshift(AvailableRasterLayers.osmCarto) - matching.push(...AvailableRasterLayers.globalLayers) - return matching - }) - ) - - availableLayers.map((a) => - console.log( - "Availabe layers at current location:", - a.map((al) => al.properties.id) - ) - ) - - new BackgroundLayerResetter(bg, availableLayers) - new SvelteUIElement(RasterLayerPicker, { availableLayers, value: bg }).AttachTo("extradiv") + const features = new UIEventSource([ + { + feature: { + type: "Feature", + properties: { + hello: "world", + id: "" + 1, + }, + geometry: { + type: "Point", + coordinates: [3.1, 51.2], + }, + }, + freshness: new Date(), + }, + ]) + const layer = new LayerConfig(bench) + const options = { + zoomToFeatures: false, + features: new SimpleFeatureSource( + { + layerDef: layer, + isDisplayed: new UIEventSource(true), + appliedFilters: new UIEventSource>(undefined), + }, + 0, + features + ), + layer, + } + new ShowDataLayer(mlmap, options) + mla.zoom.set(9) + mla.location.set({ lon: 3.1, lat: 51.1 }) + const availableLayers = AvailableRasterLayers.layersAvailableAt(location) + // new BackgroundLayerResetter(bg, availableLayers) + // new SvelteUIElement(RasterLayerPicker, { availableLayers, value: bg }).AttachTo("extradiv") + for (let i = 0; i <= 10; i++) { + await Utils.waitFor(1000) + features.ping() + new FixedUiElement("> " + (5 - i)).AttachTo("extradiv") + } + options.zoomToFeatures = false + features.setData([ + { + feature: { + type: "Feature", + properties: { + hello: "world", + id: "" + 1, + }, + geometry: { + type: "Point", + coordinates: [3.103, 51.10003], + }, + }, + freshness: new Date(), + }, + ]) + new FixedUiElement("> OK").AttachTo("extradiv") } main().then((_) => {})