From 8dc462ab257ffa45738a275437ea0b63d8199aaf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Jan 2026 08:23:37 +0000 Subject: [PATCH 01/41] Bump @commitlint/cli from 20.2.0 to 20.3.0 Bumps [@commitlint/cli](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/cli) from 20.2.0 to 20.3.0. - [Release notes](https://github.com/conventional-changelog/commitlint/releases) - [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/cli/CHANGELOG.md) - [Commits](https://github.com/conventional-changelog/commitlint/commits/v20.3.0/@commitlint/cli) --- updated-dependencies: - dependency-name: "@commitlint/cli" dependency-version: 20.3.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index e8a14e2581..42ca7f381b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -768,15 +768,15 @@ } }, "node_modules/@commitlint/cli": { - "version": "20.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.2.0.tgz", - "integrity": "sha512-l37HkrPZ2DZy26rKiTUvdq/LZtlMcxz+PeLv9dzK9NzoFGuJdOQyYU7IEkEQj0pO++uYue89wzOpZ0hcTtoqUA==", + "version": "20.3.0", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.3.0.tgz", + "integrity": "sha512-HXO8YVfqdBK+MnlX2zqNrv6waGYPs6Ysjm5W2Y0GMagWXwiIKx7C8dcIX9ca+QdHq4WA0lcMnZLQ0pzQh1piZg==", "dev": true, "license": "MIT", "dependencies": { "@commitlint/format": "^20.2.0", - "@commitlint/lint": "^20.2.0", - "@commitlint/load": "^20.2.0", + "@commitlint/lint": "^20.3.0", + "@commitlint/load": "^20.3.0", "@commitlint/read": "^20.2.0", "@commitlint/types": "^20.2.0", "tinyexec": "^1.0.0", @@ -887,15 +887,15 @@ } }, "node_modules/@commitlint/lint": { - "version": "20.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.2.0.tgz", - "integrity": "sha512-cQEEB+jlmyQbyiji/kmh8pUJSDeUmPiWq23kFV0EtW3eM+uAaMLMuoTMajbrtWYWQpPzOMDjYltQ8jxHeHgITg==", + "version": "20.3.0", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.3.0.tgz", + "integrity": "sha512-X19HOGU5nRo6i9DIY0kG0mhgtvpn1UGO1D6aLX1ILLyeqSM5yJyMcrRqNj8SLgeSeUDODhLY9QYsBIG0LdNHkA==", "dev": true, "license": "MIT", "dependencies": { "@commitlint/is-ignored": "^20.2.0", "@commitlint/parse": "^20.2.0", - "@commitlint/rules": "^20.2.0", + "@commitlint/rules": "^20.3.0", "@commitlint/types": "^20.2.0" }, "engines": { @@ -903,9 +903,9 @@ } }, "node_modules/@commitlint/load": { - "version": "20.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.2.0.tgz", - "integrity": "sha512-iAK2GaBM8sPFTSwtagI67HrLKHIUxQc2BgpgNc/UMNme6LfmtHpIxQoN1TbP+X1iz58jq32HL1GbrFTCzcMi6g==", + "version": "20.3.0", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.3.0.tgz", + "integrity": "sha512-amkdVZTXp5R65bsRXRSCwoNXbJHR2aAIY/RGFkoyd63t8UEwqEgT3f0MgeLqYw4hwXyq+TYXKdaW133E29pnGQ==", "dev": true, "license": "MIT", "dependencies": { @@ -985,9 +985,9 @@ } }, "node_modules/@commitlint/rules": { - "version": "20.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.2.0.tgz", - "integrity": "sha512-27rHGpeAjnYl/A+qUUiYDa7Yn1WIjof/dFJjYW4gA1Ug+LUGa1P0AexzGZ5NBxTbAlmDgaxSZkLLxtLVqtg8PQ==", + "version": "20.3.0", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.3.0.tgz", + "integrity": "sha512-TGgXN/qBEhbzVD13crE1l7YSMJRrbPbUL0OBZALbUM5ER36RZmiZRu2ud2W/AA7HO9YLBRbyx6YVi2t/2Be0yQ==", "dev": true, "license": "MIT", "dependencies": { From 0328f14ed57c5e4fdce3f2419860635eedca4786 Mon Sep 17 00:00:00 2001 From: Hendrik Oenings Date: Mon, 5 Jan 2026 17:07:00 +0100 Subject: [PATCH 02/41] ci(iceberg): make tsc acknowledge vitest-caused node types --- examples/iceberg/tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/iceberg/tsconfig.json b/examples/iceberg/tsconfig.json index dacf60f1d1..d1a1f06e94 100644 --- a/examples/iceberg/tsconfig.json +++ b/examples/iceberg/tsconfig.json @@ -8,6 +8,7 @@ "types": [ "vitest/importMeta", "vitest/jsdom", + "node", "../../src/@types/vite-env.d.ts", "../../src/@types/i18next.d.ts", "../../src/@types/pinia.d.ts", From 07048a5e84b429bef65fa6d34a34db8532f49aa2 Mon Sep 17 00:00:00 2001 From: Hendrik Oenings Date: Wed, 7 Jan 2026 12:59:51 +0100 Subject: [PATCH 03/41] fix(core): enforce usage of `Icon` type --- src/core/types/plugin.ts | 3 ++- src/core/types/theme.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/types/plugin.ts b/src/core/types/plugin.ts index b600f1eb47..70f150818b 100644 --- a/src/core/types/plugin.ts +++ b/src/core/types/plugin.ts @@ -2,6 +2,7 @@ import type { SetupStoreDefinition } from 'pinia' import type { Component } from 'vue' import type { NineLayoutTag } from '../utils/NineLayoutTag' import type { Locale } from './locales' +import type { Icon } from './theme' import type { PluginId as FooterPluginId } from '@/plugins/footer' import type { useFooterStore as FooterStore } from '@/plugins/footer/store' @@ -182,7 +183,7 @@ export interface PluginContainer { * Icon class for the plugin. * This icon will be used as the default for rendering in menus. */ - icon?: string + icon?: Icon /** * Whether the plugin is independently rendered. diff --git a/src/core/types/theme.ts b/src/core/types/theme.ts index 0f732f139b..04dc64a36d 100644 --- a/src/core/types/theme.ts +++ b/src/core/types/theme.ts @@ -41,7 +41,7 @@ export type Color = { oklch: OklchColor } | { rgba: RgbaColor } | string /** * An icon. */ -export type Icon = `kern-icon--${string}` +export type Icon = `kern-icon--${string}` | `kern-icon-fill--${string}` /** * A theme for the POLAR map client. From 2fa72f166e1852c3a3f781058992cb8d3e5e23b0 Mon Sep 17 00:00:00 2001 From: Hendrik Oenings Date: Fri, 2 Jan 2026 13:17:12 +0100 Subject: [PATCH 04/41] feat(filter): migrate plugin --- examples/snowbox/index.js | 73 ++++ src/components/kern/KernBlockButton.ce.vue | 37 ++ .../kern/KernBlockButtonCheckbox.ce.vue | 45 +++ .../kern/KernBlockButtonRadioGroup.ce.vue | 59 ++++ src/components/kern/KernDatePicker.ce.vue | 28 ++ .../kern/KernDateRangePicker.ce.vue | 28 ++ src/core/types/plugin.ts | 7 + src/lib/dateUtils.ts | 16 + src/lib/invisibleStyle.ts | 22 ++ .../filter/components/FilterCategory.ce.vue | 98 ++++++ .../components/FilterLayerChooser.ce.vue | 20 ++ .../filter/components/FilterTime.ce.vue | 209 +++++++++++ src/plugins/filter/components/FilterUI.ce.vue | 53 +++ .../filter/components/FilterUI.spec.ts | 95 +++++ src/plugins/filter/index.ts | 31 ++ src/plugins/filter/locales.ts | 73 ++++ src/plugins/filter/store.ts | 88 +++++ src/plugins/filter/types.ts | 332 ++++++++++++++++++ .../filter/utils/doesFeaturePassFilter.ts | 108 ++++++ src/plugins/filter/utils/getVectorSource.ts | 26 ++ .../filter/utils/parseDateWithPattern.ts | 43 +++ .../filter/utils/updateFeatureVisibility.ts | 56 +++ src/test/utils/mockI18n.ts | 3 +- vue2/packages/plugins/Filter/CHANGELOG.md | 35 -- vue2/packages/plugins/Filter/LICENSE | 287 --------------- vue2/packages/plugins/Filter/README.md | 197 ----------- vue2/packages/plugins/Filter/package.json | 30 -- .../Filter/src/components/ChooseTimeFrame.vue | 84 ----- .../plugins/Filter/src/components/Filter.vue | 171 --------- .../plugins/Filter/src/components/index.ts | 1 - vue2/packages/plugins/Filter/src/index.ts | 14 - vue2/packages/plugins/Filter/src/locales.ts | 82 ----- .../plugins/Filter/src/store/index.ts | 240 ------------- vue2/packages/plugins/Filter/src/types.ts | 55 --- .../Filter/src/utils/arrayOnlyContains.ts | 2 - .../Filter/src/utils/parseTimeOption.ts | 14 - .../plugins/Filter/src/utils/setState.ts | 13 - .../src/utils/updateFeatureVisibility.ts | 169 --------- vue2/packages/plugins/Filter/vite.config.js | 3 - 39 files changed, 1549 insertions(+), 1398 deletions(-) create mode 100644 src/components/kern/KernBlockButton.ce.vue create mode 100644 src/components/kern/KernBlockButtonCheckbox.ce.vue create mode 100644 src/components/kern/KernBlockButtonRadioGroup.ce.vue create mode 100644 src/components/kern/KernDatePicker.ce.vue create mode 100644 src/components/kern/KernDateRangePicker.ce.vue create mode 100644 src/lib/dateUtils.ts create mode 100644 src/plugins/filter/components/FilterCategory.ce.vue create mode 100644 src/plugins/filter/components/FilterLayerChooser.ce.vue create mode 100644 src/plugins/filter/components/FilterTime.ce.vue create mode 100644 src/plugins/filter/components/FilterUI.ce.vue create mode 100644 src/plugins/filter/components/FilterUI.spec.ts create mode 100644 src/plugins/filter/index.ts create mode 100644 src/plugins/filter/locales.ts create mode 100644 src/plugins/filter/store.ts create mode 100644 src/plugins/filter/types.ts create mode 100644 src/plugins/filter/utils/doesFeaturePassFilter.ts create mode 100644 src/plugins/filter/utils/getVectorSource.ts create mode 100644 src/plugins/filter/utils/parseDateWithPattern.ts create mode 100644 src/plugins/filter/utils/updateFeatureVisibility.ts delete mode 100644 vue2/packages/plugins/Filter/CHANGELOG.md delete mode 100644 vue2/packages/plugins/Filter/LICENSE delete mode 100644 vue2/packages/plugins/Filter/README.md delete mode 100644 vue2/packages/plugins/Filter/package.json delete mode 100644 vue2/packages/plugins/Filter/src/components/ChooseTimeFrame.vue delete mode 100644 vue2/packages/plugins/Filter/src/components/Filter.vue delete mode 100644 vue2/packages/plugins/Filter/src/components/index.ts delete mode 100644 vue2/packages/plugins/Filter/src/index.ts delete mode 100644 vue2/packages/plugins/Filter/src/locales.ts delete mode 100644 vue2/packages/plugins/Filter/src/store/index.ts delete mode 100644 vue2/packages/plugins/Filter/src/types.ts delete mode 100644 vue2/packages/plugins/Filter/src/utils/arrayOnlyContains.ts delete mode 100644 vue2/packages/plugins/Filter/src/utils/parseTimeOption.ts delete mode 100644 vue2/packages/plugins/Filter/src/utils/setState.ts delete mode 100644 vue2/packages/plugins/Filter/src/utils/updateFeatureVisibility.ts delete mode 100644 vue2/packages/plugins/Filter/vite.config.js diff --git a/examples/snowbox/index.js b/examples/snowbox/index.js index 5f7e4315fa..37da46e549 100644 --- a/examples/snowbox/index.js +++ b/examples/snowbox/index.js @@ -6,6 +6,7 @@ import { subscribe, updateState, } from '@polar/polar' +import pluginFilter from '@polar/polar/plugins/filter' import pluginFooter from '@polar/polar/plugins/footer' import pluginFullscreen from '@polar/polar/plugins/fullscreen' import pluginGeoLocation from '@polar/polar/plugins/geoLocation' @@ -178,6 +179,29 @@ const map = await createMap( { type: 'de', resources: { + filter: { + layer: { + [reports]: { + category: { + skat: { + title: 'Schadensart', + knownValue: { + 100: 'Wege und Straßen', + 101: 'Schlagloch und Wegeschaden', + 102: 'Verunreinigung und Vandalismus', + }, + }, + statu: { + title: 'Bearbeitungsstatus', + knownValue: { + 'In Bearbeitung': 'In Bearbeitung', + abgeschlossen: 'Abgeschlossen', + }, + }, + }, + }, + }, + }, fullscreen: { button: { label_on: 'Mach groß', @@ -311,6 +335,55 @@ addPlugin( icon: 'kern-icon-fill--share', }, ], + [ + { + plugin: pluginFilter({ + layers: { + [reports]: { + categories: [ + { + targetProperty: 'skat', + knownValues: [ + { + value: '100', + icon: 'kern-icon--road', + }, + { + value: '101', + icon: 'kern-icon--remove-road', + }, + { + value: '102', + icon: 'kern-icon--destruction', + }, + ], + selectAll: true, + }, + { + targetProperty: 'statu', + knownValues: [ + { + value: 'In Bearbeitung', + icon: 'kern-icon--assignment', + }, + { + value: 'abgeschlossen', + icon: 'kern-icon--check', + }, + ], + }, + ], + time: { + targetProperty: 'start', + freeSelection: 'until', + last: [0, 7, 30], + pattern: 'YYYYMMDD', + }, + }, + }, + }), + }, + ], [ { plugin: pluginLayerChooser({}), diff --git a/src/components/kern/KernBlockButton.ce.vue b/src/components/kern/KernBlockButton.ce.vue new file mode 100644 index 0000000000..77b5e9e6c2 --- /dev/null +++ b/src/components/kern/KernBlockButton.ce.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/src/components/kern/KernBlockButtonCheckbox.ce.vue b/src/components/kern/KernBlockButtonCheckbox.ce.vue new file mode 100644 index 0000000000..da32fd5115 --- /dev/null +++ b/src/components/kern/KernBlockButtonCheckbox.ce.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/src/components/kern/KernBlockButtonRadioGroup.ce.vue b/src/components/kern/KernBlockButtonRadioGroup.ce.vue new file mode 100644 index 0000000000..76fd8d79c7 --- /dev/null +++ b/src/components/kern/KernBlockButtonRadioGroup.ce.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/src/components/kern/KernDatePicker.ce.vue b/src/components/kern/KernDatePicker.ce.vue new file mode 100644 index 0000000000..a9a1cf4c10 --- /dev/null +++ b/src/components/kern/KernDatePicker.ce.vue @@ -0,0 +1,28 @@ + + + diff --git a/src/components/kern/KernDateRangePicker.ce.vue b/src/components/kern/KernDateRangePicker.ce.vue new file mode 100644 index 0000000000..130bce0d78 --- /dev/null +++ b/src/components/kern/KernDateRangePicker.ce.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/src/core/types/plugin.ts b/src/core/types/plugin.ts index 70f150818b..b4afa3fa6f 100644 --- a/src/core/types/plugin.ts +++ b/src/core/types/plugin.ts @@ -4,6 +4,10 @@ import type { NineLayoutTag } from '../utils/NineLayoutTag' import type { Locale } from './locales' import type { Icon } from './theme' +import type { PluginId as FilterPluginId } from '@/plugins/filter' +import type { useFilterStore as FilterStore } from '@/plugins/filter/store' +import type { resourcesEn as FilterResources } from '@/plugins/filter/locales' + import type { PluginId as FooterPluginId } from '@/plugins/footer' import type { useFooterStore as FooterStore } from '@/plugins/footer/store' import type { resourcesEn as FooterResources } from '@/plugins/footer/locales' @@ -89,6 +93,7 @@ export type PolarPluginStore< /** @internal */ export type BundledPluginId = + | typeof FilterPluginId | typeof FooterPluginId | typeof FullscreenPluginId | typeof GeoLocationPluginId @@ -110,6 +115,7 @@ type GetPluginStore< /** @internal */ export type BundledPluginStores = + | GetPluginStore | GetPluginStore | GetPluginStore | GetPluginStore @@ -132,6 +138,7 @@ type GetPluginResources< /** @internal */ export type BundledPluginLocaleResources = + | GetPluginResources | GetPluginResources | GetPluginResources | GetPluginResources< diff --git a/src/lib/dateUtils.ts b/src/lib/dateUtils.ts new file mode 100644 index 0000000000..0fa3b49450 --- /dev/null +++ b/src/lib/dateUtils.ts @@ -0,0 +1,16 @@ +export function dateToString(date: Date | null) { + if (date === null) { + return '' + } + return [date.getFullYear(), date.getMonth() + 1, date.getDate()] + .map((v) => v.toString().padStart(2, '0')) + .join('-') +} + +export function stringToDate(date: string) { + if (date.length === 0) { + return null + } + const [y, m, d] = date.split('-') + return new Date(Number(y), Number(m) - 1, Number(d)) +} diff --git a/src/lib/invisibleStyle.ts b/src/lib/invisibleStyle.ts index 6393afe037..5161ff9655 100644 --- a/src/lib/invisibleStyle.ts +++ b/src/lib/invisibleStyle.ts @@ -32,3 +32,25 @@ export const isInvisible = (feature: Feature) => */ export const isVisible = (feature: Feature) => feature.getStyle() !== InvisibleStyle + +/** + * Hides a feature. + * + * @param feature - The feature to hide. + */ +export const hideFeature = (feature: Feature) => { + if (isVisible(feature)) { + feature.setStyle(InvisibleStyle) + } +} + +/** + * Shows a feature. + * + * @param feature - The feature to show. + */ +export const showFeature = (feature: Feature) => { + if (isInvisible(feature)) { + feature.setStyle() + } +} diff --git a/src/plugins/filter/components/FilterCategory.ce.vue b/src/plugins/filter/components/FilterCategory.ce.vue new file mode 100644 index 0000000000..461e168e55 --- /dev/null +++ b/src/plugins/filter/components/FilterCategory.ce.vue @@ -0,0 +1,98 @@ + + + diff --git a/src/plugins/filter/components/FilterLayerChooser.ce.vue b/src/plugins/filter/components/FilterLayerChooser.ce.vue new file mode 100644 index 0000000000..1864a096f5 --- /dev/null +++ b/src/plugins/filter/components/FilterLayerChooser.ce.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/plugins/filter/components/FilterTime.ce.vue b/src/plugins/filter/components/FilterTime.ce.vue new file mode 100644 index 0000000000..958b0a4f9a --- /dev/null +++ b/src/plugins/filter/components/FilterTime.ce.vue @@ -0,0 +1,209 @@ + + + diff --git a/src/plugins/filter/components/FilterUI.ce.vue b/src/plugins/filter/components/FilterUI.ce.vue new file mode 100644 index 0000000000..3fef7f7886 --- /dev/null +++ b/src/plugins/filter/components/FilterUI.ce.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/src/plugins/filter/components/FilterUI.spec.ts b/src/plugins/filter/components/FilterUI.spec.ts new file mode 100644 index 0000000000..0fd7ecffa8 --- /dev/null +++ b/src/plugins/filter/components/FilterUI.spec.ts @@ -0,0 +1,95 @@ +import { expect, test as _test, vi, assert } from 'vitest' +import { mount, VueWrapper } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import { nextTick } from 'vue' +import { useFilterStore } from '../store' +import FilterUI from './FilterUI.ce.vue' +import { mockedT } from '@/test/utils/mockI18n' +import { useCoreStore } from '@/core/stores/export' + +/* eslint-disable no-empty-pattern */ +const test = _test.extend<{ + wrapper: VueWrapper + coreStore: ReturnType + store: ReturnType +}>({ + wrapper: async ({}, use) => { + vi.mock('i18next', () => ({ + t: (keyFn, opts) => mockedT(keyFn, opts), + })) + const wrapper = mount(FilterUI, { + attachTo: document.body, + global: { + plugins: [createTestingPinia({ createSpy: vi.fn })], + mocks: { + $t: mockedT, + }, + }, + }) + await use(wrapper) + wrapper.unmount() + }, + coreStore: async ({}, use) => { + const store = useCoreStore() + await use(store) + }, + store: async ({}, use) => { + const store = useFilterStore() + await use(store) + }, +}) +/* eslint-enable no-empty-pattern */ + +test('Component works as expected', async ({ wrapper, coreStore, store }) => { + // @ts-expect-error | This is for testing + coreStore.configuration = { + filter: { + layers: { + one: { + categories: [ + { + targetProperty: 'pet', + knownValues: ['cat', 'dog'], + selectAll: true, + }, + ], + time: { + targetProperty: 'time', + freeSelection: 'until', + last: [1], + }, + }, + }, + }, + } + await nextTick() + + const onlyCat = wrapper + .findAll('label') + .find( + (lbl) => lbl.text() === '$t(filter:layer.one.category.pet.knownValue.cat)' + ) + assert(onlyCat !== undefined, 'Could not find cat button') + await onlyCat.trigger('click') + + expect(store.state.one?.knownValues?.pet?.dog).toBeTruthy() + expect(store.state.one?.knownValues?.pet?.cat).toBeFalsy() + + const yesterday = wrapper + .findAll('label') + .find((lbl) => lbl.text() === '$t(filter:time.last_1)') + assert(yesterday !== undefined, 'Could not find yesterday button') + await yesterday.trigger('click') + + const now = new Date() + const from = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1) + const until = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1) + + expect(store.state.one?.timeSpan?.time?.from).toEqual(from) + expect(store.state.one?.timeSpan?.time?.until).toEqual(until) + + await nextTick() + expect( + (yesterday.element.control as HTMLInputElement | null)?.checked + ).toBeTruthy() +}) diff --git a/src/plugins/filter/index.ts b/src/plugins/filter/index.ts new file mode 100644 index 0000000000..e9523230ae --- /dev/null +++ b/src/plugins/filter/index.ts @@ -0,0 +1,31 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/filter + */ +/* eslint-enable tsdoc/syntax */ + +import component from './components/FilterUI.ce.vue' +import locales from './locales' +import { useFilterStore } from './store' +import { PluginId, type FilterPluginOptions } from './types' +import type { PluginContainer, PolarPluginStore } from '@/core' + +/** + * Creates a plugin which allows to filter arbitrary configurable vector layers by their properties. + * + * @returns Plugin for use with {@link addPlugin} + */ +export default function pluginFilter( + options: FilterPluginOptions +): PluginContainer { + return { + id: PluginId, + component, + locales, + icon: 'kern-icon--filter-alt', + storeModule: useFilterStore as PolarPluginStore, + options, + } +} + +export * from './types' diff --git a/src/plugins/filter/locales.ts b/src/plugins/filter/locales.ts new file mode 100644 index 0000000000..224ac89d5b --- /dev/null +++ b/src/plugins/filter/locales.ts @@ -0,0 +1,73 @@ +/* eslint-disable tsdoc/syntax */ +/** + * This is the documentation for the locales keys in the filter plugin. + * These locales are *NOT* exported, but documented only. + * + * @module locales/plugins/filter + */ +/* eslint-enable tsdoc/syntax */ + +import type { Locale } from '@/core' + +/** + * German locales for filter plugin. + * For overwriting these values, use the plugin's ID as namespace. + */ +export const resourcesDe = { + category: { + deselectAll: 'Alle an-/abwählen', + }, + time: { + header: 'Zeitraum', + noRestriction: 'Keine Einschränkung', + last_zero: 'Heute', + last_one: 'Der letzte Tag', + last_other: 'Die letzten {{count}} Tage', + next_zero: 'Heute', + next_one: 'Der nächste Tag', + next_other: 'Die nächsten {{count}} Tage', + chooseTimeFrame: 'Zeitraum wählen', + }, +} as const + +/** + * English locales for filter plugin. + * For overwriting these values, use the plugin's ID as namespace. + */ +export const resourcesEn = { + category: { + deselectAll: 'De-/select all', + }, + time: { + header: 'Time frame', + noRestriction: 'No restriction', + last_zero: 'Today', + last_one: 'The last day', + last_other: 'The last {{count}} days', + next_zero: 'Today', + next_one: 'The next day', + next_other: 'The next {{count}} days', + chooseTimeFrame: 'Choose time frame', + }, +} as const + +/** + * Filter plugin locales. + * + * @privateRemarks + * The first entry will be used as fallback. + * + * @internal + */ +const locales: Locale[] = [ + { + type: 'de', + resources: resourcesDe, + }, + { + type: 'en', + resources: resourcesEn, + }, +] + +export default locales diff --git a/src/plugins/filter/store.ts b/src/plugins/filter/store.ts new file mode 100644 index 0000000000..8522628af2 --- /dev/null +++ b/src/plugins/filter/store.ts @@ -0,0 +1,88 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/filter/store + */ +/* eslint-enable tsdoc/syntax */ + +import { acceptHMRUpdate, defineStore } from 'pinia' +import { computed, ref, watch } from 'vue' +import { PluginId, type FilterPluginOptions, type FilterState } from './types' +import { updateFeatureVisibility } from './utils/updateFeatureVisibility' +import { getVectorSource } from './utils/getVectorSource' +import { useCoreStore } from '@/core/stores/export' + +/* eslint-disable tsdoc/syntax */ +/** + * @function + * + * Plugin store for filtering features. + */ +/* eslint-enable tsdoc/syntax */ +export const useFilterStore = defineStore('plugins/filter', () => { + const coreStore = useCoreStore() + + const configuration = computed( + () => + (coreStore.configuration[PluginId] ?? { + layers: {}, + }) as FilterPluginOptions + ) + + const state = ref>({}) + + const filteredLayers = computed(() => + coreStore.map + .getAllLayers() + .filter((layer) => + Object.keys(configuration.value.layers).includes(layer.get('id')) + ) + ) + + const teardownCallbacks = [] as (() => void)[] + + function setupPlugin() { + filteredLayers.value.forEach((layer) => { + const source = getVectorSource(layer) + const callback = () => { + updateFeatureVisibility(source, state.value[layer.get('id')] ?? {}) + } + source.on('featuresloadend', callback) + teardownCallbacks.push(() => { + source.un('featuresloadend', callback) + }) + teardownCallbacks.push( + watch( + () => state.value[layer.get('id')], + () => { + callback() + }, + { deep: true, immediate: true } + ) + ) + }) + } + + function teardownPlugin() { + teardownCallbacks.forEach((callback) => { + callback() + }) + } + + return { + /** @internal */ + configuration, + + /** @internal */ + state, + + /** @internal */ + setupPlugin, + + /** @internal */ + teardownPlugin, + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useFilterStore, import.meta.hot)) +} diff --git a/src/plugins/filter/types.ts b/src/plugins/filter/types.ts new file mode 100644 index 0000000000..6f709f5547 --- /dev/null +++ b/src/plugins/filter/types.ts @@ -0,0 +1,332 @@ +import type { Icon, PluginOptions } from '@/core' + +/** + * Plugin identifier. + */ +export const PluginId = 'filter' + +/** + * A single value of a category-based filter configuration for a layer. + * + * @example + * ```ts + * { + * value: 'home', + * icon: 'kern-icon--home', + * } + * ``` + */ +export interface CategoryValue { + /** + * Technical value of a feature property. + */ + value: string + + /** + * An icon that is assigned to the value for filtering. + */ + icon?: Icon +} + +/** + * Category-based filter configuration for a layer. + * + * @example + * ```ts + * { + * targetProperty: 'favouriteIceCream', + * knownValues: ['chocolate', 'vanilla', 'strawberry'], + * selectAll: true, + * } + * ``` + * + * This example configuration will add these checkboxes: + * + * ``` + * ▢ De-/select all + * ▢ Chocolate + * ▢ Vanilla + * ▢ Strawberry + * ``` + */ +export interface Category { + /** + * Known values for the target property to filter by. + * Values not listed here cannot be filtered. + * + * If using `string` instead of `CategoryValue`, the string is interpreted as the `value` key. + * + * @remarks + * The values listed here can be localized: + * ```ts + * filter: { + * layer: { + * haus: { + * category: { + * houseType: { + * knownValue: { + * shed: 'Schuppen', + * mansion: 'Villa', + * fortress: 'Festung', + * }, + * }, + * }, + * }, + * }, + * } + * ``` + * + * @example ['shed', 'mansion', 'fortress'] + */ + knownValues: (CategoryValue | string)[] + + /** + * Key of the feature property to filter by. + * + * @remarks + * This value can be localized: + * ```ts + * filter: { + * layer: { + * haus: { + * category: { + * houseType: { + * title: 'Art des Hauses', + * }, + * }, + * }, + * }, + * } + * ``` + * + * @example `'houseType'` + */ + targetProperty: string + + /** + * If `true`, a checkbox is provided to enable or disable all `knownValues` at once. + * + * @example true + * @defaultValue false + */ + selectAll?: boolean +} + +/** + * Time-based filter configuration for a layer. + * + * @remarks + * Of all time restrictions, at most one can be selected at any time. + * The produced options are selectable by radio buttons. + * + * @example + * ```ts + * { + * targetProperty: 'start', + * pattern: 'YYYYMMDD', + * last: [ + * { + * amounts: [7, 30], + * }, + * ], + * next: [ + * { + * amounts: [7, 30], + * }, + * ], + * freeSelection: { + * now: 'until', + * }, + * } + * ``` + */ +export interface Time { + /** + * Key of the feature property to filter by. + */ + targetProperty: string + + /** + * Defines if the time filter is freely selectable by the user. + * If set to `'until'`, every time range until the current day (inclusive) can be selected. + * If set to `'from'`, every time range from the current day (inclusive) can be selected. + * If not set, this feature is disabled. + * + * @example + * The configuration `'until'` will add this option: + * ```ts + * ◯ Choose time frame + * From ▒▒▒▒▒▒▒▒▒▒▒ // clicking input opens a selector restricted *until* today + * To ▇▇▇▇▇▇▇▇▇▇▇ // clicking input opens a selector restricted *until* today + * ``` + */ + freeSelection?: 'until' | 'from' + + /** + * Configuration for preset time ranges in the past, measured in days. + * A configuration of `[5, 10]` adds the options `Last 5 days` and `Last 10 days`. + * + * @example + * For the configuration `[3, 7]`, this will yield the following options: + * ``` + * ◯ Last 3 days + * ◯ Last 7 days + * ``` + * + * @remarks + * The selections will always include full days, and additionally the current day. + * Due to this, the time frame of "last 7 days" is actually 8*24h long. + * This seems unexpected at first, but follows intuition – if it's Monday and a user filters to the "last seven days", they would expect to fully see last week's Monday, but also features from that day's morning. + */ + last?: number[] + + /** + * Configuration for preset time ranges in the future. + * A configuration of `[5, 10]` adds the options `Next 5 days` and `Next 10 days`. + * + * @example + * For the configuration `[3, 7]`, this will yield the following options: + * ``` + * ◯ Next 3 days + * ◯ Next 7 days + * ``` + * + * @remarks + * The selections will always include full days, and additionally the current day. + * Due to this, the time frame of "next 7 days" is actually 8*24h long. + * This seems unexpected at first, but follows intuition – if it's Monday and a user filters to the "next seven days", they would expect to fully see next week's Monday, but also features from that day's morning. + */ + next?: number[] + + /** + * A pattern that specifies the date format used in the feature properties. + * The pattern definition allows the following tokens: + * - `YYYY`: 4-digit year + * - `MM`: 2-digit month (01-12) + * - `DD`: 2-digit day of month (01-31) + * - `-`: ignored character + * + * @privateRemarks + * All characters that are not tokens are handled as ignored characters. + * This behavior may change in future versions without a breaking change! + * + * @example For the pattern `'--YYYYDD-MM'`, the value `'ML197001-04'` will be interpreted as 1970-04-01 / Apr 1, 1970. + * @defaultValue 'YYYY-MM-DD' + */ + pattern?: string +} + +/** + * Filter configuration for a layer. + * + * @example + * ```ts + * { + * categories: [ + * { + * targetProperty: 'favouriteIceCream', + * knownValues: ['chocolate', 'vanilla', 'strawberry'], + * selectAll: true, + * }, + * ], + * time: { + * targetProperty: 'start', + * pattern: 'YYYYMMDD', + * }, + * } + * ``` + */ +export interface FilterConfiguration { + /** + * A definition of different categories to filter features based on their properties. + */ + categories?: Category[] + + /** + * Filter features based on a time property. + */ + time?: Time +} + +/** + * Plugin options for filter plugin. + * + * @example + * ```ts + * { + * layers: { + * '1234': { + * categories: [ + * { + * selectAll: true, + * targetProperty: 'buildingType', + * knownValues: ['shed', 'mansion', 'fortress'] + * }, + * { + * selectAll: false, + * targetProperty: 'lightbulb', + * knownValues: ['on', 'off'] + * } + * ], + * time: { + * targetProperty: 'lastAccident', + * last: [ + * { + * amounts: [7, 30], + * unit: 'days', + * }, + * ], + * freeSelection: { + * unit: 'days', + * now: 'until' + * }, + * pattern: 'YYYYDDMM' + * } + * } + * } + * } + * ``` + */ +export interface FilterPluginOptions extends PluginOptions { + /** + * Maps a layer ID to its filter configuration. + */ + layers: Record +} + +/** + * Filter state for a layer. + * This represents the filters enabled by the user. + */ +export interface FilterState { + /** + * For each key representing a property's key, only values listed as keys with a truthy value in the value record are visible. + * + * @example + * The following example allows the property `houseType` to have the value `shed` only. + * ```ts + * { + * houseType: { shed: true, house: false }, + * } + * ``` + */ + knownValues?: Record> + + /** + * For each key representing a property's key, only values starting after `from` and ending until `until` are visible. + * The interpretation of the date is done using the `pattern` as described in `Time.pattern`. + * + * @example + * The following example allows the property `time` (ISO date) to be in 2025. + * ```ts + * { + * time: { + * from: new Date('2025-01-01'), + * to: new Date('2025-12-31'), + * pattern: 'YYYY-MM-DD', + * }, + * } + * ``` + */ + timeSpan?: Record +} diff --git a/src/plugins/filter/utils/doesFeaturePassFilter.ts b/src/plugins/filter/utils/doesFeaturePassFilter.ts new file mode 100644 index 0000000000..dbfc0335bb --- /dev/null +++ b/src/plugins/filter/utils/doesFeaturePassFilter.ts @@ -0,0 +1,108 @@ +import { Feature } from 'ol' +import type { FilterState } from '../types' +import { parseDateWithPattern } from './parseDateWithPattern' + +/** + * Checks if a given feature passes the given filter state. + * + * @param feature - Feature to check + * @param filter - Current filter state + * @returns `true` if the feature should be visible, `false` otherwise + */ +export function doesFeaturePassFilter(feature: Feature, filter: FilterState) { + if ( + filter.knownValues && + !Object.entries(filter.knownValues).every( + ([key, values]) => values[feature.get(key)] + ) + ) { + return false + } + + if ( + filter.timeSpan && + !Object.entries(filter.timeSpan).every(([key, config]) => { + const featureDate = parseDateWithPattern(feature.get(key), config.pattern) + return featureDate >= config.from && featureDate < config.until + }) + ) { + return false + } + + return true +} + +if (import.meta.vitest) { + const { expect, test } = import.meta.vitest + + const feature = new Feature() + feature.set('category', 'blue') + feature.set('time', '2025-01-01') + + test('a feature passes an empty filter', () => { + const filter = {} satisfies FilterState + expect(doesFeaturePassFilter(feature, filter)).toBeTruthy() + }) + + test('a feature passes the category filter', () => { + const filter = { + knownValues: { + category: { + blue: true, + }, + }, + } satisfies FilterState + expect(doesFeaturePassFilter(feature, filter)).toBeTruthy() + }) + + test('a feature fails the category filter', () => { + const filter = { + knownValues: { + category: { + red: true, + }, + }, + } satisfies FilterState + expect(doesFeaturePassFilter(feature, filter)).toBeFalsy() + }) + + test('a feature passes the time filter', () => { + const filter = { + timeSpan: { + time: { + pattern: 'YYYY-MM-DD', + from: new Date('Jan 1, 2024'), + until: new Date('Dec 31, 2026'), + }, + }, + } satisfies FilterState + expect(doesFeaturePassFilter(feature, filter)).toBeTruthy() + }) + + test('a feature fails the time filter', () => { + const filter = { + timeSpan: { + time: { + pattern: 'YYYY-MM-DD', + from: new Date('Jan 1, 2024'), + until: new Date('Dec 31, 2024'), + }, + }, + } satisfies FilterState + expect(doesFeaturePassFilter(feature, filter)).toBeFalsy() + }) + + test('a feature fails one out of two filters', () => { + const filter = { + knownValues: { + category: { + blue: true, + }, + misc: { + yes: true, + }, + }, + } satisfies FilterState + expect(doesFeaturePassFilter(feature, filter)).toBeFalsy() + }) +} diff --git a/src/plugins/filter/utils/getVectorSource.ts b/src/plugins/filter/utils/getVectorSource.ts new file mode 100644 index 0000000000..0144c088fe --- /dev/null +++ b/src/plugins/filter/utils/getVectorSource.ts @@ -0,0 +1,26 @@ +import type Layer from 'ol/layer/Layer' +import ClusterSource from 'ol/source/Cluster' +import VectorSource from 'ol/source/Vector' + +/** + * Retrieves the vector source from a layer using (possibly nested) `.getSource()` calls. + * + * @param layer - Layer to get the vector source from + * @returns Vector source + */ +export function getVectorSource(layer: Layer): VectorSource { + let source = layer.getSource() + if (!source) { + throw new Error('Could not find a vector source for this layer') + } + while (source instanceof ClusterSource) { + source = source.getSource() + if (!source) { + throw new Error('Could not find a vector source for this layer') + } + } + if (!(source instanceof VectorSource)) { + throw new Error('Could not find a vector source for this layer') + } + return source +} diff --git a/src/plugins/filter/utils/parseDateWithPattern.ts b/src/plugins/filter/utils/parseDateWithPattern.ts new file mode 100644 index 0000000000..ad950725db --- /dev/null +++ b/src/plugins/filter/utils/parseDateWithPattern.ts @@ -0,0 +1,43 @@ +/** + * Returns a `Date` object from a string using the parsing instruction described with pattern. + * + * @param date - The date string to parse from + * @param pattern - The pattern to parse with + * @returns Parsed Date object + */ +export function parseDateWithPattern(date: string, pattern: string): Date { + const result = Object.fromEntries(['Y', 'M', 'D'].map((key) => [key, ''])) + pattern.split('').forEach((token, index) => { + if (token in result && typeof result[token] === 'string') { + result[token] += date[index] || '' + } + }) + return new Date(Number(result.Y), Number(result.M) - 1, Number(result.D)) +} + +if (import.meta.vitest) { + const { expect, test } = import.meta.vitest + + test.for([ + { + date: '2025-07-01', + pattern: 'YYYY-MM-DD', + expected: new Date('Jul 1, 2025'), + }, + { + date: '202-521-12X', + pattern: 'YYY-YDM-MD-', + expected: new Date('Nov 22, 2025'), + }, + { + date: '2026-01', + pattern: 'YYYY-MM-DD', + expected: new Date('Dec 31, 2025'), + }, + ])( + 'parseDateWithPattern works as expected', + ({ date, pattern, expected }) => { + expect(parseDateWithPattern(date, pattern)).toEqual(expected) + } + ) +} diff --git a/src/plugins/filter/utils/updateFeatureVisibility.ts b/src/plugins/filter/utils/updateFeatureVisibility.ts new file mode 100644 index 0000000000..8ad06b22e3 --- /dev/null +++ b/src/plugins/filter/utils/updateFeatureVisibility.ts @@ -0,0 +1,56 @@ +import VectorSource from 'ol/source/Vector' +import { Feature } from 'ol' +import type { FilterState } from '../types' +import { doesFeaturePassFilter } from './doesFeaturePassFilter' +import { hideFeature, showFeature } from '@/lib/invisibleStyle' + +/** + * Update the features in the given source according to the given filter. + * + * @param source - Source of the layer + * @param filter - Filter state for the layer + */ +export function updateFeatureVisibility( + source: VectorSource, + filter: FilterState +) { + const features = source + .getFeatures() + .flatMap((feature) => feature.get('features') || [feature]) as Feature[] + + // For performance reasons, do not update each feature individually on screen. + source.clear() + features.forEach((feature) => { + ;(doesFeaturePassFilter(feature, filter) ? showFeature : hideFeature)( + feature + ) + }) + source.addFeatures(features) +} + +if (import.meta.vitest) { + const { test, expect, vi } = import.meta.vitest + const { isVisible, isInvisible } = await import('@/lib/invisibleStyle') + const doesFeaturePassFilterFile = await import('./doesFeaturePassFilter') + const filterSpy = vi.spyOn(doesFeaturePassFilterFile, 'doesFeaturePassFilter') + filterSpy.mockImplementation((f: Feature) => f.get('filter') === 'yes') + + test('feature visibility is updated according to filter', () => { + const alpha = new Feature() + alpha.set('filter', 'yes') + + const beta = new Feature() + beta.set('filter', 'no') + + const source = new VectorSource() + source.addFeatures([alpha, beta]) + + updateFeatureVisibility(source, {}) + + expect(source.getFeatures()).toHaveLength(2) + expect(source.getFeatures()).toContain(alpha) + expect(isVisible(alpha)).toBeTruthy() + expect(source.getFeatures()).toContain(beta) + expect(isInvisible(beta)).toBeTruthy() + }) +} diff --git a/src/test/utils/mockI18n.ts b/src/test/utils/mockI18n.ts index 5b6f5150b8..6b69d107da 100644 --- a/src/test/utils/mockI18n.ts +++ b/src/test/utils/mockI18n.ts @@ -7,6 +7,7 @@ export function mockedT( options: { ns: string context?: string + count?: number } ) { const target = { @@ -21,5 +22,5 @@ export function mockedT( return proxy }, }) - return `$t(${options.ns}:${keyFn(proxy)}${options.context ? `_${options.context}` : ''})` + return `$t(${options.ns}:${keyFn(proxy)}${options.context ? `_${options.context}` : ''}${options.count ? `_${options.count}` : ''})` } diff --git a/vue2/packages/plugins/Filter/CHANGELOG.md b/vue2/packages/plugins/Filter/CHANGELOG.md deleted file mode 100644 index dea2ff57d3..0000000000 --- a/vue2/packages/plugins/Filter/CHANGELOG.md +++ /dev/null @@ -1,35 +0,0 @@ -# CHANGELOG - -## unpublished - -- Fix: Resolve a bug where keyboard navigation in radio groups didn't work. - -## 3.0.0 - -- Breaking: Upgrade peerDependency `ol` from `^9.2.4` to `^10.3.1`. - -## 2.0.0 - -- Breaking: Upgrade peerDependency `ol` from `^7.1.0` to `^9.2.4`. -- Chore: Remove unused peerDependency `@masterportal/masterportalapi`. - -## 1.1.2 - -- Fix: Features with categories that are not listed in `knownValues` are never displayed now. Previously, they were initially visible, but disappeared once any filter was touched. -- Fix: It was possible to have features visible that were loaded after the filter was applied and that would have been filtered out. This has been resolved. -- Fix: Adjust documentation to properly describe optionality of configuration parameters. -- Fix: Correctly log an error if required parameter `layers` is not provided. - -## 1.1.1 - -- Fix: Configurations without time element could sometimes error on filtering operations. -- Fix: Filtering by custom timeframe added an additional day into the range. -- Fix: Filtering by a single day selected features of the next day only. - -## 1.1.0 - -- Feature: Improved implementation to make plugin SPA-ready. - -## 1.0.0 - -Initial release. diff --git a/vue2/packages/plugins/Filter/LICENSE b/vue2/packages/plugins/Filter/LICENSE deleted file mode 100644 index c29ce2f835..0000000000 --- a/vue2/packages/plugins/Filter/LICENSE +++ /dev/null @@ -1,287 +0,0 @@ - EUROPEAN UNION PUBLIC LICENCE v. 1.2 - EUPL © the European Union 2007, 2016 - -This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined -below) which is provided under the terms of this Licence. Any use of the Work, -other than as authorised under this Licence is prohibited (to the extent such -use is covered by a right of the copyright holder of the Work). - -The Work is provided under the terms of this Licence when the Licensor (as -defined below) has placed the following notice immediately following the -copyright notice for the Work: - - Licensed under the EUPL - -or has expressed by any other means his willingness to license under the EUPL. - -1. Definitions - -In this Licence, the following terms have the following meaning: - -- ‘The Licence’: this Licence. - -- ‘The Original Work’: the work or software distributed or communicated by the - Licensor under this Licence, available as Source Code and also as Executable - Code as the case may be. - -- ‘Derivative Works’: the works or software that could be created by the - Licensee, based upon the Original Work or modifications thereof. This Licence - does not define the extent of modification or dependence on the Original Work - required in order to classify a work as a Derivative Work; this extent is - determined by copyright law applicable in the country mentioned in Article 15. - -- ‘The Work’: the Original Work or its Derivative Works. - -- ‘The Source Code’: the human-readable form of the Work which is the most - convenient for people to study and modify. - -- ‘The Executable Code’: any code which has generally been compiled and which is - meant to be interpreted by a computer as a program. - -- ‘The Licensor’: the natural or legal person that distributes or communicates - the Work under the Licence. - -- ‘Contributor(s)’: any natural or legal person who modifies the Work under the - Licence, or otherwise contributes to the creation of a Derivative Work. - -- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of - the Work under the terms of the Licence. - -- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, - renting, distributing, communicating, transmitting, or otherwise making - available, online or offline, copies of the Work or providing access to its - essential functionalities at the disposal of any other natural or legal - person. - -2. Scope of the rights granted by the Licence - -The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, -sublicensable licence to do the following, for the duration of copyright vested -in the Original Work: - -- use the Work in any circumstance and for all usage, -- reproduce the Work, -- modify the Work, and make Derivative Works based upon the Work, -- communicate to the public, including the right to make available or display - the Work or copies thereof to the public and perform publicly, as the case may - be, the Work, -- distribute the Work or copies thereof, -- lend and rent the Work or copies thereof, -- sublicense rights in the Work or copies thereof. - -Those rights can be exercised on any media, supports and formats, whether now -known or later invented, as far as the applicable law permits so. - -In the countries where moral rights apply, the Licensor waives his right to -exercise his moral right to the extent allowed by law in order to make effective -the licence of the economic rights here above listed. - -The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to -any patents held by the Licensor, to the extent necessary to make use of the -rights granted on the Work under this Licence. - -3. Communication of the Source Code - -The Licensor may provide the Work either in its Source Code form, or as -Executable Code. If the Work is provided as Executable Code, the Licensor -provides in addition a machine-readable copy of the Source Code of the Work -along with each copy of the Work that the Licensor distributes or indicates, in -a notice following the copyright notice attached to the Work, a repository where -the Source Code is easily and freely accessible for as long as the Licensor -continues to distribute or communicate the Work. - -4. Limitations on copyright - -Nothing in this Licence is intended to deprive the Licensee of the benefits from -any exception or limitation to the exclusive rights of the rights owners in the -Work, of the exhaustion of those rights or of other applicable limitations -thereto. - -5. Obligations of the Licensee - -The grant of the rights mentioned above is subject to some restrictions and -obligations imposed on the Licensee. Those obligations are the following: - -Attribution right: The Licensee shall keep intact all copyright, patent or -trademarks notices and all notices that refer to the Licence and to the -disclaimer of warranties. The Licensee must include a copy of such notices and a -copy of the Licence with every copy of the Work he/she distributes or -communicates. The Licensee must cause any Derivative Work to carry prominent -notices stating that the Work has been modified and the date of modification. - -Copyleft clause: If the Licensee distributes or communicates copies of the -Original Works or Derivative Works, this Distribution or Communication will be -done under the terms of this Licence or of a later version of this Licence -unless the Original Work is expressly distributed only under this version of the -Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee -(becoming Licensor) cannot offer or impose any additional terms or conditions on -the Work or Derivative Work that alter or restrict the terms of the Licence. - -Compatibility clause: If the Licensee Distributes or Communicates Derivative -Works or copies thereof based upon both the Work and another work licensed under -a Compatible Licence, this Distribution or Communication can be done under the -terms of this Compatible Licence. For the sake of this clause, ‘Compatible -Licence’ refers to the licences listed in the appendix attached to this Licence. -Should the Licensee's obligations under the Compatible Licence conflict with -his/her obligations under this Licence, the obligations of the Compatible -Licence shall prevail. - -Provision of Source Code: When distributing or communicating copies of the Work, -the Licensee will provide a machine-readable copy of the Source Code or indicate -a repository where this Source will be easily and freely available for as long -as the Licensee continues to distribute or communicate the Work. - -Legal Protection: This Licence does not grant permission to use the trade names, -trademarks, service marks, or names of the Licensor, except as required for -reasonable and customary use in describing the origin of the Work and -reproducing the content of the copyright notice. - -6. Chain of Authorship - -The original Licensor warrants that the copyright in the Original Work granted -hereunder is owned by him/her or licensed to him/her and that he/she has the -power and authority to grant the Licence. - -Each Contributor warrants that the copyright in the modifications he/she brings -to the Work are owned by him/her or licensed to him/her and that he/she has the -power and authority to grant the Licence. - -Each time You accept the Licence, the original Licensor and subsequent -Contributors grant You a licence to their contributions to the Work, under the -terms of this Licence. - -7. Disclaimer of Warranty - -The Work is a work in progress, which is continuously improved by numerous -Contributors. It is not a finished work and may therefore contain defects or -‘bugs’ inherent to this type of development. - -For the above reason, the Work is provided under the Licence on an ‘as is’ basis -and without warranties of any kind concerning the Work, including without -limitation merchantability, fitness for a particular purpose, absence of defects -or errors, accuracy, non-infringement of intellectual property rights other than -copyright as stated in Article 6 of this Licence. - -This disclaimer of warranty is an essential part of the Licence and a condition -for the grant of any rights to the Work. - -8. Disclaimer of Liability - -Except in the cases of wilful misconduct or damages directly caused to natural -persons, the Licensor will in no event be liable for any direct or indirect, -material or moral, damages of any kind, arising out of the Licence or of the use -of the Work, including without limitation, damages for loss of goodwill, work -stoppage, computer failure or malfunction, loss of data or any commercial -damage, even if the Licensor has been advised of the possibility of such damage. -However, the Licensor will be liable under statutory product liability laws as -far such laws apply to the Work. - -9. Additional agreements - -While distributing the Work, You may choose to conclude an additional agreement, -defining obligations or services consistent with this Licence. However, if -accepting obligations, You may act only on your own behalf and on your sole -responsibility, not on behalf of the original Licensor or any other Contributor, -and only if You agree to indemnify, defend, and hold each Contributor harmless -for any liability incurred by, or claims asserted against such Contributor by -the fact You have accepted any warranty or additional liability. - -10. Acceptance of the Licence - -The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ -placed under the bottom of a window displaying the text of this Licence or by -affirming consent in any other similar way, in accordance with the rules of -applicable law. Clicking on that icon indicates your clear and irrevocable -acceptance of this Licence and all of its terms and conditions. - -Similarly, you irrevocably accept this Licence and all of its terms and -conditions by exercising any rights granted to You by Article 2 of this Licence, -such as the use of the Work, the creation by You of a Derivative Work or the -Distribution or Communication by You of the Work or copies thereof. - -11. Information to the public - -In case of any Distribution or Communication of the Work by means of electronic -communication by You (for example, by offering to download the Work from a -remote location) the distribution channel or media (for example, a website) must -at least provide to the public the information requested by the applicable law -regarding the Licensor, the Licence and the way it may be accessible, concluded, -stored and reproduced by the Licensee. - -12. Termination of the Licence - -The Licence and the rights granted hereunder will terminate automatically upon -any breach by the Licensee of the terms of the Licence. - -Such a termination will not terminate the licences of any person who has -received the Work from the Licensee under the Licence, provided such persons -remain in full compliance with the Licence. - -13. Miscellaneous - -Without prejudice of Article 9 above, the Licence represents the complete -agreement between the Parties as to the Work. - -If any provision of the Licence is invalid or unenforceable under applicable -law, this will not affect the validity or enforceability of the Licence as a -whole. Such provision will be construed or reformed so as necessary to make it -valid and enforceable. - -The European Commission may publish other linguistic versions or new versions of -this Licence or updated versions of the Appendix, so far this is required and -reasonable, without reducing the scope of the rights granted by the Licence. New -versions of the Licence will be published with a unique version number. - -All linguistic versions of this Licence, approved by the European Commission, -have identical value. Parties can take advantage of the linguistic version of -their choice. - -14. Jurisdiction - -Without prejudice to specific agreement between parties, - -- any litigation resulting from the interpretation of this License, arising - between the European Union institutions, bodies, offices or agencies, as a - Licensor, and any Licensee, will be subject to the jurisdiction of the Court - of Justice of the European Union, as laid down in article 272 of the Treaty on - the Functioning of the European Union, - -- any litigation arising between other parties and resulting from the - interpretation of this License, will be subject to the exclusive jurisdiction - of the competent court where the Licensor resides or conducts its primary - business. - -15. Applicable Law - -Without prejudice to specific agreement between parties, - -- this Licence shall be governed by the law of the European Union Member State - where the Licensor has his seat, resides or has his registered office, - -- this licence shall be governed by Belgian law if the Licensor has no seat, - residence or registered office inside a European Union Member State. - -Appendix - -‘Compatible Licences’ according to Article 5 EUPL are: - -- GNU General Public License (GPL) v. 2, v. 3 -- GNU Affero General Public License (AGPL) v. 3 -- Open Software License (OSL) v. 2.1, v. 3.0 -- Eclipse Public License (EPL) v. 1.0 -- CeCILL v. 2.0, v. 2.1 -- Mozilla Public Licence (MPL) v. 2 -- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 -- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for - works other than software -- European Union Public Licence (EUPL) v. 1.1, v. 1.2 -- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong - Reciprocity (LiLiQ-R+). - -The European Commission may update this Appendix to later versions of the above -licences without producing a new version of the EUPL, as long as they provide -the rights granted in Article 2 of this Licence and protect the covered Source -Code from exclusive appropriation. - -All other changes or additions to this Appendix require the production of a new -EUPL version. \ No newline at end of file diff --git a/vue2/packages/plugins/Filter/README.md b/vue2/packages/plugins/Filter/README.md deleted file mode 100644 index 32d730084e..0000000000 --- a/vue2/packages/plugins/Filter/README.md +++ /dev/null @@ -1,197 +0,0 @@ -# Filter - -## Scope - -The Filter plugin can be used to filter arbitrary configurable vector layers by their properties. - -## Configuration - -### filter - -| fieldName | type | description | -| - | - | - | -| layers | Record | Maps layer id to filter configuration. | - -For details on the `displayComponent` attribute, refer to the [Global Plugin Parameters](../../core/README.md#global-plugin-parameters) section of `@polar/core`. - -``` -The following chapters contain drafts in this format. Please mind that they neither represent UI nor localisation, but are merely there to communicate the idea with an example. -``` - -
-Example configuration - -```js -{ - filter: { - layers: { - '1234': { - categories: [ - { - selectAll: true, - targetProperty: 'buildingType', - knownValues: ['shed', 'mansion', 'fortress'] - }, - { - selectAll: false, - targetProperty: 'lightbulb', - knownValues: ['on', 'off'] - } - ], - time: { - targetProperty: 'lastAccident', - last: [ - { - amounts: [7, 30], - unit: 'days', - }, - ], - freeSelection: { - unit: 'days', - now: 'until' - }, - /** - * Feature holds date property as e.g. "20143012", where 2014 is the - * year, 30 the day, and 12 the month. - */ - pattern: 'YYYYDDMM' - } - } - } - } -} -``` - -
- -#### filter.filterConfiguration - -| fieldName | type | description | -| - | - | - | -| categories | category[]? | Category filter definition to filter features by their property values. | -| time | time? | Time filter definition so filter features by a time property. | - -Example configuration: -```js -categories: [ - { - targetProperty: 'favouriteIceCream', - knownValues: ['chocolate', 'vanilla', 'strawberry'], - selectAll: true - } -], -time: { - targetProperty: 'start', - pattern: 'YYYYMMDD', -}, -``` - -##### filter.filterConfiguration.category - -| fieldName | type | description | -| - | - | - | -| knownValues | (string \| number \| boolean \| null)[] | Array of known values for the feature properties. Each entry will result in a checkbox that allows filtering the appropriate features. Properties not listed will not be filterable and never be visible. The technical name will result in a localization key that can be configured on a per-client basis. | -| targetProperty | string | Target property to filter by. This is the name (that is, key) of a feature property. | -| selectAll | boolean? | If true, a checkbox is added to de/select all `knownValues` (above) at once. Defaults to `false`. | - -Example configuration: -```js -categories: [ - { - targetProperty: 'favouriteIceCream', - knownValues: ['chocolate', 'vanilla', 'strawberry'], - selectAll: true, - } -], -``` - -This example configuration will add these checkboxes: - -``` -▢ De-/select all -▢ Chocolate -▢ Vanilla -▢ Strawberry -``` - -##### filter.filterConfiguration.time - -| fieldName | type | description | -| - | - | - | -| targetProperty | string | Target property to filter by. | -| freeSelection | freeSelection? | Provide a more dynamic configurable from-to chooser for timeframes. | -| last | options[]? | Array of options to create for a `last` filter, e.g. "last 10 days". | -| next | options[]? | Array of options to create for a `next` filter, e.g. "next 10 day". | -| pattern | string? | Pattern the target string uses for its date formatting. Defaults to `'YYYY-MM-DD'`. Only 'Y', 'M', and 'D' are interpreted. All other characters are considered filler. Example: A feature has `"AA202001-04"` as property value that is supposed to convey a date. Setting `pattern` to `"--YYYYDD-MM"` would interpret it as the 1st of April, 2020. | - -Of all time restrictions, at most one can be selected at any time. The produced options are selectable by radio buttons. - -Example configuration: -```js -time: { - targetProperty: 'start', - pattern: 'YYYYMMDD', - last: [ - { - amounts: [7, 30], - }, - ], - next: [ - { - amounts: [7, 30], - }, - ], - freeSelection: { - now: 'until', - }, -} -``` - -###### filter.filterConfiguration.time.options - -| fieldName | type | description | -| - | - | - | -| amounts | number[] | Offer radio buttons for these amounts of `unit`. The rest of the current day is additionally included in the range. | -| unit | 'days'? | Implemented units. Currently, only `'days'` are supported. Defaults to `'days'`. | - -Example configuration: -```js -last: [ - { - amounts: [7, 30], - unit: 'days' - }, -], -``` - -This example configuration will add these radio buttons: - -``` -◯ Last 3 days -◯ Last 7 days -``` - -In `'days'` mode, the selections will always include full days, and additionally the current day. Due to this, the time frame of "last 7 days" is actually 8*24h long. This seems unexpected at first, but follows intuition – if it's Monday and a user filters to the "last seven days", they would expect to fully see last week's Monday, but also features from that day's morning. - -###### filter.filterConfiguration.time.freeSelection - -| fieldName | type | description | -| - | - | - | -| now | ('until' \| 'from')? | If set, only time points *until* now or *from* now are selectable, including the current time point. | -| unit | 'days'? | Implemented units. Currently, only `'days'` are supported. Defaults to `'days'`. | - -Example configuration: -```js -freeSelection: { - now: 'until', - unit: 'days' -}, -``` - -This example configuration will add this radio button: - -```js -◯ Choose time frame - From ▒▒▒▒▒▒▒▒▒▒▒ // clicking input opens a selector restricted *until* today - To ▇▇▇▇▇▇▇▇▇▇▇ // clicking input opens a selector restricted *until* today -``` diff --git a/vue2/packages/plugins/Filter/package.json b/vue2/packages/plugins/Filter/package.json deleted file mode 100644 index 38b94e4b4a..0000000000 --- a/vue2/packages/plugins/Filter/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@polar/plugin-filter", - "version": "3.0.0", - "description": "Filter plugin for POLAR that adds filter functionality to vector-data based layers.", - "keywords": [ - "OpenLayers", - "ol", - "POLAR", - "plugin", - "Filter" - ], - "license": "EUPL-1.2", - "type": "module", - "author": "Dataport AöR ", - "main": "src/index.ts", - "files": [ - "src/**/*", - "CHANGELOG.md" - ], - "peerDependencies": { - "@repositoryname/vuex-generators": "^1.1.2", - "ol": "^10.4.0", - "vue": "^2.6.14", - "vuex": "^3.6.2" - }, - "devDependencies": { - "@polar/lib-custom-types": "^2.0.0", - "@polar/lib-invisible-style": "^3.0.0" - } -} diff --git a/vue2/packages/plugins/Filter/src/components/ChooseTimeFrame.vue b/vue2/packages/plugins/Filter/src/components/ChooseTimeFrame.vue deleted file mode 100644 index e8fc666042..0000000000 --- a/vue2/packages/plugins/Filter/src/components/ChooseTimeFrame.vue +++ /dev/null @@ -1,84 +0,0 @@ - - - - - diff --git a/vue2/packages/plugins/Filter/src/components/Filter.vue b/vue2/packages/plugins/Filter/src/components/Filter.vue deleted file mode 100644 index 8cb16f701e..0000000000 --- a/vue2/packages/plugins/Filter/src/components/Filter.vue +++ /dev/null @@ -1,171 +0,0 @@ - - - - - diff --git a/vue2/packages/plugins/Filter/src/components/index.ts b/vue2/packages/plugins/Filter/src/components/index.ts deleted file mode 100644 index 86f96a7cfa..0000000000 --- a/vue2/packages/plugins/Filter/src/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as Filter } from './Filter.vue' diff --git a/vue2/packages/plugins/Filter/src/index.ts b/vue2/packages/plugins/Filter/src/index.ts deleted file mode 100644 index 69d629c439..0000000000 --- a/vue2/packages/plugins/Filter/src/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import Vue from 'vue' -import { FilterConfiguration } from '@polar/lib-custom-types' -import { Filter } from './components' -import locales from './locales' -import { makeStoreModule } from './store' - -export default (options: FilterConfiguration) => (instance: Vue) => - instance.$store.dispatch('addComponent', { - name: 'filter', - plugin: Filter, - locales, - storeModule: makeStoreModule(), - options, - }) diff --git a/vue2/packages/plugins/Filter/src/locales.ts b/vue2/packages/plugins/Filter/src/locales.ts deleted file mode 100644 index 6ded8088b7..0000000000 --- a/vue2/packages/plugins/Filter/src/locales.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Locale } from '@polar/lib-custom-types' - -export const resourcesDe = { - plugins: { - filter: { - category: { - deselectAll: 'Alle an-/abwählen', - }, - time: { - header: 'Zeitraum', - noRestriction: 'Keine Einschränkung', - last: { - days_one: 'Der letzte Tag', - days_other: 'Die letzten {{ count }} Tage', - }, - next: { - days_one: 'Der nächste Tag', - days_other: 'Die nächsten {{ count }} Tage', - }, - chooseTimeFrame: { - label: 'Zeitraum wählen', - info: 'Bitte wählen Sie ein Einzeldatum oder das erste und letzte Datum eines Zeitraums.', - }, - vuetify: { - aria: { - nextMonth: 'Nächsten Monat auswählen', - nextYear: 'Nächstes Jahr auswählen', - prevMonth: 'Vorherigen Monat auswählen', - prevYear: 'Vorheriges Jahr auswählen', - }, - }, - }, - }, - }, -} as const - -export const resourcesEn = { - plugins: { - filter: { - category: { - deselectAll: 'De-/select all', - }, - time: { - header: 'Time frame', - noRestriction: 'No restriction', - last: { - days_one: 'The last day', - days_other: 'The last {{ count }} days', - }, - next: { - days_one: 'The next day', - days_other: 'The next {{ count }} days', - }, - chooseTimeFrame: { - label: 'Choose time frame', - info: 'Please choose a singular date or the first and last date of a time frame.', - }, - vuetify: { - aria: { - nextMonth: 'Choose next month', - nextYear: 'Choose next year', - prevMonth: 'Choose previous month', - prevYear: 'Choose previous year', - }, - }, - }, - }, - }, -} as const - -const locales: Locale[] = [ - { - type: 'de', - resources: resourcesDe, - }, - { - type: 'en', - resources: resourcesEn, - }, -] - -export default locales diff --git a/vue2/packages/plugins/Filter/src/store/index.ts b/vue2/packages/plugins/Filter/src/store/index.ts deleted file mode 100644 index 79cd202f3b..0000000000 --- a/vue2/packages/plugins/Filter/src/store/index.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { - generateSimpleGetters, - generateSimpleMutations, -} from '@repositoryname/vuex-generators' -import { FilterConfiguration, PolarModule } from '@polar/lib-custom-types' -import ClusterSource from 'ol/source/Cluster' -import ChooseTimeFrame from '../components/ChooseTimeFrame.vue' -import { - FilterGetters, - FilterState, - KnownValue, - LayerId, - TargetProperty, -} from '../types' -import { - getLayer, - updateFeatureVisibility, -} from '../utils/updateFeatureVisibility' -import { setState } from '../utils/setState' -import { arrayOnlyContains } from '../utils/arrayOnlyContains' -import { parseTimeOption } from '../utils/parseTimeOption' - -const getInitialState = (): FilterState => ({ - category: {}, - time: {}, -}) - -export const makeStoreModule = () => { - const storeModule: PolarModule = { - namespaced: true, - state: getInitialState(), - actions: { - setupModule({ - getters: { filterConfiguration }, - rootGetters: { map }, - commit, - dispatch, - }): void { - if (Object.entries(filterConfiguration.layers).length === 0) { - console.error( - '@polar/plugin-filter: No configuration for parameter "layers" was found. Plugin will not be usable.' - ) - } - Object.entries(filterConfiguration.layers).forEach( - ([layerId, { categories, time }]) => { - if (categories) { - categories.forEach(({ targetProperty, knownValues }) => - commit('setupState', { - path: ['category', layerId, targetProperty], - value: knownValues.reduce((accumulator, current) => { - accumulator[current] = true - return accumulator - }, {}), - }) - ) - } - if (time) { - const { targetProperty, freeSelection, pattern } = time - commit('setupState', { - path: ['time', layerId], - value: { - targetProperty, - pattern: pattern || 'YYYY-MM-DD', - radioId: 0, - freeSelection: freeSelection ? [] : null, - }, - }) - } - // apply filter effects on layer loads - // @ts-expect-error | only layers with getSource allowed - let source = getLayer(map, layerId).getSource() - while (source instanceof ClusterSource) { - source = source.getSource() - } - source.on('featuresloadend', () => - dispatch('updateFeatureVisibility', layerId) - ) - // initially update visibility in case loading already took place - dispatch('updateFeatureVisibility', layerId) - } - ) - }, - toggleCategory( - { getters, commit, dispatch }, - payload: { - layerId: LayerId - targetProperty: TargetProperty - knownValue: KnownValue - } - ) { - const value = !getters.getActiveCategory(payload) - commit('setCategory', { ...payload, value }) - dispatch('updateFeatureVisibility', payload.layerId) - }, - toggleCategoryAll( - { getters, commit, dispatch }, - payload: { - layerId: LayerId - targetProperty: TargetProperty - } - ) { - // 'indeterminate' to false intentionally (something had to be decided) - const value = !getters.getActiveCategoryAll(payload) - const { layerId } = payload - // @ts-expect-error | this call only happens if structures exist (generation in .vue) - getters - .getCategories(layerId) - .find( - ({ targetProperty }) => targetProperty === payload.targetProperty - ) - .knownValues.forEach((knownValue) => { - commit('setCategory', { ...payload, knownValue, value }) - }) - dispatch('updateFeatureVisibility', payload.layerId) - }, - changeTimeRadio( - { commit, dispatch }, - payload: { layerId: LayerId; radioId: number } - ) { - commit('setTimeRadio', payload) - dispatch('updateFeatureVisibility', payload.layerId) - }, - changeFreeSelection( - { commit, dispatch }, - { layerId, freeSelection }: { layerId: LayerId; freeSelection: Date[] } - ) { - commit('setFreeSelection', { layerId, freeSelection }) - dispatch('updateFeatureVisibility', layerId) - }, - updateFeatureVisibility( - { state, rootGetters, getters }, - layerId: LayerId - ) { - updateFeatureVisibility({ - map: rootGetters.map, - layerId, - categories: getters.getCategories(layerId), - timeOptions: getters.getTimeOptions(layerId), - state: JSON.parse(JSON.stringify(state)), - }) - }, - }, - mutations: { - ...generateSimpleMutations(getInitialState()), - setCategory( - state, - { - layerId, - targetProperty, - knownValue, - value, - }: { - layerId: LayerId - targetProperty: TargetProperty - knownValue: KnownValue - value: boolean - } - ) { - state.category[layerId][targetProperty][knownValue] = value - }, - setupState(state, { path, value }: { path: string[]; value: boolean }) { - setState(state, path, value) - }, - setTimeRadio( - state, - { layerId, radioId }: { layerId: LayerId; radioId: number } - ) { - state.time[layerId].radioId = radioId - }, - setFreeSelection( - state, - { layerId, freeSelection }: { layerId: LayerId; freeSelection: Date[] } - ) { - state.time[layerId].freeSelection = freeSelection - }, - }, - getters: { - ...generateSimpleGetters(getInitialState()), - filterConfiguration(_, __, ___, rootGetters): FilterConfiguration { - return rootGetters.configuration?.filter || { layers: {} } - }, - getActiveCategory: - (state) => - ({ layerId, targetProperty, knownValue }) => - state.category[layerId][targetProperty][knownValue], - getActiveCategoryAll: - (state) => - ({ layerId, targetProperty }) => { - const allValues = Object.values( - state.category[layerId][targetProperty] - ) - if (arrayOnlyContains(allValues, true)) { - return true - } - if (arrayOnlyContains(allValues, false)) { - return false - } - return 'indeterminate' - }, - getActiveTime: - (state) => - ({ layerId }) => - state.time[layerId].radioId, - getCategories: (_, getters) => (layerId) => - getters.filterConfiguration.layers[layerId]?.categories || [], - getTimeConfig: - (_, { filterConfiguration }) => - (layerId) => - filterConfiguration.layers[layerId]?.time || null, - getTimeOptions: - (_, { getTimeConfig }) => - (layerId) => { - const timeConfig = getTimeConfig(layerId) - if (!timeConfig) { - return [] - } - return [ - ...(timeConfig.last || []).map(parseTimeOption('last')).flat(1), - ...(timeConfig.next || []).map(parseTimeOption('next')).flat(1), - ...(!timeConfig.freeSelection - ? [] - : [ - { - label: 'plugins.filter.time.chooseTimeFrame.label', - component: ChooseTimeFrame, - amount: null, - unit: timeConfig.freeSelection.unit || 'days', - now: timeConfig.freeSelection.now, - type: 'freeSelection', - }, - ]), - ] - }, - getFreeSelection: (state) => (layerId: string) => - state.time[layerId].freeSelection, - }, - } - - return storeModule -} diff --git a/vue2/packages/plugins/Filter/src/types.ts b/vue2/packages/plugins/Filter/src/types.ts deleted file mode 100644 index 1c00f56140..0000000000 --- a/vue2/packages/plugins/Filter/src/types.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { FilterConfiguration } from '@polar/lib-custom-types' -import Vue from 'vue' - -export type LayerId = string -export type TargetProperty = string -export type KnownValue = string -export type DatePattern = string -export interface TimeLayerEntry { - targetProperty: TargetProperty - pattern: DatePattern - radioId: number - freeSelection: Date[] -} -export interface TimeOption { - label: string - component: Vue | null - amount: number | null - unit: 'days' - now: 'until' | 'from' | null - type: 'last' | 'next' | 'freeSelection' -} - -export interface FilterState { - category: Record>> - time: Record -} - -export interface FilterGetters extends FilterState { - filterConfiguration: FilterConfiguration - getActiveCategory: ({ - layerId, - targetProperty, - knownValue, - }: { - layerId: LayerId - targetProperty: TargetProperty - knownValue: KnownValue - }) => boolean - getActiveCategoryAll: ({ - layerId, - targetProperty, - }: { - layerId: LayerId - targetProperty: TargetProperty - }) => boolean | 'indeterminate' - getActiveTime: ({ layerId }: { layerId: LayerId }) => number - getCategories: ( - layerId: LayerId - ) => FilterConfiguration['layers'][string]['categories'] - getTimeConfig: ( - layerId: LayerId - ) => FilterConfiguration['layers'][string]['time'] - getTimeOptions: (layerId: LayerId) => TimeOption[] - getFreeSelection: (layerId: LayerId) => TimeLayerEntry['freeSelection'] -} diff --git a/vue2/packages/plugins/Filter/src/utils/arrayOnlyContains.ts b/vue2/packages/plugins/Filter/src/utils/arrayOnlyContains.ts deleted file mode 100644 index 126c612772..0000000000 --- a/vue2/packages/plugins/Filter/src/utils/arrayOnlyContains.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const arrayOnlyContains = (array: unknown[], value: unknown) => - array.reduce((accumulator, current) => accumulator && current === value, true) diff --git a/vue2/packages/plugins/Filter/src/utils/parseTimeOption.ts b/vue2/packages/plugins/Filter/src/utils/parseTimeOption.ts deleted file mode 100644 index cf81a54cd3..0000000000 --- a/vue2/packages/plugins/Filter/src/utils/parseTimeOption.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { FilterConfigurationTimeOption } from '@polar/lib-custom-types' -import { TimeOption } from '../types' - -export const parseTimeOption = - (timeDirection: 'last' | 'next') => - (config: FilterConfigurationTimeOption): TimeOption[] => - config.amounts.map((amount) => ({ - label: `plugins.filter.time.${timeDirection}.${config.unit || 'days'}`, - component: null, - amount, - unit: config.unit || 'days', - now: null, - type: timeDirection, - })) diff --git a/vue2/packages/plugins/Filter/src/utils/setState.ts b/vue2/packages/plugins/Filter/src/utils/setState.ts deleted file mode 100644 index 2b0bcfb24d..0000000000 --- a/vue2/packages/plugins/Filter/src/utils/setState.ts +++ /dev/null @@ -1,13 +0,0 @@ -import Vue from 'vue' - -export const setState = (state: object, path: string[], value: unknown) => { - if (path.length === 1) { - Vue.set(state, path[0], value) - return - } - const [step, ...restPath] = path - if (!state[step]) { - Vue.set(state, step, {}) - } - setState(state[step], restPath, value) -} diff --git a/vue2/packages/plugins/Filter/src/utils/updateFeatureVisibility.ts b/vue2/packages/plugins/Filter/src/utils/updateFeatureVisibility.ts deleted file mode 100644 index b83efdcdda..0000000000 --- a/vue2/packages/plugins/Filter/src/utils/updateFeatureVisibility.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { Feature, Map } from 'ol' -import { InvisibleStyle } from '@polar/lib-invisible-style' -import { FilterConfiguration } from '@polar/lib-custom-types' -import ClusterSource from 'ol/source/Cluster' -import BaseLayer from 'ol/layer/Base' -import { DatePattern, FilterState, LayerId, TimeOption } from '../types' - -const doesFeaturePassCategoryFilter = ( - categories: FilterConfiguration['layers'][string]['categories'], - category: FilterState['category'], - feature: Feature, - layerId: LayerId -) => - (categories || []).every( - ({ targetProperty }) => - category[layerId][targetProperty][feature.get(targetProperty)] - ) - -const getFreeSelectionLimits = (clickLimits: Date[]): Date[] => { - const limits = clickLimits - .slice(0, 2) - .sort() - .map((x) => new Date(x)) - if (!limits[1]) { - limits[1] = new Date(limits[0]) - } - return limits -} - -const getDateFromValue = ( - propertyValue: string, - pattern: DatePattern -): Date => { - const yearIndices: number[] = [] - const monthIndices: number[] = [] - const dayIndices: number[] = [] - const indexLookup = { - Y: yearIndices, - M: monthIndices, - D: dayIndices, - } - ;[...pattern].forEach((letter, index) => indexLookup[letter]?.push?.(index)) - function getFromPropertyValue(index: number) { - return propertyValue[index] - } - return new Date( - Number(yearIndices.map(getFromPropertyValue).join('')), - Number(monthIndices.map(getFromPropertyValue).join('')) - 1, - Number(dayIndices.map(getFromPropertyValue).join('')) - ) -} - -const doesFeaturePassTimeFilter = ( - layerId: LayerId, - time: FilterState['time'], - timeOptions: TimeOption[], - feature: Feature -): boolean => { - // radioId=0 means 'no restriction' - if (!time || !time[layerId] || time[layerId].radioId === 0) { - return true - } - const { targetProperty, radioId, pattern } = time[layerId] - const selectedTimeFilter = timeOptions[radioId - 1] - const propertyValue = feature.get(targetProperty) - const featureDate = getDateFromValue(propertyValue, pattern) - const { type, amount, unit } = selectedTimeFilter - - // only unit 'days' currently supported - const unitMilliseconds = { - days: 24 * 60 * 60 * 1000, - }[unit || 'days'] - const limits: Date[] = [] - - if (type === 'freeSelection') { - const chosenLimits = time[layerId].freeSelection - if (chosenLimits.length === 0) { - // no limits selected? feature passes automatically - return true - } - ;[limits[0], limits[1]] = getFreeSelectionLimits(chosenLimits) - } else { - limits[type === 'last' ? 0 : 1] = new Date( - // @ts-expect-error | amount is number in last/next case - Date.now() - amount * unitMilliseconds - ) - limits[type === 'last' ? 1 : 0] = new Date(Date.now()) - } - limits[0].setHours(0, 0, 0, 0) - limits[1].setHours(23, 59, 59, 999) - - return limits[0] <= featureDate && featureDate <= limits[1] -} - -const doesFeaturePassFilter = ( - feature: Feature, - { category, time }: FilterState, - categories: FilterConfiguration['layers'][string]['categories'], - layerId: LayerId, - timeOptions: TimeOption[] -): boolean => { - if ( - category && - !doesFeaturePassCategoryFilter(categories, category, feature, layerId) - ) { - return false - } - - return Boolean( - time && doesFeaturePassTimeFilter(layerId, time, timeOptions, feature) - ) -} - -export const getLayer = (map: Map, layerId: LayerId): BaseLayer => { - const layer = map - .getLayers() - .getArray() - .find((layer) => layer.get('id') === layerId) - if (!layer) { - throw new Error( - `Layer ${layerId} undefined in Filter.utils.updateFeatureVisibility.` - ) - } - return layer -} - -export const updateFeatureVisibility = ({ - map, - layerId, - state, - categories, - timeOptions, -}: { - map: Map - layerId: LayerId - state: FilterState - categories: FilterConfiguration['layers'][string]['categories'] - timeOptions: TimeOption[] -}) => { - const layer = getLayer(map, layerId) - - // @ts-expect-error | only layers with getSource allowed - let source = layer.getSource() - while (source instanceof ClusterSource) { - source = source.getSource() - } - const updateFeatures = source - .getFeatures() - .map((feature) => feature.get('features') || [feature]) - .flat(1) - // only update finally to prevent overly recalculating clusters - source.clear() - - updateFeatures.forEach((feature) => { - const targetStyle = doesFeaturePassFilter( - feature, - state, - categories, - layerId, - timeOptions - ) - ? null - : InvisibleStyle - if (feature.getStyle() !== targetStyle) { - feature.setStyle(targetStyle) - } - }) - source.addFeatures(updateFeatures) -} diff --git a/vue2/packages/plugins/Filter/vite.config.js b/vue2/packages/plugins/Filter/vite.config.js deleted file mode 100644 index 0d2ec38a15..0000000000 --- a/vue2/packages/plugins/Filter/vite.config.js +++ /dev/null @@ -1,3 +0,0 @@ -import { getCodeConfig } from '../../../viteConfigs' - -export default getCodeConfig() From cd23d05978de7330977fe35ce4176b6744dcf72c Mon Sep 17 00:00:00 2001 From: Pascal Roehling Date: Fri, 16 Jan 2026 18:14:07 +0100 Subject: [PATCH 05/41] fix(filter): use correct import for export store --- src/plugins/filter/components/FilterTime.ce.vue | 2 +- src/plugins/filter/components/FilterUI.spec.ts | 2 +- src/plugins/filter/store.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plugins/filter/components/FilterTime.ce.vue b/src/plugins/filter/components/FilterTime.ce.vue index fd7a490236..7b2efe7a65 100644 --- a/src/plugins/filter/components/FilterTime.ce.vue +++ b/src/plugins/filter/components/FilterTime.ce.vue @@ -23,7 +23,7 @@ import type { Icon } from '@/core' import KernBlockButtonRadioGroup from '@/components/kern/KernBlockButtonRadioGroup.ce.vue' import KernDateRangePicker from '@/components/kern/KernDateRangePicker.ce.vue' -import { useCoreStore } from '@/core/stores/export' +import { useCoreStore } from '@/core/stores' import { useFilterStore } from '../store' import { type FilterConfiguration } from '../types' diff --git a/src/plugins/filter/components/FilterUI.spec.ts b/src/plugins/filter/components/FilterUI.spec.ts index b154003769..0fdec840f8 100644 --- a/src/plugins/filter/components/FilterUI.spec.ts +++ b/src/plugins/filter/components/FilterUI.spec.ts @@ -3,7 +3,7 @@ import { mount, VueWrapper } from '@vue/test-utils' import { expect, test as _test, vi, assert } from 'vitest' import { nextTick } from 'vue' -import { useCoreStore } from '@/core/stores/export' +import { useCoreStore } from '@/core/stores' import { mockedT } from '@/test/utils/mockI18n' import { useFilterStore } from '../store' diff --git a/src/plugins/filter/store.ts b/src/plugins/filter/store.ts index ae2a0e3ec3..dcff2249ee 100644 --- a/src/plugins/filter/store.ts +++ b/src/plugins/filter/store.ts @@ -7,7 +7,7 @@ import { acceptHMRUpdate, defineStore } from 'pinia' import { computed, ref, watch } from 'vue' -import { useCoreStore } from '@/core/stores/export' +import { useCoreStore } from '@/core/stores' import { PluginId, type FilterPluginOptions, type FilterState } from './types' import { getVectorSource } from './utils/getVectorSource' From 25d11ec52b0b14ef05e9ab62e94fccaf7c371cc8 Mon Sep 17 00:00:00 2001 From: Hendrik Oenings Date: Fri, 23 Jan 2026 10:42:17 +0100 Subject: [PATCH 06/41] style: apply new linting rule for self-closing --- src/components/kern/KernBlockButton.ce.vue | 2 +- src/components/kern/KernBlockButtonCheckbox.ce.vue | 2 +- src/components/kern/KernBlockButtonRadioGroup.ce.vue | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/kern/KernBlockButton.ce.vue b/src/components/kern/KernBlockButton.ce.vue index 77b5e9e6c2..837d0292dd 100644 --- a/src/components/kern/KernBlockButton.ce.vue +++ b/src/components/kern/KernBlockButton.ce.vue @@ -7,7 +7,7 @@ v-if="props.icon" :class="{ 'kern-icon': true, [props.icon]: true }" aria-hidden="true" - > + /> {{ props.label }} diff --git a/src/components/kern/KernBlockButtonCheckbox.ce.vue b/src/components/kern/KernBlockButtonCheckbox.ce.vue index 266c0e99fb..cd21b422f0 100644 --- a/src/components/kern/KernBlockButtonCheckbox.ce.vue +++ b/src/components/kern/KernBlockButtonCheckbox.ce.vue @@ -5,7 +5,7 @@ v-if="props.icon" :class="{ 'kern-icon': true, [props.icon]: true }" aria-hidden="true" - > + /> {{ props.label }} diff --git a/src/components/kern/KernBlockButtonRadioGroup.ce.vue b/src/components/kern/KernBlockButtonRadioGroup.ce.vue index ad975a4dca..5e127dcf5b 100644 --- a/src/components/kern/KernBlockButtonRadioGroup.ce.vue +++ b/src/components/kern/KernBlockButtonRadioGroup.ce.vue @@ -15,7 +15,7 @@ v-if="item.icon" :class="{ 'kern-icon': true, [item.icon]: true }" aria-hidden="true" - > + /> {{ item.label }} From e973288b3dfa65a9788a7aea4a1f2b1f691c3756 Mon Sep 17 00:00:00 2001 From: Hendrik Oenings Date: Fri, 30 Jan 2026 16:45:38 +0100 Subject: [PATCH 07/41] fix(filter): remove bottom padding that was mitigation for a fixed bug --- src/plugins/filter/components/FilterUI.ce.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/src/plugins/filter/components/FilterUI.ce.vue b/src/plugins/filter/components/FilterUI.ce.vue index 45e5c025e2..43a47fa585 100644 --- a/src/plugins/filter/components/FilterUI.ce.vue +++ b/src/plugins/filter/components/FilterUI.ce.vue @@ -36,7 +36,6 @@ watch( diff --git a/src/components/kern/KernBlockButtonRadioGroup.ce.vue b/src/components/kern/KernBlockButtonRadioGroup.ce.vue index 5e127dcf5b..4258b11bf1 100644 --- a/src/components/kern/KernBlockButtonRadioGroup.ce.vue +++ b/src/components/kern/KernBlockButtonRadioGroup.ce.vue @@ -49,12 +49,15 @@ const id = useId() } } +label { + border: var(--kern-metric-border-width-default) solid transparent; +} + input[type='radio'] { display: none; &:checked + label { - border: var(--kern-metric-border-width-default) solid - var(--kern-color-action-default); + border-color: var(--kern-color-action-default); } } From 36777dbbbad3171592fb8680f121a5c0134277a2 Mon Sep 17 00:00:00 2001 From: Hendrik Oenings Date: Tue, 3 Feb 2026 20:41:56 +0100 Subject: [PATCH 17/41] chore(filter): add missing store files --- src/plugins/filter/stores/main.ts | 100 ++++++++++++++ src/plugins/filter/stores/time.ts | 217 ++++++++++++++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 src/plugins/filter/stores/main.ts create mode 100644 src/plugins/filter/stores/time.ts diff --git a/src/plugins/filter/stores/main.ts b/src/plugins/filter/stores/main.ts new file mode 100644 index 0000000000..9f254c9a4f --- /dev/null +++ b/src/plugins/filter/stores/main.ts @@ -0,0 +1,100 @@ +import { acceptHMRUpdate, defineStore } from 'pinia' +import { computed, ref, watch } from 'vue' + +import { useCoreStore } from '@/core/stores' + +import { + PluginId, + type FilterConfiguration, + type FilterPluginOptions, + type FilterState, +} from '../types' + +export const useFilterMainStore = defineStore('plugins/filter/main', () => { + const coreStore = useCoreStore() + + const configuration = computed( + () => + (coreStore.configuration[PluginId] ?? { + layers: {}, + }) as FilterPluginOptions + ) + + const state = ref>({}) + + const layers = computed(() => + Object.entries(configuration.value.layers).map( + ([layerId, filterConfiguration]) => ({ + layerId, + layerConfiguration: coreStore.getLayerConfiguration(layerId), + filterConfiguration, + }) + ) + ) + + const selectedLayerId = ref(null) + watch(selectedLayerId, (newLayerId) => { + if (newLayerId === null || state.value[newLayerId]) { + return + } + state.value[newLayerId] = {} + }) + watch( + () => configuration.value.layers, + (layers) => { + selectedLayerId.value = Object.keys(layers)[0] || '' + }, + { immediate: true, deep: true } + ) + + const selectedLayer = computed( + () => + layers.value.find((layer) => layer.layerId === selectedLayerId.value) ?? + null + ) + + const selectedLayerConfiguration = computed( + () => + (selectedLayerId.value + ? configuration.value.layers[selectedLayerId.value] + : {}) as FilterConfiguration + ) + + const selectedLayerState = computed( + () => + (selectedLayerId.value + ? state.value[selectedLayerId.value] + : null) as FilterState | null + ) + + const selectedLayerHasTimeFilter = computed( + () => + selectedLayerConfiguration.value.time?.last || + selectedLayerConfiguration.value.time?.next || + selectedLayerConfiguration.value.time?.freeSelection + ) + + const filteredLayers = computed(() => + coreStore.map + .getAllLayers() + .filter((layer) => + Object.keys(configuration.value.layers).includes(layer.get('id')) + ) + ) + + return { + configuration, + state, + layers, + selectedLayerId, + selectedLayer, + selectedLayerConfiguration, + selectedLayerState, + selectedLayerHasTimeFilter, + filteredLayers, + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useFilterMainStore, import.meta.hot)) +} diff --git a/src/plugins/filter/stores/time.ts b/src/plugins/filter/stores/time.ts new file mode 100644 index 0000000000..0e457ad6c3 --- /dev/null +++ b/src/plugins/filter/stores/time.ts @@ -0,0 +1,217 @@ +import { t } from 'i18next' +import { acceptHMRUpdate, defineStore } from 'pinia' +import { computed, ref } from 'vue' + +import type { Icon } from '@/core' + +import { useCoreStore } from '@/core/stores' + +import { PluginId, type Time } from '../types' +import { useFilterMainStore } from './main' + +export const useFilterTimeStore = defineStore('plugins/filter/time', () => { + const minDate = new Date(-8640000000000000) + const maxDate = new Date(8640000000000000) + + const coreStore = useCoreStore() + const filterMainStore = useFilterMainStore() + + const configuration = computed( + () => filterMainStore.selectedLayerConfiguration.time || ({} as Time) + ) + const state = computed( + () => filterMainStore.selectedLayerState?.timeSpan || null + ) + + const targetProperty = computed( + () => configuration.value.targetProperty || '' + ) + const pattern = computed(() => configuration.value.pattern || 'YYYY-MM-DD') + const timeState = computed( + () => + state.value?.[targetProperty.value] ?? { + from: minDate, + until: maxDate, + pattern: pattern.value, + } + ) + + const customModelStart = computed({ + get: () => + timeState.value.from.getTime() === minDate.getTime() + ? null + : timeState.value.from, + set: (value) => { + if (!filterMainStore.selectedLayerState) { + return + } + const layerState = filterMainStore.selectedLayerState + const spanState = (layerState.timeSpan ??= {}) + const propState = (spanState[targetProperty.value] ??= { + from: minDate, + until: maxDate, + pattern: pattern.value, + }) + propState.from = value || minDate + }, + }) + const customModelEnd = computed({ + get: () => { + if (timeState.value.until.getTime() === maxDate.getTime()) { + return null + } + const value = new Date(timeState.value.until) + value.setDate(value.getDate() - 1) + return value + }, + set: (value) => { + if (!filterMainStore.selectedLayerState) { + return + } + const layerState = filterMainStore.selectedLayerState + const spanState = (layerState.timeSpan ??= {}) + const propState = (spanState[targetProperty.value] ??= { + from: minDate, + until: maxDate, + pattern: pattern.value, + }) + if (value === null) { + propState.until = maxDate + return + } + const newValue = new Date(value) + newValue.setDate(newValue.getDate() + 1) + propState.until = newValue + }, + }) + + function checkDate(offset: number, date: Date) { + const referenceDate = new Date() + referenceDate.setDate(referenceDate.getDate() + offset) + return date.toDateString() === referenceDate.toDateString() + } + + const isCustom = ref(false) + const model = computed< + 'all' | 'custom' | `last-${string}` | `next-${string}` + >({ + get: () => { + if (isCustom.value) { + return 'custom' + } + if (customModelStart.value === null && customModelEnd.value === null) { + return 'all' + } + if (customModelStart.value && customModelEnd.value) { + for (const offset of filterMainStore.selectedLayerConfiguration.time + ?.last || []) { + if ( + checkDate(0, customModelEnd.value) && + checkDate(-offset, customModelStart.value) + ) { + return `last-${offset}` as `last-${string}` + } + } + for (const offset of filterMainStore.selectedLayerConfiguration.time + ?.next || []) { + if ( + checkDate(0, customModelStart.value) && + checkDate(offset, customModelEnd.value) + ) { + return `next-${offset}` as `next-${string}` + } + } + } + return 'custom' + }, + set: (value) => { + if (value === 'all' || value === 'custom') { + customModelStart.value = null + customModelEnd.value = null + } else if (value.startsWith('last-')) { + const offset = Number(value.substring(5)) + const now = new Date() + const from = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate() - offset + ) + const until = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + customModelStart.value = from + customModelEnd.value = until + } else if (value.startsWith('next-')) { + const offset = Number(value.substring(5)) + const now = new Date() + const from = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const until = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate() + offset + ) + until.setDate(until.getDate() + offset) + customModelStart.value = from + customModelEnd.value = until + } + isCustom.value = value === 'custom' + }, + }) + + const timeConstraints = computed( + () => + ({ + from: { + min: new Date(), + }, + until: { + max: new Date(), + }, + })[ + filterMainStore.selectedLayerConfiguration.time?.freeSelection || '' + ] || {} + ) + + const items = computed(() => { + // This reactive value needs to recompute on language changes. + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + coreStore.language + + return [ + { + value: 'all', + label: t(($) => $.time.noRestriction, { ns: PluginId }), + icon: 'kern-icon--all-inclusive', + }, + ...(configuration.value.last?.map((offset) => ({ + value: `last-${offset}`, + label: t(($) => $.time.last, { ns: PluginId, count: offset }), + icon: 'kern-icon--history' as Icon, + })) || []), + ...(configuration.value.next?.map((offset) => ({ + value: `next-${offset}`, + label: t(($) => $.time.next, { ns: PluginId, count: offset }), + icon: 'kern-icon--timeline' as Icon, + })) || []), + ...(configuration.value.freeSelection + ? [ + { + value: 'custom', + label: t(($) => $.time.chooseTimeFrame, { ns: PluginId }), + icon: 'kern-icon--calendar-month' as Icon, + }, + ] + : []), + ] satisfies { value: string; label: string; icon: Icon }[] + }) + + return { + model, + customModelStart, + customModelEnd, + timeConstraints, + items, + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useFilterTimeStore, import.meta.hot)) +} From ac723a0b3ecfa55dbfd86afa63b5fdc177418052 Mon Sep 17 00:00:00 2001 From: Hendrik Oenings Date: Tue, 3 Feb 2026 21:41:45 +0100 Subject: [PATCH 18/41] refactor: show focus for KernBlockButtonX --- src/components/kern/KernBlockButtonCheckbox.ce.vue | 12 +++++++++++- src/components/kern/KernBlockButtonRadioGroup.ce.vue | 12 +++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/components/kern/KernBlockButtonCheckbox.ce.vue b/src/components/kern/KernBlockButtonCheckbox.ce.vue index 9b9732b45e..88cc461d35 100644 --- a/src/components/kern/KernBlockButtonCheckbox.ce.vue +++ b/src/components/kern/KernBlockButtonCheckbox.ce.vue @@ -40,7 +40,17 @@ label { } input[type='checkbox'] { - display: none; + position: absolute; + height: 0; + + &:focus + label { + padding: var(--kern-metric-space-none) var(--kern-metric-space-default); + border-radius: var(--kern-metric-border-radius-default); + box-shadow: + 0 0 0 2px var(--kern-color-action-on-default), + 0 0 0 4px var(--kern-color-action-focus-border-inside), + 0 0 0 6px var(--kern-color-action-focus-border-outside); + } &:checked + label { border-color: var(--kern-color-action-default); diff --git a/src/components/kern/KernBlockButtonRadioGroup.ce.vue b/src/components/kern/KernBlockButtonRadioGroup.ce.vue index 4258b11bf1..864b8a5a7e 100644 --- a/src/components/kern/KernBlockButtonRadioGroup.ce.vue +++ b/src/components/kern/KernBlockButtonRadioGroup.ce.vue @@ -54,7 +54,17 @@ label { } input[type='radio'] { - display: none; + position: absolute; + height: 0; + + &:focus + label { + padding: var(--kern-metric-space-none) var(--kern-metric-space-default); + border-radius: var(--kern-metric-border-radius-default); + box-shadow: + 0 0 0 2px var(--kern-color-action-on-default), + 0 0 0 4px var(--kern-color-action-focus-border-inside), + 0 0 0 6px var(--kern-color-action-focus-border-outside); + } &:checked + label { border-color: var(--kern-color-action-default); From 715c37b0ec185084ae8ddf505e9ab9e511d66ad2 Mon Sep 17 00:00:00 2001 From: Hendrik Oenings Date: Tue, 3 Feb 2026 21:49:15 +0100 Subject: [PATCH 19/41] chore: remove now unused @ts-expect-error directives --- src/core/utils/export/store.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/core/utils/export/store.ts b/src/core/utils/export/store.ts index 119d491981..72e9969cba 100644 --- a/src/core/utils/export/store.ts +++ b/src/core/utils/export/store.ts @@ -59,7 +59,6 @@ export function subscribe( options?: WatchOptions ) { const store = getStore(map, storeName) - // @ts-expect-error | Parameter name is checked, but TS does not infer this return watch(() => store[parameterName], callback, { immediate: true, ...options, @@ -81,6 +80,5 @@ export function updateState( payload: unknown ) { const store = getStore(map, storeName) - // @ts-expect-error | Parameter name is checked, but TS does not infer this store[parameterName] = payload } From 32888042609f8ca860b492fb3b4dd411e03eccba Mon Sep 17 00:00:00 2001 From: Hendrik Oenings Date: Fri, 6 Feb 2026 10:41:51 +0100 Subject: [PATCH 20/41] feat(filter): allow multiple filter categories for a targetProperty --- src/plugins/filter/components/FilterCategory.ce.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/filter/components/FilterCategory.ce.vue b/src/plugins/filter/components/FilterCategory.ce.vue index 269bde9045..867c4a379f 100644 --- a/src/plugins/filter/components/FilterCategory.ce.vue +++ b/src/plugins/filter/components/FilterCategory.ce.vue @@ -1,7 +1,7 @@