Skip to content

Commit

Permalink
wip: Provide a selection manager component (#1098)
Browse files Browse the repository at this point in the history
  • Loading branch information
claustres committed Feb 13, 2025
1 parent 7b60eec commit 8f68f70
Show file tree
Hide file tree
Showing 10 changed files with 406 additions and 41 deletions.
14 changes: 13 additions & 1 deletion core/client/composables/selection.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import { useStore } from './store.js'
export function useSelection (name, options = {}) {
// Item comparator
const comparator = options.matches || _.matches
// data

// selection store
const { store, set, get, has } = useStore(`selections.${name}`)

// functions
// Single selection will rely on the lastly selected item only
// Multiple selection mode will rely on all items
function clearSelection () {
if (!isSelectionEnabled()) return
// Do not force an update if not required
// We set a new array so that deeply watch is not required
if (hasSelectedItem()) set('items', [])
Expand All @@ -28,13 +29,20 @@ export function useSelection (name, options = {}) {
function isMultipleSelectionMode () {
return get('mode') !== 'single'
}
function setSelectionEnabled (enabled = true) {
return set('enabled', enabled)
}
function isSelectionEnabled () {
return get('enabled')
}
function getSelectionFilter () {
return get('filter')
}
function setSelectionFilter (filter) {
return set('filter', filter)
}
function selectItem (item) {
if (!isSelectionEnabled()) return
const filter = getSelectionFilter()
if (filter && !filter(item)) return
const items = get('items')
Expand All @@ -43,6 +51,7 @@ export function useSelection (name, options = {}) {
if (!selected) set('items', items.concat([item]))
}
function unselectItem (item) {
if (!isSelectionEnabled()) return
const items = get('items')
// We set a new array so that deeply watch is not required
_.remove(items, comparator(item))
Expand All @@ -66,6 +75,7 @@ export function useSelection (name, options = {}) {
// We set a new array so that deeply watch is not required
set('items', [])
set('mode', 'single')
set('enabled', true)
}

// expose
Expand All @@ -76,6 +86,8 @@ export function useSelection (name, options = {}) {
setSelectionMode,
isSingleSelectionMode,
isMultipleSelectionMode,
setSelectionEnabled,
isSelectionEnabled,
getSelectionFilter,
setSelectionFilter,
selectItem,
Expand Down
2 changes: 1 addition & 1 deletion map/client/cesium/utils/utils.style.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ function processStyle (style, feature, options, mappings) {
// visibility attribute can be used to hide individual features
// visibility is true by default but can also be a string when it's
// a result of a lodash string template evaluation
let visibility = _.get(style, `style.${type}.visibility`, true)
let visibility = _.get(style, `style.${type}.visibility`, _.get(style, 'style.visibility', true))
if (typeof visibility === 'string') visibility = visibility === 'true'
// The 'kdk-hidden-features' pane is created when the leaflet map is initialized
// if (!visibility) _.set(style, `style.${type}.pane`, 'kdk-hidden-features')
Expand Down
61 changes: 61 additions & 0 deletions map/client/components/selection/KFeaturesSelection.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<template>
<div class="fit column no-wrap">
<div v-if="hasItems">
<template v-for="(item, index) in items" :key="item._id">
<div :class="rendererClass">
<component
:id="item._id"
:item="item"
:is="itemRenderer"
v-bind="renderer"
/>
</div>
</template>
</div>
<div v-else class="row justify-center q-pa-sm">
<KStamp
icon="las la-exclamation-circle"
icon-size="sm"
:text="$t('KFeaturesSelection.NONE_SELECTED')"
direction="horizontal"
/>
</div>
</div>
</template>

<script setup>
import { computed } from 'vue'
import { loadComponent } from '../../../../core/client/utils'
import KStamp from '../../../../core/client/components/KStamp.vue'
import { useCurrentActivity } from '../../composables/activity.js'
// Props
const props = defineProps({
renderer: {
type: Object,
default: () => {
return {
component: 'selection/KSelectedLayerFeatures'
}
}
}
})
// Data
const { getSelectedFeaturesByLayer } = useCurrentActivity()
// Computed
const itemRenderer = computed(() => {
return loadComponent(props.renderer.component)
})
const rendererClass = computed(() => {
return props.renderer.class || 'q-pa-sm col-12 col-sm-6 col-md-4 col-lg-3'
})
const items = computed(() => {
return getSelectedFeaturesByLayer()
})
const hasItems = computed(() => {
return Object.keys(items.value).length > 0
})
</script>
230 changes: 230 additions & 0 deletions map/client/components/selection/KSelectedLayerFeatures.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
<template>
<q-tree
:nodes="[root]"
node-key="_id"
label-key="label"
children-key="features"
default-expand-all
dense
>
<template v-slot:default-header="prop">
<!-- Layer rendering -->
<q-icon v-if="prop.node.icon" :name="prop.node.icon"/>
<KLayerItem v-if="prop.node.name"
v-bind="$props"
:togglable="false"
:layer="root"
/>
<!-- Features rendering -->
<div v-else class="row fit items-center q-pl-md q-pr-sm no-wrap">
<div :class="{
'text-primary': root.isVisible,
'text-grey-6': root.isDisabled || !root.isVisible
}"
>
<span v-html="prop.node.label || prop.node._id" />
</div>
<q-space />
<!-- Features actions -->
<KPanel
:id="`${prop.node.label}-feature-actions`"
:content="featureActions"
:context="prop.node"
/>
</div>
</template>
</q-tree>
</template>

<script setup>
import _ from 'lodash'
import { Dialog } from 'quasar'
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import bbox from '@turf/bbox'
import { Store, i18n } from '../../../../core/client'
import KLayerItem from '../catalog/KLayerItem.vue'
import { useCurrentActivity } from '../../composables/activity.js'
import { getFeatureId, getFeatureLabel } from '../../utils/utils.js'
import { isLayerDataEditable } from '../../utils/utils.layers.js'
// Props
const props = defineProps({
item: {
type: Object,
default: () => {}
}
})
// Data
const route = useRoute()
const router = useRouter()
const { CurrentActivity } = useCurrentActivity()
const editedFeatures = ref([])
// Computed
const layerActions = computed(() => {
return [{
id: 'layer-actions',
component: 'menu/KMenu',
dropdownIcon: 'las la-ellipsis-v',
actionRenderer: 'item',
propagate: false,
dense: true,
content: [{
id: 'zoom-to-selected-features',
label: 'KSelectedLayerFeatures.ZOOM_TO_FEATURES_LABEL',
icon: 'las la-search-location',
handler: zoomToSelectedFeatures
}, {
id: 'edit-selected-features',
label: 'KSelectedLayerFeatures.EDIT_FEATURES_LABEL',
icon: 'las la-edit',
handler: editSelectedFeatures,
visible: isLayerDataEditable(props.item.layer)
}, {
id: 'remove-selected-features',
label: 'KSelectedLayerFeatures.REMOVE_FEATURES_LABEL',
icon: 'las la-trash',
handler: removeSelectedFeatures,
visible: isLayerDataEditable(props.item.layer)
}]
}]
})
const featureActions = computed(() => {
return [{
id: 'feature-actions',
component: 'menu/KMenu',
dropdownIcon: 'las la-ellipsis-v',
actionRenderer: 'item',
propagate: false,
dense: true,
content: [{
id: 'zoom-to-selected-feature',
label: 'KSelectedLayerFeatures.ZOOM_TO_FEATURE_LABEL',
icon: 'las la-search-location',
handler: zoomToSelectedFeature
}, {
id: 'edit-selected-feature',
label: 'KSelectedLayerFeatures.EDIT_FEATURE_LABEL',
icon: 'las la-edit',
handler: editSelectedFeature,
visible: isLayerDataEditable(props.item.layer)
}, {
id: 'edit-selected-feature-properties',
label: 'KSelectedLayerFeatures.EDIT_FEATURE_PROPERTIES_LABEL',
icon: 'las la-address-card',
handler: editSelectedFeatureProperties,
visible: isLayerDataEditable(props.item.layer) && _.get(props.item.layer, 'schema.content')
}, {
id: 'remove-selected-feature',
label: 'KSelectedLayerFeatures.REMOVE_FEATURE_LABEL',
icon: 'las la-trash',
handler: removeSelectedFeature,
visible: isLayerDataEditable(props.item.layer)
}]
}]
})
const root = computed(() => {
const features = props.item.features.map(feature => Object.assign({
label: getFeatureLabel(feature, props.item.layer),
icon: (editedFeatures.value.contains(feature) ? 'las la-edit' : '')
}, feature))
// Replace default layer actions with new ones
const root = Object.assign({
icon: (editedFeatures.value.length > 0 ? 'las la-edit' : '')
}, _.omit(props.item.layer, ['icon', 'actions']), { actions: layerActions.value, features })
return root
})
// Functions
function zoomToSelectedFeatures () {
CurrentActivity.value.zoomToBBox(bbox({ type: 'FeatureCollection', features: props.item.features }))
}
function zoomToSelectedFeature (feature) {
CurrentActivity.value.zoomToBBox(bbox(feature))
}
function editSelectedFeatures () {
// Zoom to then edit
zoomToSelectedFeatures()
CurrentActivity.value.startEditLayer(props.item.layer, {
features: props.item.features.map(feature => getFeatureId(feature, props.item.layer)),
editMode: 'edit-geometry',
allowedEditModes: [
'edit-properties',
'edit-geometry',
'drag',
'rotate'
],
callback: (event) => {
editedFeatures.value = (event.status === 'edit-start' ? props.item.features : [])
}
})
}
function editSelectedFeature (feature) {
// Zoom to then edit
zoomToSelectedFeature(feature)
CurrentActivity.value.startEditLayer(props.item.layer, {
features: [getFeatureId(feature, props.item.layer)],
editMode: 'edit-geometry',
allowedEditModes: [
'edit-properties',
'edit-geometry',
'drag',
'rotate'
],
callback: (event) => {
editedFeatures.value = (event.status === 'edit-start' ? [feature] : [])
}
})
}
function editSelectedFeatureProperties (feature) {
// Zoom to then edit
zoomToSelectedFeature(feature)
router.push({
name: 'edit-map-layer-feature',
query: route.query,
params: Object.assign(route.params, {
layerId: props.item.layer._id,
layerName: props.item.layer.name,
featureId: feature._id,
contextId: Store.get('context')
})
})
}
function removeSelectedFeatures () {
Dialog.create({
title: i18n.t('KSelectedLayerFeatures.REMOVE_FEATURES_DIALOG_TITLE'),
message: i18n.t('KSelectedLayerFeatures.REMOVE_FEATURES_DIALOG_MESSAGE'),
html: true,
ok: {
label: i18n.t('OK'),
flat: true
},
cancel: {
label: i18n.t('CANCEL'),
flat: true
}
}).onOk(async () => {
await CurrentActivity.value.removeFeatures(props.item.features, props.item.layer)
})
}
function removeSelectedFeature (feature) {
Dialog.create({
title: i18n.t('KSelectedLayerFeatures.REMOVE_FEATURE_DIALOG_TITLE'),
message: i18n.t('KSelectedLayerFeatures.REMOVE_FEATURE_DIALOG_MESSAGE'),
html: true,
ok: {
label: i18n.t('OK'),
flat: true
},
cancel: {
label: i18n.t('CANCEL'),
flat: true
}
}).onOk(async () => {
await CurrentActivity.value.removeFeatures(feature, props.item.layer)
})
}
</script>
Loading

0 comments on commit 8f68f70

Please sign in to comment.