Counts
This commit is contained in:
@@ -2,9 +2,8 @@
|
||||
<div>
|
||||
<div class="flex justify-between items-center pr-1">
|
||||
<ViewerFiltersFilterStringSelectAll
|
||||
:selected-count="selectedCount"
|
||||
:total-count="filteredValues.length"
|
||||
@select-all="selectAll"
|
||||
:filter="filter"
|
||||
:search-query="searchQuery"
|
||||
/>
|
||||
|
||||
<!-- Sorting Controls -->
|
||||
@@ -37,27 +36,13 @@
|
||||
<div
|
||||
v-for="{ data: value, index } in list"
|
||||
:key="`${index}-${value}`"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${itemHeight}px`,
|
||||
transform: `translateY(${index * itemHeight}px)`
|
||||
}"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
:style="{ transform: `translateY(${index * itemHeight}px)` }"
|
||||
>
|
||||
<ViewerFiltersFilterStringValueItem
|
||||
:filter-id="filter.id"
|
||||
:filter="filter"
|
||||
:value="value"
|
||||
:is-selected="isValueSelected(value)"
|
||||
:count="getValueCount(value)"
|
||||
:color="getValueColor(value)"
|
||||
:is-default-selected="
|
||||
isStringFilter(filter) &&
|
||||
filter.isDefaultAllSelected &&
|
||||
isValueSelected(value)
|
||||
"
|
||||
@toggle="() => toggleValue(value)"
|
||||
@toggle="() => toggleActiveFilterValue(filter.id, value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,14 +66,7 @@ const props = defineProps<{
|
||||
searchQuery?: string
|
||||
}>()
|
||||
|
||||
const {
|
||||
toggleActiveFilterValue,
|
||||
updateActiveFilterValues,
|
||||
isActiveFilterValueSelected,
|
||||
getFilterValueColor,
|
||||
getAvailableFilterValues,
|
||||
filters
|
||||
} = useFilterUtilities()
|
||||
const { toggleActiveFilterValue, getFilteredFilterValues } = useFilterUtilities()
|
||||
|
||||
const showSortMenu = ref(false)
|
||||
const sortMode = ref<'selected-first' | 'alphabetical'>('alphabetical')
|
||||
@@ -108,87 +86,31 @@ const sortMenuItems = computed<LayoutMenuItem[][]>(() => [
|
||||
]
|
||||
])
|
||||
|
||||
// Handle sort option selection
|
||||
const onSortOptionChosen = ({ item }: { item: LayoutMenuItem; event: MouseEvent }) => {
|
||||
sortMode.value = item.id as 'selected-first' | 'alphabetical'
|
||||
showSortMenu.value = false
|
||||
}
|
||||
|
||||
const isValueSelected = (value: string): boolean => {
|
||||
return isActiveFilterValueSelected(props.filter.id, value)
|
||||
}
|
||||
|
||||
const getValueCount = (_value: string): number => {
|
||||
return 1
|
||||
}
|
||||
|
||||
const getValueColor = (value: string): string | null => {
|
||||
if (filters.activeColorFilterId.value !== props.filter.id) {
|
||||
return null
|
||||
}
|
||||
return getFilterValueColor(value)
|
||||
}
|
||||
|
||||
const toggleValue = (value: string) => {
|
||||
toggleActiveFilterValue(props.filter.id, value)
|
||||
}
|
||||
|
||||
const selectAll = (selected: boolean) => {
|
||||
if (!isStringFilter(props.filter) || !props.filter.filter) return
|
||||
|
||||
const allAvailableValues = getAvailableFilterValues(props.filter.filter)
|
||||
if (selected) {
|
||||
updateActiveFilterValues(props.filter.id, allAvailableValues)
|
||||
} else {
|
||||
updateActiveFilterValues(props.filter.id, [])
|
||||
}
|
||||
}
|
||||
|
||||
const availableValues = computed(() => {
|
||||
const filteredValues = computed(() => {
|
||||
if (isStringFilter(props.filter) && props.filter.filter) {
|
||||
return getAvailableFilterValues(props.filter.filter)
|
||||
return getFilteredFilterValues(props.filter.filter, {
|
||||
sortMode: sortMode.value,
|
||||
filterId: props.filter.id
|
||||
})
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const filteredValues = computed(() => {
|
||||
let values = availableValues.value
|
||||
|
||||
if (props.searchQuery?.trim()) {
|
||||
const searchTerm = props.searchQuery.toLowerCase().trim()
|
||||
values = values.filter((value: string) => value.toLowerCase().includes(searchTerm))
|
||||
}
|
||||
|
||||
if (sortMode.value === 'selected-first') {
|
||||
// Sort: selected first, then alphabetical
|
||||
const selectedValues = values.filter((value: string) => isValueSelected(value))
|
||||
const unselectedValues = values.filter((value: string) => !isValueSelected(value))
|
||||
|
||||
// Sort each group alphabetically
|
||||
const sortedSelectedValues = selectedValues.sort((a, b) => a.localeCompare(b))
|
||||
const sortedUnselectedValues = unselectedValues.sort((a, b) => a.localeCompare(b))
|
||||
|
||||
return [...sortedSelectedValues, ...sortedUnselectedValues]
|
||||
} else {
|
||||
// Sort: pure alphabetical
|
||||
return values.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
})
|
||||
|
||||
const selectedCount = computed(() => {
|
||||
return filteredValues.value.filter((value) => isValueSelected(value)).length
|
||||
})
|
||||
|
||||
const itemHeight = 28 // Height of each checkbox item in pixels
|
||||
const maxHeight = 240
|
||||
|
||||
const { list, containerProps } = useVirtualList(filteredValues, {
|
||||
itemHeight: 28,
|
||||
overscan: 5
|
||||
})
|
||||
|
||||
const containerHeight = computed(() => {
|
||||
const contentHeight = filteredValues.value.length * itemHeight
|
||||
return `${Math.min(contentHeight, maxHeight)}px`
|
||||
})
|
||||
|
||||
const { list, containerProps } = useVirtualList(filteredValues, {
|
||||
itemHeight,
|
||||
overscan: 5
|
||||
})
|
||||
const onSortOptionChosen = ({ item }: { item: LayoutMenuItem; event: MouseEvent }) => {
|
||||
sortMode.value = item.id as 'selected-first' | 'alphabetical'
|
||||
showSortMenu.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -26,30 +26,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { FormCheckbox } from '@speckle/ui-components'
|
||||
import { useFilterUtilities } from '~~/lib/viewer/composables/filtering'
|
||||
import { isStringFilter, type FilterData } from '~/lib/viewer/helpers/filters/types'
|
||||
|
||||
const props = defineProps<{
|
||||
selectedCount: number
|
||||
totalCount: number
|
||||
filter: FilterData
|
||||
searchQuery?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectAll: [selected: boolean]
|
||||
}>()
|
||||
const {
|
||||
getFilteredFilterValues,
|
||||
isActiveFilterValueSelected,
|
||||
updateActiveFilterValues
|
||||
} = useFilterUtilities()
|
||||
|
||||
const filteredValues = computed(() => {
|
||||
if (isStringFilter(props.filter) && props.filter.filter) {
|
||||
return getFilteredFilterValues(props.filter.filter, {
|
||||
searchQuery: props.searchQuery
|
||||
})
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const selectedCount = computed(() => {
|
||||
return filteredValues.value.filter((value) =>
|
||||
isActiveFilterValueSelected(props.filter.id, value)
|
||||
).length
|
||||
})
|
||||
|
||||
const totalCount = computed(() => filteredValues.value.length)
|
||||
|
||||
const areAllValuesSelected = computed(() => {
|
||||
return props.totalCount > 0 && props.selectedCount === props.totalCount
|
||||
return totalCount.value > 0 && selectedCount.value === totalCount.value
|
||||
})
|
||||
|
||||
const areSomeValuesSelected = computed(() => {
|
||||
return props.selectedCount > 0 && props.selectedCount < props.totalCount
|
||||
return selectedCount.value > 0 && selectedCount.value < totalCount.value
|
||||
})
|
||||
|
||||
const handleSelectAllChange = () => {
|
||||
// If some are selected (indeterminate state), always select all
|
||||
// If all are selected, deselect all
|
||||
// If none are selected, select all
|
||||
const finalSelection = areSomeValuesSelected.value || !areAllValuesSelected.value
|
||||
|
||||
emit('selectAll', finalSelection)
|
||||
if (isStringFilter(props.filter) && props.filter.filter) {
|
||||
const allAvailableValues = getFilteredFilterValues(props.filter.filter)
|
||||
if (finalSelection) {
|
||||
updateActiveFilterValues(props.filter.id, allAvailableValues)
|
||||
} else {
|
||||
updateActiveFilterValues(props.filter.id, [])
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
'opacity-50 dark:!bg-transparent !border !border-outline-5 !group-hover:border-outline-5':
|
||||
isDefaultSelected
|
||||
}"
|
||||
:name="`filter-${filterId}-${value}`"
|
||||
:name="`filter-${filter.id}-${value}`"
|
||||
:model-value="isSelected"
|
||||
hide-label
|
||||
/>
|
||||
@@ -24,7 +24,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="shrink-0 text-foreground-2 text-body-3xs">
|
||||
<div v-if="count !== null" class="shrink-0 text-foreground-2 text-body-3xs">
|
||||
{{ count }}
|
||||
</div>
|
||||
<div
|
||||
@@ -39,17 +39,47 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FormCheckbox } from '@speckle/ui-components'
|
||||
import { useFilterUtilities } from '~~/lib/viewer/composables/filtering'
|
||||
import { isStringFilter, type FilterData } from '~/lib/viewer/helpers/filters/types'
|
||||
|
||||
defineProps<{
|
||||
filterId: string
|
||||
const props = defineProps<{
|
||||
filter: FilterData
|
||||
value: string
|
||||
isSelected: boolean
|
||||
count: number
|
||||
color?: string | null
|
||||
isDefaultSelected?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
toggle: []
|
||||
}>()
|
||||
|
||||
const {
|
||||
isActiveFilterValueSelected,
|
||||
getFilterValueColor,
|
||||
getPropertyValueCounts,
|
||||
filters
|
||||
} = useFilterUtilities()
|
||||
|
||||
const isSelected = computed(() =>
|
||||
isActiveFilterValueSelected(props.filter.id, props.value)
|
||||
)
|
||||
|
||||
const count = computed(() => {
|
||||
if (!props.filter.filter?.key) return null
|
||||
const counts = getPropertyValueCounts(props.filter.filter.key)
|
||||
return counts[props.value] || 0
|
||||
})
|
||||
|
||||
const color = computed(() => {
|
||||
if (filters.activeColorFilterId.value !== props.filter.id) {
|
||||
return null
|
||||
}
|
||||
return getFilterValueColor(props.value)
|
||||
})
|
||||
|
||||
const isDefaultSelected = computed(() => {
|
||||
return (
|
||||
isStringFilter(props.filter) &&
|
||||
props.filter.isDefaultAllSelected &&
|
||||
isSelected.value
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -406,7 +406,9 @@ function createFilteringDataStore() {
|
||||
currentFilterLogic,
|
||||
setGhostMode,
|
||||
ghostMode,
|
||||
dataSlices
|
||||
dataSlices,
|
||||
dataSources,
|
||||
buildPropertyIndex
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,6 +536,43 @@ export function useFilterUtilities(
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets counts for all values of a property at once (performance optimized)
|
||||
*/
|
||||
const getPropertyValueCounts = (propertyKey: string): Record<string, number> => {
|
||||
const valueCounts: Record<string, number> = {}
|
||||
|
||||
for (const dataSource of dataStore.dataSources.value) {
|
||||
const propertyIndex = dataStore.buildPropertyIndex(dataSource, propertyKey)
|
||||
|
||||
for (const [value, objectIds] of Object.entries(propertyIndex)) {
|
||||
if (!valueCounts[value]) {
|
||||
valueCounts[value] = 0
|
||||
}
|
||||
valueCounts[value] += objectIds.length
|
||||
}
|
||||
}
|
||||
|
||||
return valueCounts
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the count of objects that have a specific value for a property
|
||||
* Note: For better performance when getting multiple counts, use getPropertyValueCounts
|
||||
*/
|
||||
const getPropertyValueCount = (propertyKey: string, value: string): number => {
|
||||
let totalCount = 0
|
||||
|
||||
for (const dataSource of dataStore.dataSources.value) {
|
||||
const propertyIndex = dataStore.buildPropertyIndex(dataSource, propertyKey)
|
||||
if (propertyIndex && propertyIndex[value]) {
|
||||
totalCount += propertyIndex[value].length
|
||||
}
|
||||
}
|
||||
|
||||
return totalCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a properly typed FilterData object from PropertyInfo
|
||||
*/
|
||||
@@ -1204,6 +1243,50 @@ export function useFilterUtilities(
|
||||
return color.startsWith('#') ? color : `#${color}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets filtered and sorted values for a string filter with search and sorting options
|
||||
*/
|
||||
const getFilteredFilterValues = (
|
||||
filter: PropertyInfo,
|
||||
options?: {
|
||||
searchQuery?: string
|
||||
sortMode?: 'alphabetical' | 'selected-first'
|
||||
filterId?: string
|
||||
}
|
||||
): string[] => {
|
||||
const { searchQuery, sortMode = 'alphabetical', filterId } = options || {}
|
||||
|
||||
let values = getAvailableFilterValues(filter)
|
||||
|
||||
// Apply search filtering
|
||||
if (searchQuery?.trim()) {
|
||||
const searchTerm = searchQuery.toLowerCase().trim()
|
||||
values = values.filter((value: string) =>
|
||||
value.toLowerCase().includes(searchTerm)
|
||||
)
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
if (sortMode === 'selected-first' && filterId) {
|
||||
// Sort: selected first, then alphabetical
|
||||
const selectedValues = values.filter((value: string) =>
|
||||
isActiveFilterValueSelected(filterId, value)
|
||||
)
|
||||
const unselectedValues = values.filter(
|
||||
(value: string) => !isActiveFilterValueSelected(filterId, value)
|
||||
)
|
||||
|
||||
// Sort each group alphabetically
|
||||
const sortedSelectedValues = selectedValues.sort((a, b) => a.localeCompare(b))
|
||||
const sortedUnselectedValues = unselectedValues.sort((a, b) => a.localeCompare(b))
|
||||
|
||||
return [...sortedSelectedValues, ...sortedUnselectedValues]
|
||||
} else {
|
||||
// Sort: pure alphabetical
|
||||
return values.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isolateObjects,
|
||||
unIsolateObjects,
|
||||
@@ -1211,6 +1294,8 @@ export function useFilterUtilities(
|
||||
showObjects,
|
||||
filters,
|
||||
getAvailableFilterValues,
|
||||
getPropertyValueCount,
|
||||
getPropertyValueCounts,
|
||||
addActiveFilter,
|
||||
updateFilterProperty,
|
||||
removeActiveFilter,
|
||||
@@ -1243,6 +1328,8 @@ export function useFilterUtilities(
|
||||
toggleColorFilter,
|
||||
getFilterColorGroups,
|
||||
getFilterValueColor,
|
||||
// Filtered values
|
||||
getFilteredFilterValues,
|
||||
// Numeric range filtering
|
||||
setNumericRange,
|
||||
// Filter logic
|
||||
|
||||
Reference in New Issue
Block a user