diff --git a/packages/clients/snowbox/src/language.ts b/packages/clients/snowbox/src/language.ts index 4a73f9327..eb71670fc 100644 --- a/packages/clients/snowbox/src/language.ts +++ b/packages/clients/snowbox/src/language.ts @@ -12,6 +12,9 @@ const language: LanguageOption[] = [ 'Strecken U-Bahn © Freie und Hansestadt Hamburg, Behörde für Wirtschaft, Verkehr und Innovation', rapid: 'Strecken S-Bahn © Freie und Hansestadt Hamburg, Behörde für Wirtschaft, Verkehr und Innovation', + reports: 'Meldungen durch Bürger', + ausgleichsflaechen: + 'Ausgleichsflächen © Freie und Hansestadt Hamburg, Behörde für Umwelt und Energie', hamburgBorder: 'Landesgrenze Hamburg © Freie und Hansestadt Hamburg', }, layers: { @@ -19,6 +22,8 @@ const language: LanguageOption[] = [ basemapGrey: 'Basemap.de (Grau)', underground: 'U-Bahn', rapid: 'S-Bahn', + reports: 'Anliegen (MML)', + ausgleichsflaechen: 'Ausgleichsflächen', hamburgBorder: 'Landesgrenze Hamburg', }, }, @@ -43,6 +48,9 @@ const language: LanguageOption[] = [ 'Railway Lines U-Bahn © Freie und Hansestadt Hamburg, Behörde für Wirtschaft, Verkehr und Innovation', rapid: 'Railway Lines S-Bahn © Freie und Hansestadt Hamburg, Behörde für Wirtschaft, Verkehr und Innovation', + reports: 'Reports by citizens', + ausgleichsflaechen: + 'Compensation area © Freie und Hansestadt Hamburg, Behörde für Umwelt und Energie', hamburgBorder: 'City border Hamburg © Freie und Hansestadt Hamburg', }, layers: { @@ -50,6 +58,8 @@ const language: LanguageOption[] = [ basemapGrey: 'Basemap.de (Grey)', underground: 'Underground railway (U-Bahn)', rapid: 'City rapid railway (S-Bahn)', + reports: 'Reports (MML)', + ausgleichsflaechen: 'Compensation area', hamburgBorder: 'City border Hamburg', }, }, diff --git a/packages/clients/snowbox/src/mapConfiguration.ts b/packages/clients/snowbox/src/mapConfiguration.ts index 39e411cf9..4fe993a71 100644 --- a/packages/clients/snowbox/src/mapConfiguration.ts +++ b/packages/clients/snowbox/src/mapConfiguration.ts @@ -1,3 +1,8 @@ +import { Feature as GeoJsonFeature } from 'geojson' +import { + ExtendedMasterportalapiMarkersIsSelectableFunction, + GfiIsSelectableFunction, +} from '@polar/lib-custom-types' import language from './language' const eigengrau = '#16161d' @@ -8,9 +13,40 @@ const basemapId = '23420' const basemapGreyId = '23421' const sBahn = '23050' const uBahn = '23053' +export const reports = '6059' +const ausgleichsflaechen = '1454' const hamburgBorder = '6074' +const isAusgleichsflaecheActive = (feature: GeoJsonFeature) => + new Date( + Date.parse(feature.properties?.vorhaben_zulassung_am.split('.')[2]) + ).getFullYear() >= 2000 + +// arbitrary condition for testing +const isEvenId = (mmlid: string) => Number(mmlid.slice(-1)) % 2 === 0 + +const isReportActive: GfiIsSelectableFunction = (feature) => + feature.properties?.features + ? // client is in cluster mode + feature.properties?.features.reduce( + (accumulator, current) => + // NOTE: that's how ol/GeoJSON packs clustered features as GeoJSON + isEvenId(current.values_.mmlid) || accumulator, + false + ) + : isEvenId(feature.properties?.mmlid) + +const isReportSelectable: ExtendedMasterportalapiMarkersIsSelectableFunction = ( + feature +) => + feature + .get('features') + .reduce( + (accumulator, current) => isEvenId(current.get('mmlid')) || accumulator, + false + ) + export const mapConfiguration = { language: 'en', locales: language, @@ -26,6 +62,27 @@ export const mapConfiguration = { }, }, }, + extendedMasterportalapiMarkers: { + layers: [reports], + defaultStyle: { + stroke: '#FFFFFF', + fill: '#005CA9', + }, + hoverStyle: { + stroke: '#46688E', + fill: '#8BA1B8', + }, + selectionStyle: { + stroke: '#FFFFFF', + fill: '#E10019', + }, + unselectableStyle: { + stroke: '#FFFFFF', + fill: '#333333', + }, + isSelectable: isReportSelectable, + clusterClickZoom: true, + }, addressSearch: { searchMethods: [ { @@ -61,6 +118,14 @@ export const mapConfiguration = { id: sBahn, title: 'snowbox.attributions.rapid', }, + { + id: reports, + title: 'snowbox.attributions.reports', + }, + { + id: ausgleichsflaechen, + title: 'snowbox.attributions.ausgleichsflaechen', + }, ], }, draw: { @@ -93,6 +158,8 @@ export const mapConfiguration = { zoomLevel: 9, }, gfi: { + mode: 'bboxDot', + activeLayerPath: 'plugin/layerChooser/activeMaskIds', layers: { [uBahn]: { geometry: true, @@ -110,10 +177,24 @@ export const mapConfiguration = { art: 'Art', }, }, + [reports]: { + geometry: false, + window: true, + // only one of these will be displayed, depending on whether (extended markers && clusters) are on + properties: ['_gfiLayerId', 'mmlid'], + isSelectable: isReportActive, + }, + [ausgleichsflaechen]: { + geometry: true, + window: true, + properties: ['vorhaben', 'vorhaben_zulassung_am'], + isSelectable: isAusgleichsflaecheActive, + }, }, coordinateSources: [ 'plugin/pins/transformedCoordinate', 'plugin/pins/coordinatesAfterDrag', + 'selectedCoordinates', ], customHighlightStyle: { stroke: { @@ -148,6 +229,16 @@ export const mapConfiguration = { type: 'mask', name: 'snowbox.layers.rapid', }, + { + id: reports, + type: 'mask', + name: 'snowbox.layers.reports', + }, + { + id: ausgleichsflaechen, + type: 'mask', + name: 'snowbox.layers.ausgleichsflaechen', + }, { id: hamburgBorder, visibility: true, diff --git a/packages/clients/snowbox/src/polar-client.ts b/packages/clients/snowbox/src/polar-client.ts index 4f55d6c1a..4a539ec87 100644 --- a/packages/clients/snowbox/src/polar-client.ts +++ b/packages/clients/snowbox/src/polar-client.ts @@ -1,8 +1,10 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import polarCore from '@polar/core' import { changeLanguage } from 'i18next' +// NOTE bad pattern, but probably fine for a test client +import { enableClustering } from '../../meldemichel/src/utils/enableClustering' import { addPlugins } from './addPlugins' -import { mapConfiguration } from './mapConfiguration' +import { mapConfiguration, reports } from './mapConfiguration' addPlugins(polarCore) @@ -12,7 +14,7 @@ const createMap = (layerConf) => { containerId: 'polarstern', mapConfiguration: { ...mapConfiguration, - layerConf, + layerConf: (enableClustering(layerConf, reports), layerConf), }, }) .then((map) => { diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 3da4ffb10..4efa28544 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -4,6 +4,7 @@ - Breaking: Upgrade `@masterportal/masterportalapi` from `2.8.0` to `2.40.0` and subsequently `ol` from `^7.1.0` to `^9.2.4`. - Breaking: Remove support for marking client CSS via `data-polar="true"`. Please use the configuration parameter `stylePath` instead. +- Feature: The `extendedMasterportalapiFeatures` feature has been extended by a `isSelectable` function and `unselectableStyle` to style markers accordingly. - Feature: Add new state parameter `mapHasDimensions` to let plugins have a "hook" to react on when the map is ready. - Feature: Add `deviceIsHorizontal` as a getter to have a more central place to check if the device is in landscape mode. - Feature: Add clearer documentation regarding `@masterportal/masterportalapi` related configuration parameters including examples. diff --git a/packages/core/README.md b/packages/core/README.md index 33b0c14c4..454c7e086 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -178,8 +178,9 @@ To figure out the name of the locales to override, inspect the matching plugin i | defaultStyle | MarkerStyle? | Used as the default marker style. The default fill color for these markers is `'#005CA9'`. | | dispatchOnMapSelect | string[]? | If set, the parameters will be spread to dispatchment on map selection. `['target', 'value']` will `dispatch(...['target', 'value'])`. This can be used to open the iconMenu's GFI with `['plugin/iconMenu/openMenuById', 'gfi']`, should the IconMenu exist and the gfi plugin be in it with this id. | | hoverStyle | MarkerStyle? | Used as map marker style for hovered features. The default fill color for these markers is `'#7B1045'`. | +| isSelectable | ((feature: GeoJsonFeature) => boolean)? | If undefined, all features are selectable. If defined, this can be used to sort out features to be unselectable, and such features will be styled different and won't react on click. | | selectionStyle | MarkerStyle? | Used as map marker style for selected features. The default fill color for these markers is `'#679100'`. | - +| unselectableStyle | MarkerStyle? | Used as a map marker style for unselectable features. Features are unselectable if a given `isSelectable` method returns falsy for a feature. The default fill color for these markers is `'#333333'`. | Example configuration: ```js @@ -197,6 +198,11 @@ extendedMasterportalapiMarkers: { stroke: '#FFFFFF', fill: '#E10019', }, + unselectableStyle: { + stroke: '#FFFFFF', + fill: '#333333' + }, + isSelectable: (feature: Feature) => feature.get('indicator') clusterClickZoom: true, dispatchOnMapSelect: ['plugin/iconMenu/openMenuById', 'gfi'], }, diff --git a/packages/core/src/utils/markers/index.ts b/packages/core/src/utils/markers/index.ts index c40908824..79ff0cf54 100644 --- a/packages/core/src/utils/markers/index.ts +++ b/packages/core/src/utils/markers/index.ts @@ -15,6 +15,7 @@ const defaultStrokeWidth = '2' const defaultFill = '#005CA9' const defaultHoverFill = '#7B1045' const defaultSelectionFill = '#679100' +const defaultUnselectableFill = '#333333' const prefix = 'data:image/svg+xml,' @@ -124,3 +125,7 @@ export const getHoveredStyle = memoizeStyle(getStyleFunction(defaultHoverFill)) export const getSelectedStyle = memoizeStyle( getStyleFunction(defaultSelectionFill) ) + +export const getUnselectableStyle = memoizeStyle( + getStyleFunction(defaultUnselectableFill) +) diff --git a/packages/core/src/vuePlugins/actions/useExtendedMasterportalapiMarkers/index.ts b/packages/core/src/vuePlugins/actions/useExtendedMasterportalapiMarkers/index.ts index 5249c58ea..12bf015bd 100644 --- a/packages/core/src/vuePlugins/actions/useExtendedMasterportalapiMarkers/index.ts +++ b/packages/core/src/vuePlugins/actions/useExtendedMasterportalapiMarkers/index.ts @@ -2,6 +2,7 @@ import { Feature, MapBrowserEvent } from 'ol' import { CoreGetters, CoreState, + ExtendedMasterportalapiMarkersIsSelectableFunction, MarkerStyle, PolarActionContext, PolarStore, @@ -11,7 +12,11 @@ import { isVisible } from '@polar/lib-invisible-style' import VectorLayer from 'ol/layer/Vector' import BaseLayer from 'ol/layer/Base' import getCluster from '@polar/lib-get-cluster' -import { getHoveredStyle, getSelectedStyle } from '../../../utils/markers' +import { + getHoveredStyle, + getSelectedStyle, + getUnselectableStyle, +} from '../../../utils/markers' import { resolveClusterClick } from '../../../utils/resolveClusterClick' import { setLayerId } from './setLayerId' @@ -70,12 +75,16 @@ export function useExtendedMasterportalapiMarkers( { hoverStyle = {}, selectionStyle = {}, + unselectableStyle = {}, + isSelectable = () => true, layers, clusterClickZoom = false, dispatchOnMapSelect, }: { hoverStyle?: MarkerStyle selectionStyle?: MarkerStyle + unselectableStyle?: MarkerStyle + isSelectable?: ExtendedMasterportalapiMarkersIsSelectableFunction layers: string[] clusterClickZoom: boolean dispatchOnMapSelect?: string[] @@ -101,6 +110,20 @@ export function useExtendedMasterportalapiMarkers( (feature: Feature) => isVisible(feature) ? feature.getGeometry() : null } + const originalStyleFunction = (layer as VectorLayer).getStyle() + ;(layer as VectorLayer).setStyle((feature) => { + if ( + typeof isSelectable === 'undefined' || + isSelectable(feature as Feature) + ) { + // @ts-expect-error | always is a function due to masterportalapi design + return originalStyleFunction(feature) + } + return getUnselectableStyle( + unselectableStyle, + feature.get('features').length > 1 + ) + }) }) // // // STORE EVENT HANDLING @@ -146,7 +169,7 @@ export function useExtendedMasterportalapiMarkers( hovered = null commit('setHovered', hovered) } - if (!feature) { + if (!feature || !isSelectable(feature)) { return } const isMultiFeature = feature.get('features')?.length > 1 @@ -164,7 +187,11 @@ export function useExtendedMasterportalapiMarkers( dispatch('updateSelection', { feature: selected }) } const feature = map.getFeaturesAtPixel(event.pixel, { layerFilter })[0] - if (!feature || feature instanceof RenderFeature) { + if ( + !feature || + feature instanceof RenderFeature || + !isSelectable(feature) + ) { return } const isMultiFeature = feature.get('features')?.length > 1 diff --git a/packages/core/src/vuePlugins/vuex.ts b/packages/core/src/vuePlugins/vuex.ts index 1dc0fa1fa..e3d0f3d6b 100644 --- a/packages/core/src/vuePlugins/vuex.ts +++ b/packages/core/src/vuePlugins/vuex.ts @@ -141,6 +141,12 @@ export const makeStore = () => { noop(state.selected) return selected }, + selectedCoordinates: (state) => { + noop(state.selected) + return selected === null + ? null + : (selected.getGeometry() as Point).getCoordinates() + }, // hack: deliver components (outside vuex) based on counter; see NOTE above components: (state) => { noop(state.components) diff --git a/packages/plugins/Gfi/CHANGELOG.md b/packages/plugins/Gfi/CHANGELOG.md index 352411eff..8feec2fb0 100644 --- a/packages/plugins/Gfi/CHANGELOG.md +++ b/packages/plugins/Gfi/CHANGELOG.md @@ -3,6 +3,7 @@ ## unpublished - Breaking: Upgrade `@masterportal/masterportalapi` from `2.8.0` to `2.40.0` and subsequently `ol` from `^7.1.0` to `^9.2.4`. +- Feature: Add new configuration parameter `isSelectable` that can be used to filter features to be unselectable. - Fix: Adjust documentation to properly describe optionality of configuration parameters. - Fix: Add missing configuration parameters `featureList` and `maxFeatures` to the general documentation and `filterBy` and `format` to `gfi.gfiLayerConfiguration` - Refactor: Replace redundant prop-forwarding with `getters`. diff --git a/packages/plugins/Gfi/README.md b/packages/plugins/Gfi/README.md index ad492540e..ee3bb4405 100644 --- a/packages/plugins/Gfi/README.md +++ b/packages/plugins/Gfi/README.md @@ -83,6 +83,7 @@ function afterLoadFunction(featuresByLayerId: Record): | exportProperty | string? | Property of the features of a service having an url usable to trigger a download of features as a document. | | geometry | boolean? | If true, feature geometry will be highlighted within the map. Defaults to `false`. | | geometryName | string? | Name of the geometry property if not the default field. | +| isSelectable | ((feature: GeoJsonFeature) => boolean)? | A function can be defined to allow filtering features to be either selectable (return `true`) or not. Unselectable features will be filtered out by the GFI plugin and have neither GFI display nor store presence, but may be visible in the map nonetheless, depending on your other configuration. Please also mind that usage in combination with `extendedMasterportalapiMarkers` requires further configuration of that feature for smooth UX; see the respective documentation of `@polar/core`. | | properties | Record/string[]? | In case `window` is `true`, this will be used to determine which contents to show. In case of an array, keys are used to select properties. In case of an object, keys are used to select properties, but will be titles as their respective values. Displays all properties by default. | | showTooltip | ((feature: Feature) => [string, string][])? | If given, a tooltip will be shown with the values calculated for the feature. The first string is the HTML tag to render, the second its contents; contants may be locale keys. For more information regarding the strings, see the documentation of the `@polar/lib-tooltip` package. Defaults to `undefined`. Please mind that tooltips will only be shown if a mouse is used or the hovering device could not be detected. Touch and pen interactions do not open tooltips since they will open the GFI window, rendering the gatherable information redundant. | | window | boolean? | If true, properties will be shown in the map client. Defaults to `false`. | @@ -106,7 +107,8 @@ layers: { ['div', `Feature ID: ${feature.properties.id}`], ['span', `Coordinates: ${feature.geometry.coordinates.join(', ')}`] ]; - }; + }, + isSelectable: (feature: Feature): boolean => Boolean(Math.random() < 0.5) }, } ``` diff --git a/packages/plugins/Gfi/src/store/actions/debouncedGfiRequest.ts b/packages/plugins/Gfi/src/store/actions/debouncedGfiRequest.ts index 06af3d1b0..10e125a83 100644 --- a/packages/plugins/Gfi/src/store/actions/debouncedGfiRequest.ts +++ b/packages/plugins/Gfi/src/store/actions/debouncedGfiRequest.ts @@ -18,7 +18,7 @@ import { requestGfi } from '../../utils/requestGfi' import sortFeatures from '../../utils/sortFeatures' import { GfiGetters, GfiState } from '../../types' -const mapFeaturesToLayerIds = ( +const filterAndMapFeaturesToLayerIds = ( layerKeys: string[], gfiConfiguration: GfiConfiguration, features: (symbol | GeoJsonFeature[])[], @@ -30,10 +30,12 @@ const mapFeaturesToLayerIds = ( (accumulator, key, index) => ({ ...accumulator, [key]: Array.isArray(features[index]) - ? (features[index] as []).slice( - 0, - gfiConfiguration.layers[key].maxFeatures || generalMaxFeatures - ) + ? (features[index] as []) + .filter(gfiConfiguration.layers[key].isSelectable || (() => true)) + .slice( + 0, + gfiConfiguration.layers[key].maxFeatures || generalMaxFeatures + ) : features[index], }), {} @@ -135,7 +137,7 @@ const gfiRequest = : errorSymbol(result.reason.message) ) const srsName: string = map.getView().getProjection().getCode() - let featuresByLayerId = mapFeaturesToLayerIds( + let featuresByLayerId = filterAndMapFeaturesToLayerIds( layerKeys, // NOTE if there was no configuration, we would not be here // eslint-disable-next-line @typescript-eslint/no-non-null-assertion diff --git a/packages/plugins/Gfi/src/store/getters.ts b/packages/plugins/Gfi/src/store/getters.ts index 242b2a374..6a8e23845 100644 --- a/packages/plugins/Gfi/src/store/getters.ts +++ b/packages/plugins/Gfi/src/store/getters.ts @@ -10,6 +10,7 @@ import noop from '@repositoryname/noop' import { isVisible } from '@polar/lib-invisible-style' import { Feature } from 'ol' import { Cluster as ClusterSource } from 'ol/source' +import { GeoJSON } from 'ol/format' import { GfiGetters, GfiState } from '../types' import { listableLayersFilter } from '../utils/listableLayersFilter' import getInitialState from './getInitialState' @@ -214,16 +215,18 @@ const getters: PolarGetterTree = { }, listFeatures( { visibilityChangeIndicator }, - { listableLayerSources, listMode }, + { gfiConfiguration, listableLayerSources, listMode }, __, rootGetters ): Feature[] { const { map, clientHeight, clientWidth, center, zoomLevel } = rootGetters + const writer = new GeoJSON() // trigger getter on those who indicate feature change possibility noop(clientHeight, clientWidth, center, zoomLevel) noop(visibilityChangeIndicator) return listableLayerSources .map((source) => { + const layerId = source.get('_gfiLayerId') return ( listMode === 'loaded' ? source.getFeatures() @@ -233,9 +236,15 @@ const getters: PolarGetterTree = { ) ) .filter(isVisible) + .filter((feature) => { + const { isSelectable } = gfiConfiguration.layers[layerId] + return typeof isSelectable === 'function' + ? isSelectable(JSON.parse(writer.writeFeature(feature))) + : true + }) .map((feature) => { // true = silent change (prevents cluster recomputation & rerender) - feature.set('_gfiLayerId', source.get('_gfiLayerId'), true) + feature.set('_gfiLayerId', layerId, true) return feature }) }) diff --git a/packages/types/custom/CHANGELOG.md b/packages/types/custom/CHANGELOG.md index 3df669eb2..85d6bccfd 100644 --- a/packages/types/custom/CHANGELOG.md +++ b/packages/types/custom/CHANGELOG.md @@ -2,6 +2,9 @@ ## unpublished +- Feature: Add `selectedCoordinate` to core store getters; it returns `null` or the `selected` feature's point coordinates. +- Feature: Add new parameters `unselectableStyle` and `isSelectable` with new type `ExtendedMasterportalapiMarkersIsSelectableFunction` to interface `ExtendedMasterportalapiMarkers`. +- Feature: Add new parameter `isSelectable` with new type `GfiIsSelectableFunction` to interface `GfiLayerConfiguration`. - Feature: Add new parameter `enableOptions` to interface `DrawConfiguration`. - Feature: Add new interface `ScaleConfiguration` and new property `scale` to `mapConfiguration`. - Feature: Add `afterResultComponent` to `AddressSearchConfiguration` for custom search result suffixes. diff --git a/packages/types/custom/core.ts b/packages/types/custom/core.ts index 1b6e05888..748be4365 100644 --- a/packages/types/custom/core.ts +++ b/packages/types/custom/core.ts @@ -20,6 +20,7 @@ import { } from 'vuex' import { Feature as GeoJsonFeature, FeatureCollection } from 'geojson' import { VueConstructor, WatchOptions } from 'vue' +import { Coordinate } from 'ol/coordinate' /** * @@ -250,6 +251,7 @@ export interface GfiLayerConfiguration { geometry?: boolean // name of field to use for geometry, if not default field geometryName?: string + isSelectable?: GfiIsSelectableFunction maxFeatures?: number /** * If window is true, the properties are either @@ -321,6 +323,12 @@ export interface FullscreenConfiguration extends PluginOptions { targetContainerId?: string } +export type ExtendedMasterportalapiMarkersIsSelectableFunction = ( + feature: Feature +) => boolean + +export type GfiIsSelectableFunction = (feature: GeoJsonFeature) => boolean + /** configurable function to gather additional info */ export type GfiAfterLoadFunction = ( featureInformation: Record, @@ -579,8 +587,10 @@ export interface ExtendedMasterportalapiMarkers { defaultStyle: MarkerStyle hoverStyle: MarkerStyle selectionStyle: MarkerStyle + unselectableStyle: MarkerStyle clusterClickZoom?: boolean dispatchOnMapSelect?: string + isSelectable?: ExtendedMasterportalapiMarkersIsSelectableFunction } export interface MasterportalApiConfig { @@ -705,6 +715,7 @@ export interface CoreGetters moveHandle: MoveHandleProperties moveHandleActionButton: MoveHandleActionButton selected: Feature | null + selectedCoordinate: Coordinate | null // regular getters deviceIsHorizontal: boolean