This commit is contained in:
andrewwallacespeckle
2025-09-01 15:53:51 +01:00
parent 7b3fec6b97
commit 67f1246a85
4 changed files with 183 additions and 119 deletions
@@ -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