Design updates

This commit is contained in:
andrewwallacespeckle
2025-08-26 15:12:50 +01:00
parent 467adb92fe
commit 61010cc38f
14 changed files with 346 additions and 114 deletions
@@ -25,7 +25,7 @@
</template>
<!-- Filter Logic Selection -->
<ViewerFiltersFilterLogicSelector
<ViewerFiltersLogicSelector
v-if="propertyFilters.length > 0"
v-model="filterLogic"
@update:model-value="handleFilterLogicChange"
@@ -43,7 +43,6 @@
:key="filter.id"
:filter="filter"
collapsed
@select-condition="(val) => handleConditionSelect(filter.id, val)"
/>
</div>
</div>
@@ -71,7 +70,6 @@ import {
} from '~~/lib/viewer/composables/setup'
import type {
PropertySelectOption,
ConditionOption,
FilterLogicOption
} from '~/lib/viewer/helpers/filters/types'
import { FilterLogic } from '~/lib/viewer/helpers/filters/types'
@@ -84,7 +82,6 @@ const {
filters: { propertyFilters },
getRelevantFilters,
addActiveFilter,
updateFilterCondition,
resetFilters,
setFilterLogic
} = useFilterUtilities()
@@ -177,10 +174,6 @@ const selectProperty = (propertyKey: string) => {
})
}
const handleConditionSelect = (filterId: string, conditionOption: ConditionOption) => {
updateFilterCondition(filterId, conditionOption.value)
}
const handleFilterLogicChange = (logicOption: FilterLogicOption) => {
filterLogic.value = logicOption.value
}
@@ -1,47 +1,23 @@
<template>
<div class="border border-outline-2 rounded-lg">
<div class="p-1" :class="{ 'border-b border-outline-3 pb-0.5': !collapsed }">
<div class="p-1" :class="{ 'border-b border-outline-3': !collapsed }">
<ViewerFiltersFilterHeader v-model:collapsed="collapsed" :filter="filter" />
<ViewerFiltersFilterConditionSelector
v-if="!collapsed"
:filter="filter"
@select-condition="$emit('selectCondition', $event)"
/>
<ViewerSearchInput
v-if="filter.type === FilterType.String && !collapsed"
v-model="searchQuery"
placeholder="Search for a value..."
/>
</div>
<div v-if="filter.filter && !collapsed">
<ViewerFiltersFilterValuesNumericRange
v-if="isNumericFilter(filter)"
:filter="filter"
/>
<ViewerFiltersFilterValuesStringCheckboxes
v-else
:filter="filter"
:search-query="searchQuery"
/>
<ViewerFiltersFilterNumeric v-if="isNumericFilter(filter)" :filter="filter" />
<ViewerFiltersFilterString v-else :filter="filter" />
</div>
</div>
</template>
<script setup lang="ts">
import type { FilterData } from '~/lib/viewer/helpers/filters/types'
import { FilterType, isNumericFilter } from '~/lib/viewer/helpers/filters/types'
import { isNumericFilter } from '~/lib/viewer/helpers/filters/types'
defineProps<{
filter: FilterData
}>()
const collapsed = ref(true)
defineEmits(['selectCondition'])
const searchQuery = ref('')
const collapsed = ref(false)
</script>
@@ -1,32 +1,38 @@
<template>
<div class="pl-8 -mb-0.5">
<LayoutMenu
v-model:open="showMenu"
:items="menuItems"
show-ticks="right"
:custom-menu-items-classes="['!w-24 !text-body-2xs']"
@chosen="onConditionChosen"
<LayoutMenu
v-model:open="showMenu"
:items="menuItems"
show-ticks="right"
:custom-menu-items-classes="[
'!text-body-2xs',
filter.type === FilterType.Numeric ? '!w-36' : '!w-24'
]"
@chosen="onConditionChosen"
>
<FormButton
class="-ml-2"
color="subtle"
size="sm"
:class="showMenu ? '!bg-highlight-2' : ''"
@click="showMenu = !showMenu"
>
<FormButton
class="-ml-2"
color="subtle"
size="sm"
:class="showMenu ? '!bg-highlight-2' : ''"
@click="showMenu = !showMenu"
>
<span class="text-foreground-2 font-medium text-body-2xs">
{{ selectedConditionLabel }}
</span>
</FormButton>
</LayoutMenu>
</div>
<span class="text-foreground-2 font-medium text-body-2xs">
{{ selectedConditionLabel }}
</span>
</FormButton>
</LayoutMenu>
</template>
<script setup lang="ts">
import {
import type {
FilterCondition,
type FilterData,
type ConditionOption
FilterData,
ConditionOption
} from '~/lib/viewer/helpers/filters/types'
import {
getConditionsForType,
getConditionLabel,
FilterType
} from '~/lib/viewer/helpers/filters/types'
import { LayoutMenu, FormButton, type LayoutMenuItem } from '@speckle/ui-components'
@@ -38,37 +44,37 @@ const emit = defineEmits(['selectCondition'])
const showMenu = ref(false)
const getConditionLabel = (condition: FilterCondition): string => {
switch (condition) {
case FilterCondition.Is:
return 'is'
case FilterCondition.IsNot:
return 'is not'
default:
return 'is'
}
}
// Get condition options based on filter type
const conditionOptions = computed<ConditionOption[]>(() => {
const availableConditions = getConditionsForType(props.filter.type)
return availableConditions.map((condition) => ({
value: condition,
label: getConditionLabel(condition)
}))
})
const menuItems = computed<LayoutMenuItem[][]>(() => [
Object.values(FilterCondition).map((condition) => ({
id: condition,
title: getConditionLabel(condition),
active: condition === (props.filter.condition || FilterCondition.Is)
conditionOptions.value.map((conditionOption) => ({
id: conditionOption.value,
title: conditionOption.label,
active: conditionOption.value === props.filter.condition
}))
])
const selectedConditionLabel = computed(() => {
return getConditionLabel(props.filter.condition || FilterCondition.Is)
return getConditionLabel(props.filter.condition)
})
const onConditionChosen = ({ item }: { item: LayoutMenuItem }) => {
const onConditionChosen = ({ item }: { item: LayoutMenuItem; event: MouseEvent }) => {
// Since we control the menu items, we know item.id is a FilterCondition
const condition = item.id as FilterCondition
const conditionOption: ConditionOption = {
value: condition,
label: getConditionLabel(condition)
const conditionOption = conditionOptions.value.find(
(option) => option.value === condition
)
if (conditionOption) {
emit('selectCondition', conditionOption)
}
emit('selectCondition', conditionOption)
showMenu.value = false
}
</script>
@@ -1,11 +1,11 @@
<template>
<div class="flex flex-col p-3">
<div class="flex flex-col px-2 py-1">
<FormDualRange
v-model:min-value="currentMin"
v-model:max-value="currentMax"
:name="`range-${filter.id}`"
:min="(filter.filter as NumericPropertyInfo).min"
:max="(filter.filter as NumericPropertyInfo).max"
:min="filterMin"
:max="filterMax"
:step="0.01"
show-fields
/>
@@ -14,8 +14,7 @@
<script setup lang="ts">
import { FormDualRange } from '@speckle/ui-components'
import type { NumericPropertyInfo } from '@speckle/viewer'
import { useFilterUtilities } from '~~/lib/viewer/composables/ui'
import { useFilterUtilities } from '~~/lib/viewer/composables/filtering'
import { isNumericFilter, type FilterData } from '~/lib/viewer/helpers/filters/types'
const props = defineProps<{
@@ -24,6 +23,21 @@ const props = defineProps<{
const { setNumericRange } = useFilterUtilities()
// Get the filter's min/max bounds
const filterMin = computed(() => {
if (isNumericFilter(props.filter)) {
return props.filter.filter.min
}
return 0
})
const filterMax = computed(() => {
if (isNumericFilter(props.filter)) {
return props.filter.filter.max
}
return 100
})
const currentMin = computed({
get: () => {
if (isNumericFilter(props.filter)) {
@@ -0,0 +1,32 @@
<template>
<div class="p-1">
<ViewerFiltersFilterConditionSelector
:filter="filter"
class="pl-4"
@select-condition="handleConditionSelect"
/>
<ViewerFiltersFilterNumericBetween
v-if="filter.condition === NumericFilterCondition.IsBetween"
:filter="filter"
/>
<ViewerFiltersFilterNumericSingle v-else :filter="filter" />
</div>
</template>
<script setup lang="ts">
import type { FilterData, ConditionOption } from '~/lib/viewer/helpers/filters/types'
import { NumericFilterCondition } from '~/lib/viewer/helpers/filters/types'
import { useFilterUtilities } from '~~/lib/viewer/composables/filtering'
const props = defineProps<{
filter: FilterData
}>()
const { updateFilterCondition } = useFilterUtilities()
const handleConditionSelect = (conditionOption: ConditionOption) => {
updateFilterCondition(props.filter.id, conditionOption.value)
}
</script>
@@ -0,0 +1,85 @@
<template>
<div class="flex flex-col px-2 py-1 gap-1">
<FormRange
v-if="showRangeSlider"
v-model="singleValueReactive"
:min="filterMin"
:max="filterMax"
:step="0.01"
name="singleValueRange"
:label="rangeLabel"
hide-header
class="-mt-1.5"
/>
<FormTextInput
:model-value="String(singleValue)"
name="singleValue"
size="sm"
type="number"
placeholder="Enter value"
@update:model-value="updateSingleValue"
/>
</div>
</template>
<script setup lang="ts">
import { FormTextInput, FormRange } from '@speckle/ui-components'
import { useFilterUtilities } from '~~/lib/viewer/composables/filtering'
import {
isNumericFilter,
NumericFilterCondition,
type FilterData
} from '~/lib/viewer/helpers/filters/types'
const props = defineProps<{
filter: FilterData
}>()
const { setNumericRange } = useFilterUtilities()
// Get the filter's min/max bounds for range inputs
const filterMin = computed(() => {
if (isNumericFilter(props.filter)) {
return props.filter.filter.min
}
return 0
})
const filterMax = computed(() => {
if (isNumericFilter(props.filter)) {
return props.filter.filter.max
}
return 100
})
const singleValue = computed(() => props.filter.numericRange.min)
// Show range slider for greater than / less than conditions
const showRangeSlider = computed(() => {
return (
props.filter.condition === NumericFilterCondition.IsGreaterThan ||
props.filter.condition === NumericFilterCondition.IsLessThan
)
})
const rangeLabel = computed(() => {
return props.filter.condition === NumericFilterCondition.IsGreaterThan
? 'Greater than'
: 'Less than'
})
// Reactive value for FormRange v-model
const singleValueReactive = computed({
get: () => props.filter.numericRange.min,
set: (value: number) => {
setNumericRange(props.filter.id, value, value)
}
})
const updateSingleValue = (value: string) => {
const numericValue = parseFloat(value) || 0
// For single value conditions, set both min and max to the same value
setNumericRange(props.filter.id, numericValue, numericValue)
}
</script>
@@ -1,6 +1,6 @@
<template>
<div>
<ViewerFiltersFilterValuesSelectAllCheckbox
<ViewerFiltersFilterStringSelectAll
:selected-count="selectedCount"
:total-count="filteredValues.length"
@select-all="selectAll"
@@ -23,7 +23,7 @@
transform: `translateY(${index * itemHeight}px)`
}"
>
<ViewerFiltersFilterValuesFilterValueItem
<ViewerFiltersFilterStringValueItem
:filter-id="filter.id"
:value="value"
:is-selected="isValueSelected(value)"
@@ -0,0 +1,36 @@
<template>
<div class="pt-1">
<ViewerFiltersFilterConditionSelector
:filter="filter"
class="pl-9"
@select-condition="handleConditionSelect"
/>
<ViewerSearchInput
v-if="!collapsed"
v-model="searchQuery"
placeholder="Search for a value..."
class="pl-1 -mt-0.5"
/>
<ViewerFiltersFilterStringCheckboxes :filter="filter" :search-query="searchQuery" />
</div>
</template>
<script setup lang="ts">
import type { FilterData, ConditionOption } from '~/lib/viewer/helpers/filters/types'
import { useFilterUtilities } from '~~/lib/viewer/composables/filtering'
const props = defineProps<{
filter: FilterData
}>()
const collapsed = ref(false)
const searchQuery = ref('')
const { updateFilterCondition } = useFilterUtilities()
const handleConditionSelect = (conditionOption: ConditionOption) => {
updateFilterCondition(props.filter.id, conditionOption.value)
}
</script>
@@ -67,6 +67,7 @@ import { Ellipsis } from 'lucide-vue-next'
import { useFilterUtilities } from '~~/lib/viewer/composables/filtering'
import { useInjectedViewer } from '~~/lib/viewer/composables/setup'
import type { KeyValuePair } from '~/components/viewer/selection/types'
import { isNumericPropertyInfo } from '~/lib/viewer/helpers/sceneExplorer'
const props = defineProps<{
kvp: KeyValuePair
@@ -78,7 +79,8 @@ const {
findFilterByKvp,
addActiveFilter,
updateActiveFilterValues,
toggleFilterApplied
toggleFilterApplied,
setNumericRange
} = useFilterUtilities()
const {
@@ -109,9 +111,20 @@ const handleAddToFilters = (kvp: KeyValuePair) => {
const filter = findFilterByKvp(kvp, availableFilters.value)
if (filter && kvp.value !== null && kvp.value !== undefined) {
const filterId = addActiveFilter(filter)
const values = [String(kvp.value)]
updateActiveFilterValues(filterId, values)
toggleFilterApplied(filterId)
if (isNumericPropertyInfo(filter)) {
// For numeric filters, set the specific numeric value
const numericValue =
typeof kvp.value === 'number' ? kvp.value : parseFloat(String(kvp.value))
if (!isNaN(numericValue)) {
setNumericRange(filterId, numericValue, numericValue)
}
} else {
// For string filters, use the selectedValues array
const values = [String(kvp.value)]
updateActiveFilterValues(filterId, values)
toggleFilterApplied(filterId)
}
}
}
@@ -21,7 +21,7 @@ import {
isNumericPropertyInfo
} from '~/lib/viewer/helpers/sceneExplorer'
import {
FilterCondition,
type FilterCondition,
FilterLogic,
FilterType,
type FilterData,
@@ -34,7 +34,10 @@ import {
type ResourceInfo,
type CreateFilterParams,
isNumericFilter,
isStringFilter
isStringFilter,
NumericFilterCondition,
StringFilterCondition,
getConditionLabel
} from '~/lib/viewer/helpers/filters/types'
import { useOnViewerLoadComplete } from '~~/lib/viewer/composables/viewer'
import { arraysEqual } from '~~/lib/common/helpers/utils'
@@ -140,28 +143,52 @@ function createFilteringDataStore() {
continue
}
// Handle numeric conditions
if (criteria.minValue !== undefined || criteria.maxValue !== undefined) {
const minValue = criteria.minValue ?? -Infinity
const maxValue = criteria.maxValue ?? Infinity
for (const [value, objectIds] of Object.entries(propertyIndex)) {
const numericValue = Number(value)
if (
!isNaN(numericValue) &&
numericValue >= minValue &&
numericValue <= maxValue
) {
matchingIds.push(...objectIds)
if (!isNaN(numericValue)) {
let shouldInclude = false
switch (criteria.condition) {
case NumericFilterCondition.IsBetween:
shouldInclude = numericValue >= minValue && numericValue <= maxValue
break
case NumericFilterCondition.IsGreaterThan:
shouldInclude = numericValue > minValue
break
case NumericFilterCondition.IsLessThan:
shouldInclude = numericValue < maxValue
break
case NumericFilterCondition.IsEqualTo:
// For numeric "is", check if the value is within the range
shouldInclude = numericValue >= minValue && numericValue <= maxValue
break
case NumericFilterCondition.IsNotEqualTo:
// For numeric "is not", exclude values within the range
shouldInclude = numericValue < minValue || numericValue > maxValue
break
default:
// Default to range behavior for backward compatibility
shouldInclude = numericValue >= minValue && numericValue <= maxValue
}
if (shouldInclude) {
matchingIds.push(...objectIds)
}
}
}
} else if (criteria.condition === FilterCondition.Is) {
} else if (criteria.condition === StringFilterCondition.Is) {
for (const value of criteria.values) {
const objectIds = propertyIndex[value]
if (objectIds) {
matchingIds.push(...objectIds)
}
}
} else if (criteria.condition === FilterCondition.IsNot) {
} else if (criteria.condition === StringFilterCondition.IsNot) {
const excludeValues = new Set(criteria.values)
for (const [value, objectIds] of Object.entries(propertyIndex)) {
if (!excludeValues.has(value)) {
@@ -330,6 +357,7 @@ export function useFilterUtilities(
dataStore.finalObjectIds,
(newObjectIds, oldObjectIds) => {
if (preventFilterWatchers) return
if (arraysEqual(newObjectIds, oldObjectIds || [])) return
withWatchersDisabled(() => {
@@ -444,16 +472,12 @@ export function useFilterUtilities(
const createFilterData = (params: CreateFilterParams): FilterData => {
const { filter, id, availableValues } = params
const baseData = {
id,
isApplied: false,
selectedValues: [...availableValues],
condition: FilterCondition.Is
}
if (isNumericPropertyInfo(filter)) {
return {
...baseData,
id,
isApplied: false,
selectedValues: [...availableValues],
condition: NumericFilterCondition.IsEqualTo,
type: FilterType.Numeric,
filter: filter as NumericPropertyInfo,
numericRange: {
@@ -463,7 +487,10 @@ export function useFilterUtilities(
} satisfies NumericFilterData
} else {
return {
...baseData,
id,
isApplied: false,
selectedValues: [...availableValues],
condition: StringFilterCondition.Is,
type: FilterType.String,
filter: filter as StringPropertyInfo,
numericRange: { min: 0, max: 100 } // Default range for consistency
@@ -505,6 +532,9 @@ export function useFilterUtilities(
removeColorFilter()
}
filters.propertyFilters.value.splice(index, 1)
// Update viewer to reflect filter removal
updateDataStoreSlices()
}
}
@@ -515,6 +545,9 @@ export function useFilterUtilities(
const filter = filters.propertyFilters.value.find((f) => f.id === filterId)
if (filter) {
filter.isApplied = !filter.isApplied
// Update viewer to reflect filter state change
updateDataStoreSlices()
}
}
@@ -525,6 +558,9 @@ export function useFilterUtilities(
const filter = filters.propertyFilters.value.find((f) => f.id === filterId)
if (filter) {
filter.selectedValues = [...values]
// Update viewer to reflect filter value changes
updateDataStoreSlices()
}
}
@@ -584,8 +620,8 @@ export function useFilterUtilities(
const slice: DataSlice = {
id: `filter-${filter.id}`,
name: `${getPropertyName(
filter.filter.key
name: `${getPropertyName(filter.filter.key)} ${getConditionLabel(
filter.condition
)} (${filter.numericRange.min.toFixed(2)} - ${filter.numericRange.max.toFixed(
2
)})`,
@@ -605,7 +641,7 @@ export function useFilterUtilities(
const slice: DataSlice = {
id: `filter-${filter.id}`,
name: `${getPropertyName(filter.filter.key)} ${
filter.condition === FilterCondition.Is ? 'is' : 'is not'
filter.condition === StringFilterCondition.Is ? 'is' : 'is not'
} ${filter.selectedValues.join(', ')}`,
objectIds: matchingObjectIds
}
@@ -4,11 +4,52 @@ import type {
StringPropertyInfo
} from '@speckle/viewer'
export enum FilterCondition {
export enum NumericFilterCondition {
IsBetween = 'is_between',
IsEqualTo = 'is_equal_to',
IsNotEqualTo = 'is_not_equal_to',
IsGreaterThan = 'is_greater_than',
IsLessThan = 'is_less_than'
}
export enum StringFilterCondition {
Is = 'is',
IsNot = 'is_not'
}
export type FilterCondition = NumericFilterCondition | StringFilterCondition
// Centralized condition configuration
export const CONDITION_CONFIG: Record<FilterCondition, { label: string }> = {
// String conditions
[StringFilterCondition.Is]: { label: 'is' },
[StringFilterCondition.IsNot]: { label: 'is not' },
// Numeric conditions
[NumericFilterCondition.IsEqualTo]: { label: 'is equal to' },
[NumericFilterCondition.IsNotEqualTo]: { label: 'is not equal to' },
[NumericFilterCondition.IsGreaterThan]: { label: 'is greater than' },
[NumericFilterCondition.IsLessThan]: { label: 'is less than' },
[NumericFilterCondition.IsBetween]: { label: 'is between' }
} as const
// Helper to get available conditions for a filter type
export const getConditionsForType = (filterType: FilterType): FilterCondition[] => {
if (filterType === FilterType.Numeric) {
return Object.values(NumericFilterCondition)
} else {
return Object.values(StringFilterCondition)
}
}
// Helper to get condition label
export const getConditionLabel = (condition: FilterCondition): string => {
return CONDITION_CONFIG[condition]?.label || 'is'
}
export type FilterConditionLabel = {
[key in FilterCondition]: string
}
export enum FilterLogic {
All = 'all',
Any = 'any'