Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/architecture.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe('Architectural checks', () => {
.matchingPattern('^plugins/.*$')
.should()
.matchPattern(
'^plugins/[^/]+/((index|locales|store|types)\\.ts|utils/.*\\.ts|components/.*\\.spec\\.ts|stores/.*\\.ts)$'
'^plugins/[^/]+/((index|locales|store|types)\\.ts|utils/.*\\.ts|components/.*\\.spec\\.ts|stores/.*\\.ts|composables/.*\\.ts)$'
)
.check()
expect(violations).toEqual([])
Expand Down
31 changes: 31 additions & 0 deletions src/core/stores/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,37 @@ export const useCoreStore = defineStore('core', () => {
*/
zoom: mainStoreRefs.zoom,

/**
* Masks an interaction for a plugin.
* If the interaction is already masked by another plugin, an error is thrown.
*
* This may, for example, be used for interactions that should not be triggered while drawing.
*
* @param pluginId - ID of the plugin that wants to mask the interaction
* @param interaction - Name of the interaction to be masked
* @alpha
*/
maskInteraction: mainStore.maskInteraction,

/**
* Unmasks an interaction for a plugin.
* If the interaction is not masked by the plugin, nothing happens.
*
* @param pluginId - ID of the plugin that wants to unmask the interaction
* @param interaction - Name of the interaction to be unmasked
* @alpha
*/
unmaskInteraction: mainStore.unmaskInteraction,

/**
* Checks whether an interaction is masked by another plugin.
*
* @param interaction - Name of the interaction to be checked
* @returns `true` if the interaction is masked by another plugin, `false` otherwise
* @alpha
*/
isInteractionMasked: mainStore.isInteractionMasked,

/**
* Before instantiating the map, all required plugins have to be added. Depending on how you use POLAR, this may
* already have been done. Ready-made clients (that is, packages prefixed `@polar/client-`) come with plugins prepared.
Expand Down
70 changes: 70 additions & 0 deletions src/core/stores/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
ColorScheme,
MapConfigurationIncludingDefaults,
MasterportalApiServiceRegister,
PluginId,
} from '../types'

import { rawLayerList } from '@masterportal/masterportalapi'
Expand Down Expand Up @@ -35,7 +36,7 @@

const layout = computed(() => configuration.value.layout ?? 'nineRegions')

// TODO(dopenguin): Both will possibly be updated with different breakpoints -> Breakpoints are e.g. not valid on newer devices

Check warning on line 39 in src/core/stores/main.ts

View workflow job for this annotation

GitHub Actions / Linting

Unexpected 'todo' comment: 'TODO(dopenguin): Both will possibly be...'
const clientHeight = ref(0)
const clientWidth = ref(0)
const hasSmallHeight = computed(
Expand Down Expand Up @@ -89,6 +90,26 @@
return { ...register, ...polar } as typeof polar
}

const maskedInteractions = ref(new globalThis.Map<string, PluginId>())
function maskInteraction(pluginId: PluginId, interaction: string) {
if (maskedInteractions.value.has(interaction)) {
throw new Error(
`Interaction "${interaction}" is already masked by plugin "${maskedInteractions.value.get(
interaction
)}"`
)
}
maskedInteractions.value.set(interaction, pluginId)
}
function unmaskInteraction(pluginId: PluginId, interaction: string) {
if (maskedInteractions.value.get(interaction) === pluginId) {
maskedInteractions.value.delete(interaction)
}
}
function isInteractionMasked(interaction: string) {
return maskedInteractions.value.has(interaction)
}

function setup() {
addEventListener('resize', updateHasSmallDisplay)
updateHasSmallDisplay()
Expand Down Expand Up @@ -124,6 +145,9 @@
centerOnFeature,
updateHasSmallDisplay,
getLayerMapConfiguration,
maskInteraction,
unmaskInteraction,
isInteractionMasked,
setup,
teardown,
}
Expand All @@ -132,3 +156,49 @@
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useMainStore, import.meta.hot))
}

if (import.meta.vitest) {
const { expect, test: _test } = import.meta.vitest
const { createPinia, setActivePinia } = await import('pinia')

/* eslint-disable no-empty-pattern */
const test = _test.extend<{
store: ReturnType<typeof useMainStore>
}>({
store: async ({}, use) => {
setActivePinia(createPinia())
const store = useMainStore()
store.setup()
await use(store)
store.teardown()
},
})
/* eslint-enable no-empty-pattern */

test('Masking interactions works as expected', ({ store }) => {
const pluginId = 'external-test-plugin'
const interaction = 'click'

expect(store.isInteractionMasked(interaction)).toBe(false)

store.maskInteraction(pluginId, interaction)
expect(store.isInteractionMasked(interaction)).toBe(true)

store.unmaskInteraction(pluginId, interaction)
expect(store.isInteractionMasked(interaction)).toBe(false)
})

test('Masking interactions twice fails', ({ store }) => {
const pluginId = 'external-test-plugin'
const interaction = 'click'

expect(store.isInteractionMasked(interaction)).toBe(false)

store.maskInteraction(pluginId, interaction)
expect(store.isInteractionMasked(interaction)).toBe(true)

expect(() => {
store.maskInteraction(pluginId, interaction)
}).toThrow()
})
}
20 changes: 2 additions & 18 deletions src/plugins/pins/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { toMerged } from 'es-toolkit'
import { pointerMove } from 'ol/events/condition'
import Feature from 'ol/Feature'
import Point from 'ol/geom/Point'
import { Draw, Modify, Select, Translate } from 'ol/interaction'
import { Select, Translate } from 'ol/interaction'
import VectorLayer from 'ol/layer/Vector'
import { toLonLat } from 'ol/proj'
import { Vector } from 'ol/source'
Expand Down Expand Up @@ -167,28 +167,12 @@ export const usePinsStore = defineStore('plugins/pins', () => {
}

async function click(coordinate: Coordinate) {
const isDrawing = coreStore.map
.getInteractions()
.getArray()
.some(
(interaction) =>
(interaction instanceof Draw &&
// @ts-expect-error | internal hack to detect it from gfi plugin
(interaction._isMultiSelect ||
// @ts-expect-error | internal hack to detect it from routing plugin
interaction._isRoutingDraw ||
// @ts-expect-error | internal hack to detect it from draw plugin
interaction._isDrawPlugin)) ||
interaction instanceof Modify ||
// @ts-expect-error | internal hack to detect it from draw plugin
interaction._isDeleteSelect
)
const { minZoomLevel, movable } = configuration.value
if (
(movable === 'drag' || movable === 'click') &&
// NOTE: It is assumed that getZoom actually returns the currentZoomLevel, thus the view has a constraint in the resolution.
(coreStore.map.getView().getZoom() as number) >= minZoomLevel &&
!isDrawing &&
!coreStore.isInteractionMasked('click') &&
(await isCoordinateInBoundaryLayer(
coordinate,
coreStore.map,
Expand Down
8 changes: 5 additions & 3 deletions src/plugins/routing/components/RoutingInput.ce.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
<template>
<div class="polar-plugin-routing-route-wrapper">
<div class="kern-form-input">
<label class="kern-label" :for="`polar-plugin-routing-input-${index}`">
<label class="kern-label" :for="id">
{{ $t(($) => $.label[getRouteLabel(index)], { ns: PluginId }) }}
</label>
<input
:id="`polar-plugin-routing-input-${index}`"
:id="id"
v-model="route[index]"
class="kern-form-input__input"
:aria-label="
Expand Down Expand Up @@ -44,7 +44,7 @@

<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { computed, useId } from 'vue'

import KernButton from '@/components/kern/KernButton.ce.vue'

Expand Down Expand Up @@ -75,6 +75,8 @@ function getRouteLabel(index: number) {
? 'end'
: 'middle'
}

const id = useId()
</script>

<style scoped>
Expand Down
20 changes: 20 additions & 0 deletions src/plugins/routing/composables/useLayer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Map } from 'ol'
import type VectorSource from 'ol/source/Vector'

import VectorLayer from 'ol/layer/Vector'
import { Stroke, Style } from 'ol/style'
import { onScopeDispose } from 'vue'

export function useLayer(map: Map, routeSource: VectorSource) {
const layer = new VectorLayer({
source: routeSource,
style: new Style({
stroke: new Stroke({ color: 'blue', width: 6 }),
}),
})

map.addLayer(layer)
onScopeDispose(() => {
map.removeLayer(layer)
})
}
70 changes: 25 additions & 45 deletions src/plugins/routing/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

import type { Coordinate } from 'ol/coordinate'
import type { Point } from 'ol/geom'
import type { WatchStopHandle } from 'vue'
import type {
RoutingPluginOptions,
RoutingResponseData,
Expand All @@ -18,16 +17,15 @@ import { t } from 'i18next'
import { Feature } from 'ol'
import { LineString } from 'ol/geom'
import Draw from 'ol/interaction/Draw'
import VectorLayer from 'ol/layer/Vector'
import { transform } from 'ol/proj'
import VectorSource from 'ol/source/Vector'
import { Stroke, Style } from 'ol/style'
import { acceptHMRUpdate, defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'

import { useCoreStore } from '@/core/stores'
import { computedT } from '@/lib/computedT'

import { useLayer } from './composables/useLayer'
import { PluginId } from './types'
import { handleErrors } from './utils/handleErrors'

Expand All @@ -42,13 +40,9 @@ export const useRoutingStore = defineStore('plugins/routing', () => {
const coreStore = useCoreStore()

const routeSource = new VectorSource()
let routeLayer: VectorLayer | undefined
let abortController: AbortController | null = null
let draw: Draw | undefined

let stopRouteWatch: WatchStopHandle | undefined
let stopTravelModeWatch: WatchStopHandle | undefined

const _currentlyFocusedInput = ref(-1)
const route = ref<Coordinate[]>([[], []])
const routingResponseData = ref<RoutingResponseData | null>(null)
Expand All @@ -65,9 +59,11 @@ export const useRoutingStore = defineStore('plugins/routing', () => {
_currentlyFocusedInput.value = index

if (index !== -1) {
coreStore.maskInteraction('routing', 'click')
coreStore.map.addInteraction(draw as Draw)
} else {
coreStore.map.removeInteraction(draw as Draw)
coreStore.unmaskInteraction('routing', 'click')
}
},
})
Expand Down Expand Up @@ -161,7 +157,7 @@ export const useRoutingStore = defineStore('plugins/routing', () => {
}

async function fetchRoute(signal: AbortSignal): Promise<RoutingResponseData> {
const response = await fetch(encodeURI(url.value), {
const response = await fetch(url.value, {
method: 'POST',
headers: {
/* eslint-disable @typescript-eslint/naming-convention */
Expand Down Expand Up @@ -227,12 +223,9 @@ export const useRoutingStore = defineStore('plugins/routing', () => {

function initializeDraw() {
draw = new Draw({ stopClick: true, type: 'Point' })
// @ts-expect-error | internal hack to detect it in @polar/plugin-pins and @polar/plugin-gfi
draw._isRoutingDraw = true
draw.on('drawend', (e) => {
addCoordinateToRoute((e.feature.getGeometry() as Point).getCoordinates())
// @ts-expect-error | internal hack to detect it in @polar/plugin-pins and @polar/plugin-gfi
draw._isRoutingDraw = false
coreStore.unmaskInteraction('routing', 'click')
currentlyFocusedInput.value = -1
})
}
Expand All @@ -252,15 +245,26 @@ export const useRoutingStore = defineStore('plugins/routing', () => {
}
}

function setupPlugin() {
routeLayer = new VectorLayer({
source: routeSource,
style: new Style({
stroke: new Stroke({ color: 'blue', width: 6 }),
}),
})
coreStore.map.addLayer(routeLayer)
watch(
[
route,
selectedPreference,
selectedRouteTypesToAvoid,
selectedTravelMode,
() => coreStore.language,
],
() => {
if (!routeIncomplete.value) {
void getRoute()
}
}
)
watch(selectedTravelMode, () => {
selectedRouteTypesToAvoid.value = []
})

function setupPlugin() {
useLayer(coreStore.map, routeSource)
initializeDraw()
// `pointerdown` handles mouse interaction while `focusin` handles keyboard
// navigation (e.g. tabbing) away from the routing inputs.
Expand All @@ -272,30 +276,9 @@ export const useRoutingStore = defineStore('plugins/routing', () => {
'focusin',
updateFocus
)
stopRouteWatch = watch(
[
route,
selectedPreference,
selectedRouteTypesToAvoid,
selectedTravelMode,
() => coreStore.language,
],
() => {
if (!routeIncomplete.value) {
void getRoute()
}
}
)
stopTravelModeWatch = watch(selectedTravelMode, () => {
selectedRouteTypesToAvoid.value = []
})
}

function teardownPlugin() {
stopRouteWatch?.()
stopRouteWatch = undefined
stopTravelModeWatch?.()
stopTravelModeWatch = undefined
;(coreStore.shadowRoot as ShadowRoot).removeEventListener(
'pointerdown',
updateFocus
Expand All @@ -307,12 +290,9 @@ export const useRoutingStore = defineStore('plugins/routing', () => {

reset()

if (routeLayer) {
coreStore.map.removeLayer(routeLayer)
routeLayer = undefined
}
if (draw) {
coreStore.map.removeInteraction(draw)
coreStore.unmaskInteraction('routing', 'click')
draw = undefined
}
}
Expand Down
Loading