pr comments. refactor

This commit is contained in:
andrewwallacespeckle
2025-09-04 17:13:16 +01:00
parent 7573f2602e
commit 1d9bc060ef
8 changed files with 304 additions and 209 deletions
@@ -86,11 +86,7 @@
</template>
<script setup lang="ts">
import {
useInjectedViewerInterfaceState,
useInjectedViewer,
useInjectedViewerState
} from '~~/lib/viewer/composables/setup'
import { useInjectedViewerInterfaceState } from '~~/lib/viewer/composables/setup'
import type { PropertySelectOption } from '~/lib/viewer/helpers/filters/types'
import { FilterType } from '~/lib/viewer/helpers/filters/types'
import { useMixpanel } from '~~/lib/core/composables/mp'
@@ -111,9 +107,6 @@ const {
setFilterLogic
} = useFilterUtilities()
const dataStore = useFilteringDataStore()
const { instance } = useInjectedViewer()
const { resourceItems } = useInjectedViewerState().resources.response
const { currentFilterLogic } = useFilteringDataStore()
const { filteredObjectsCount } = useFilteredObjectsCount()
const mp = useMixpanel()
@@ -128,6 +121,10 @@ const filtersContainerRef = ref<HTMLElement>()
const shouldScrollToNewFilter = ref(false)
const propertySelectOptions = computed((): PropertySelectOption[] => {
if (!showPropertySelection.value) {
return []
}
const existingFilterKeys = new Set(
propertyFilters.value.map((f) => f.filter?.key).filter(Boolean)
)
@@ -137,28 +134,34 @@ const propertySelectOptions = computed((): PropertySelectOption[] => {
const allOptions: PropertySelectOption[] = relevantFilters
.filter((filter) => !existingFilterKeys.has(filter.key))
.map((filter) => {
const pathParts = filter.key.split('.')
const propertyName = pathParts[pathParts.length - 1]
const parentPath = pathParts.slice(0, -1).join('.')
const lastDotIndex = filter.key.lastIndexOf('.')
const propertyName =
lastDotIndex === -1 ? filter.key : filter.key.slice(lastDotIndex + 1)
const parentPath = lastDotIndex === -1 ? '' : filter.key.slice(0, lastDotIndex)
return {
value: filter.key,
label: propertyName,
parentPath,
type: filter.type === 'number' ? FilterType.Numeric : FilterType.String,
hasParent: parentPath.length > 0
hasParent: lastDotIndex !== -1
}
})
// Use a more efficient sorting approach
const sortedOptions = allOptions.sort((a, b) => {
if (!a.hasParent && b.hasParent) return -1
if (a.hasParent && !b.hasParent) return 1
// First sort by whether they have parents (no-parent items first)
if (a.hasParent !== b.hasParent) {
return a.hasParent ? 1 : -1
}
// If both have parents, sort by parent path first
if (a.hasParent && b.hasParent) {
const parentComparison = a.parentPath.localeCompare(b.parentPath)
if (parentComparison !== 0) return parentComparison
}
// Finally sort by label
return a.label.localeCompare(b.label)
})
@@ -244,26 +247,6 @@ onKeyStroke('Escape', () => {
}
})
// Populate data store on mount to avoid delay when clicking plus button
onMounted(async () => {
if (dataStore.dataSources.value.length === 0 && resourceItems.value.length > 0) {
const tree = instance.getWorldTree()
if (tree) {
const availableResources = resourceItems.value.filter((item) => {
const nodes = tree.findId(item.objectId)
return nodes && nodes.length > 0
})
if (availableResources.length > 0) {
const resources = availableResources.map((item) => ({
resourceUrl: item.objectId
}))
await dataStore.populateDataStore(instance, resources)
}
}
}
})
// Watch for new filters being added and scroll when needed
watch(
() => propertyFilters.value.length,
@@ -79,19 +79,35 @@ const updateDebouncedSearch = useDebounceFn((query: string) => {
debouncedSearchQuery.value = query
}, 200)
// Pre-compute lowercase versions for efficient searching
const optionsWithLowercase = computed(() => {
return props.options.map((option) => ({
...option,
_searchLabel: option.label.toLowerCase(),
_searchValue: option.value.toLowerCase(),
_searchParentPath: option.parentPath.toLowerCase(),
_searchType: option.type.toLowerCase()
}))
})
const filteredOptions = computed(() => {
if (!debouncedSearchQuery.value.trim()) {
return props.options
}
const searchTerm = debouncedSearchQuery.value.toLowerCase().trim()
return props.options.filter(
(option) =>
option.label.toLowerCase().includes(searchTerm) ||
option.value.toLowerCase().includes(searchTerm) ||
option.parentPath.toLowerCase().includes(searchTerm) ||
option.type.toLowerCase().includes(searchTerm)
)
return optionsWithLowercase.value
.filter(
(option) =>
option._searchLabel.includes(searchTerm) ||
option._searchValue.includes(searchTerm) ||
option._searchParentPath.includes(searchTerm) ||
option._searchType.includes(searchTerm)
)
.map(
({ _searchLabel, _searchValue, _searchParentPath, _searchType, ...option }) =>
option
)
})
const listItems = computed((): PropertySelectionListItem[] => {
@@ -105,8 +121,10 @@ const listItems = computed((): PropertySelectionListItem[] => {
return searchResults
}
// Create a map for O(1) lookup instead of O(n) find operations
const optionsMap = new Map(props.options.map((opt) => [opt.value, opt]))
const availablePopular = FILTERS_POPULAR_PROPERTIES.map((filterKey) =>
props.options.find((opt) => opt.value === filterKey)
optionsMap.get(filterKey)
)
.filter(Boolean)
.slice(0, 6) // Show max 6 popular filters
@@ -1,5 +1,5 @@
import type { SpeckleObject, TreeNode, Viewer } from '@speckle/viewer'
import { uniq, flatten, isEmpty, compact } from 'lodash-es'
import { uniq, flatten, compact } from 'lodash-es'
import {
FilterLogic,
NumericFilterCondition,
@@ -21,39 +21,6 @@ export function createViewerFilteringDataStore() {
const currentFilterLogic = ref<FilterLogic>(FilterLogic.All)
const dataSlices: Ref<DataSlice[]> = ref([])
const propertyExtractionCache = new Map<Record<string, unknown>, PropertyInfoBase[]>()
const extractNestedProperties = (
obj: Record<string, unknown>
): PropertyInfoBase[] => {
if (propertyExtractionCache.has(obj)) {
return propertyExtractionCache.get(obj)!
}
const properties: PropertyInfoBase[] = []
function traverse(current: Record<string, unknown>, path: string[] = []) {
for (const [key, value] of Object.entries(current)) {
const currentPath = [...path, key]
const concatenatedPath = currentPath.join('.')
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
traverse(value as Record<string, unknown>, currentPath)
} else {
properties.push({
concatenatedPath,
value,
type: typeof value
} as PropertyInfoBase)
}
}
}
traverse(obj)
propertyExtractionCache.set(obj, properties)
return properties
}
const populateDataStore = async (viewer: Viewer, resources: ResourceInfo[]) => {
const tree = viewer.getWorldTree()
if (!tree) return
@@ -67,36 +34,86 @@ export function createViewerFilteringDataStore() {
const objectMap: Record<string, SpeckleObject> = {}
const propertyMap: Record<string, PropertyInfoBase> = {}
const propertyIndexCache: Record<string, Record<string, string[]>> = {}
// Map from objectId to its property values for efficient filtering
const objectProperties: Record<string, Record<string, unknown>> = {}
await tree.walkAsync((node: TreeNode) => {
if (
node.model.atomic &&
node.model.raw.id &&
node.model.raw.id.length === 32 &&
node.model.id &&
node.model.id.length === 32 &&
!node.model.raw.speckle_type?.includes('Proxy') &&
node.model.raw.properties?.builtInCategory !== 'OST_Levels'
) {
const objectId = node.model.raw.id
const objectId = node.model.id
objectMap[objectId] = node.model.raw as SpeckleObject
const props = extractNestedProperties(node.model.raw)
for (const p of props) {
propertyMap[p.concatenatedPath] = p
// Extract only commonly used properties - avoid expensive full traversal
const objProps: Record<string, unknown> = {}
const propertyKey = p.concatenatedPath
const value = String(p.value)
if (!propertyIndexCache[propertyKey]) {
propertyIndexCache[propertyKey] = {}
// Direct properties
for (const [key, value] of Object.entries(node.model.raw)) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
const path = key
objProps[path] = value
if (!propertyMap[path]) {
propertyMap[path] = {
concatenatedPath: path,
value,
type: typeof value
} as PropertyInfoBase
}
}
if (!propertyIndexCache[propertyKey][value]) {
propertyIndexCache[propertyKey][value] = []
}
propertyIndexCache[propertyKey][value].push(objectId)
}
// Properties.* (one level)
const props = node.model.raw.properties
if (props && typeof props === 'object' && !Array.isArray(props)) {
for (const [key, value] of Object.entries(
props as Record<string, unknown>
)) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
const path = `properties.${key}`
objProps[path] = value
if (!propertyMap[path]) {
propertyMap[path] = {
concatenatedPath: path,
value,
type: typeof value
} as PropertyInfoBase
}
}
}
}
// Parameters.*.value (Revit objects)
const params = node.model.raw.parameters
if (params && typeof params === 'object' && !Array.isArray(params)) {
for (const [key, paramObj] of Object.entries(
params as Record<string, unknown>
)) {
if (
paramObj &&
typeof paramObj === 'object' &&
!Array.isArray(paramObj)
) {
const param = paramObj as Record<string, unknown>
if ('value' in param) {
const path = `parameters.${key}.value`
objProps[path] = param.value
if (!propertyMap[path]) {
propertyMap[path] = {
concatenatedPath: path,
value: param.value,
type: typeof param.value
} as PropertyInfoBase
}
}
}
}
}
objectProperties[objectId] = objProps
}
return true
}, subnode)
@@ -109,54 +126,58 @@ export function createViewerFilteringDataStore() {
rootObject: rootObject ? markRaw(rootObject) : null,
objectMap: markRaw(objectMap),
propertyMap,
_propertyIndexCache: propertyIndexCache
objectProperties
}
}
}
const buildPropertyIndex = (
dataSource: DataSource,
propertyKey: string
): Record<string, string[]> => {
if (dataSource._propertyIndexCache && dataSource._propertyIndexCache[propertyKey]) {
return dataSource._propertyIndexCache[propertyKey]
}
return {}
}
const queryObjects = (criteria: QueryCriteria): string[] => {
const matchingIds: string[] = []
const PRECISION = 4 // matches 0.0001 step
for (const dataSource of dataSources.value) {
const propertyIndex = buildPropertyIndex(dataSource, criteria.propertyKey)
if (!propertyIndex || isEmpty(propertyIndex)) {
// Check if property exists in propertyMap
const propertyInfo = dataSource.propertyMap[criteria.propertyKey]
if (!propertyInfo) {
continue
}
if (criteria.condition === ExistenceFilterCondition.IsSet) {
matchingIds.push(...flatten(Object.values(propertyIndex)))
} else if (criteria.condition === ExistenceFilterCondition.IsNotSet) {
const objectsWithProperty = new Set<string>(
flatten(Object.values(propertyIndex))
)
for (const [objectId] of Object.entries(dataSource.objectMap)) {
if (!objectsWithProperty.has(objectId)) {
// Find all objects that have this property - use pre-computed objectProperties
for (const [objectId, objProps] of Object.entries(
dataSource.objectProperties
)) {
const hasProperty = criteria.propertyKey in objProps
if (hasProperty) {
matchingIds.push(objectId)
}
}
} else if (criteria.minValue !== undefined || criteria.maxValue !== undefined) {
const minValue = criteria.minValue ?? -Infinity
const maxValue = criteria.maxValue ?? Infinity
} else if (criteria.condition === ExistenceFilterCondition.IsNotSet) {
// Find all objects that don't have this property
for (const [objectId, objProps] of Object.entries(
dataSource.objectProperties
)) {
const hasProperty = criteria.propertyKey in objProps
if (!hasProperty) {
matchingIds.push(objectId)
}
}
} else {
// For value-based filtering, check each object
for (const [objectId, objProps] of Object.entries(
dataSource.objectProperties
)) {
const value = objProps[criteria.propertyKey]
if (value === undefined) continue
let shouldInclude = false
for (const [value, objectIds] of Object.entries(propertyIndex)) {
const numericValue = Number(value)
if (!isNaN(numericValue)) {
// Only round for display purposes, not for filtering logic
let shouldInclude = false
if (criteria.minValue !== undefined || criteria.maxValue !== undefined) {
// Numeric filtering
const numericValue = Number(value)
if (isNaN(numericValue)) continue
const minValue = criteria.minValue ?? -Infinity
const maxValue = criteria.maxValue ?? Infinity
switch (criteria.condition) {
case NumericFilterCondition.IsBetween:
@@ -169,13 +190,11 @@ export function createViewerFilteringDataStore() {
shouldInclude = numericValue < maxValue
break
case NumericFilterCondition.IsEqualTo: {
// For equality, use a small tolerance to account for floating-point precision
const tolerance = Math.pow(10, -PRECISION)
shouldInclude = Math.abs(numericValue - minValue) <= tolerance
break
}
case NumericFilterCondition.IsNotEqualTo: {
// For inequality, use a small tolerance to account for floating-point precision
const tolerance = Math.pow(10, -PRECISION)
shouldInclude = Math.abs(numericValue - minValue) > tolerance
break
@@ -183,28 +202,16 @@ export function createViewerFilteringDataStore() {
default:
shouldInclude = numericValue >= minValue && numericValue <= maxValue
}
} else if (criteria.condition === StringFilterCondition.Is) {
// String filtering - exact match
shouldInclude = criteria.values.includes(String(value))
} else if (criteria.condition === StringFilterCondition.IsNot) {
// String filtering - exclude values
shouldInclude = !criteria.values.includes(String(value))
}
if (shouldInclude) {
matchingIds.push(...objectIds)
}
}
}
} 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 === StringFilterCondition.IsNot) {
if (criteria.values.length === 0) {
// Return empty array - nothing matches "is not" with no exclusions
} else {
const excludeValues = new Set(criteria.values)
for (const [value, objectIds] of Object.entries(propertyIndex)) {
if (!excludeValues.has(value)) {
matchingIds.push(...objectIds)
}
if (shouldInclude) {
matchingIds.push(objectId)
}
}
}
@@ -272,17 +279,18 @@ export function createViewerFilteringDataStore() {
.map((slice) => slice.objectIds)
)
)
return uniq(validObjectIds)
const result = uniq(validObjectIds)
return result
} else {
const lastSlice = dataSlices.value[dataSlices.value.length - 1]
return lastSlice?.intersectedObjectIds || []
const result = lastSlice?.intersectedObjectIds || []
return result
}
}
const clearDataOnRouteLeave = () => {
dataSourcesMap.value = {}
dataSlices.value = []
propertyExtractionCache.clear()
}
const setFilterLogic = (logic: FilterLogic) => {
@@ -301,8 +309,7 @@ export function createViewerFilteringDataStore() {
setFilterLogic,
currentFilterLogic,
dataSlices,
dataSources,
buildPropertyIndex
dataSources
}
}
@@ -83,18 +83,24 @@ export function useFilterUtilities(
}
/**
* Gets ALL values for a property from pre-computed indices (used for filtering logic)
* Gets ALL values for a property using pre-computed data (used for filtering logic)
*/
const getAllPropertyValues = (propertyKey: string): string[] => {
const allValues: string[] = []
for (const dataSource of dataStore.dataSources.value) {
if (
dataSource._propertyIndexCache &&
dataSource._propertyIndexCache[propertyKey]
) {
const propertyIndex = dataSource._propertyIndexCache[propertyKey]
allValues.push(...Object.keys(propertyIndex))
// Check if property exists in propertyMap first (quick check)
const propertyInfo = dataSource.propertyMap[propertyKey]
if (!propertyInfo) {
continue
}
// Collect values from pre-computed object properties
for (const [, objProps] of Object.entries(dataSource.objectProperties)) {
const value = objProps[propertyKey]
if (value !== undefined) {
allValues.push(String(value))
}
}
}
@@ -130,11 +136,89 @@ export function useFilterUtilities(
return []
}
/**
* Computes full property data for a given property key (min/max, valueGroups)
*/
const computeFullPropertyData = (propertyKey: string): PropertyInfo | null => {
const valueToObjectIds = new Map<string, string[]>()
for (const dataSource of dataStore.dataSources.value) {
// Check if property exists in this data source
if (!dataSource.propertyMap[propertyKey]) {
continue
}
// Collect values and their associated object IDs using pre-computed data
for (const [objectId, objProps] of Object.entries(dataSource.objectProperties)) {
const value = objProps[propertyKey]
if (value !== undefined) {
const stringValue = String(value)
if (!valueToObjectIds.has(stringValue)) {
valueToObjectIds.set(stringValue, [])
}
valueToObjectIds.get(stringValue)!.push(objectId)
}
}
}
if (valueToObjectIds.size === 0) {
return null
}
const uniqueValues = Array.from(valueToObjectIds.keys())
const firstValue = uniqueValues[0]
const isNumeric =
typeof firstValue === 'number' ||
(!isNaN(Number(firstValue)) && String(firstValue) !== '')
if (isNumeric) {
const numericValues = uniqueValues.map((v) => Number(v)).filter((v) => !isNaN(v))
const min = Math.min(...numericValues)
const max = Math.max(...numericValues)
const numericValueGroups: { value: number; id: string }[] = []
for (const value of uniqueValues) {
const objectIds = valueToObjectIds.get(value) || []
for (const objectId of objectIds) {
numericValueGroups.push({
value: Number(value),
id: objectId
})
}
}
return {
key: propertyKey,
type: 'number',
objectCount: valueToObjectIds.size,
min,
max,
valueGroups: numericValueGroups,
passMin: null,
passMax: null
} as NumericPropertyInfo
} else {
return {
key: propertyKey,
type: 'string',
objectCount: valueToObjectIds.size,
valueGroups: uniqueValues.map((value) => ({
value: String(value),
ids: valueToObjectIds.get(value) || []
}))
} as StringPropertyInfo
}
}
const createFilterData = (params: CreateFilterParams): FilterData => {
const { filter, id } = params
if (isNumericPropertyInfo(filter)) {
const numericFilter = filter as NumericPropertyInfo
// If the filter doesn't have full data, compute it now
const fullFilter =
filter.objectCount === 0 ? computeFullPropertyData(filter.key) || filter : filter
if (isNumericPropertyInfo(fullFilter)) {
const numericFilter = fullFilter as NumericPropertyInfo
const { min, max } = numericFilter
const range = max - min
@@ -178,7 +262,7 @@ export function useFilterUtilities(
selectedValues: [],
condition: StringFilterCondition.Is,
type: FilterType.String,
filter: filter as StringPropertyInfo,
filter: fullFilter as StringPropertyInfo,
isDefaultAllSelected: true
} satisfies StringFilterData
}
@@ -388,11 +472,11 @@ export function useFilterUtilities(
filter.condition === ExistenceFilterCondition.IsSet ||
filter.condition === ExistenceFilterCondition.IsNotSet)
) {
// Handle lazy loading: if isDefaultAllSelected is true and selectedValues is empty,
// use all available values from pre-computed indices
const values =
filter.isDefaultAllSelected && filter.selectedValues.length === 0
? getAllPropertyValues(filter.filter.key)
? filter.filter.valueGroups
?.map((vg) => String(vg.value))
.filter((v) => v !== 'null' && v !== 'undefined') || []
: filter.selectedValues
const { condition } = filter
@@ -533,43 +617,32 @@ export function useFilterUtilities(
}
/**
* Gets property options from the data store
* Gets property options from the data store (optimized to use propertyMap)
*/
const getPropertyOptionsFromDataStore = (): PropertyInfo[] => {
const allProperties = new Map<string, PropertyInfo>()
for (const dataSource of dataStore.dataSources.value) {
for (const [propertyKey] of Object.entries(dataSource.propertyMap)) {
if (shouldExcludeFromFiltering(propertyKey)) {
continue
}
const propertyKeys = Object.keys(dataSource.propertyMap)
for (const propertyKey of propertyKeys) {
if (allProperties.has(propertyKey)) {
continue
}
const propertyIndex = dataSource._propertyIndexCache?.[propertyKey] || {}
const values = Object.keys(propertyIndex)
if (values.length === 0) {
continue
}
const firstValue = values[0]
const isNumeric = !isNaN(Number(firstValue)) && firstValue !== ''
const propertyInfo = dataSource.propertyMap[propertyKey]
const value = propertyInfo.value
const isNumeric =
typeof value === 'number' || (!isNaN(Number(value)) && String(value) !== '')
if (isNumeric) {
const numericValues = values.map((v) => Number(v)).filter((v) => !isNaN(v))
const min = Math.min(...numericValues)
const max = Math.max(...numericValues)
allProperties.set(propertyKey, {
key: propertyKey,
type: 'number',
objectCount: values.length,
min,
max,
valueGroups: values.map((value) => ({ value: Number(value), id: '' })), // IDs not needed for selection
objectCount: 0,
min: 0,
max: 0,
valueGroups: [],
passMin: null,
passMax: null
} as NumericPropertyInfo)
@@ -577,11 +650,8 @@ export function useFilterUtilities(
allProperties.set(propertyKey, {
key: propertyKey,
type: 'string',
objectCount: values.length,
valueGroups: values.map((value) => ({
value,
ids: propertyIndex[value] || []
}))
objectCount: 0, // Not needed for selection
valueGroups: []
} as StringPropertyInfo)
}
}
@@ -153,19 +153,9 @@ export const usePropertyFilteringPostSetup = () => {
*/
const applyPropertyFilters = () => {
const objectIds = dataStore.getFinalObjectIds()
const extension = filteringExtension()
extension.resetFilters()
const hasAppliedFilters = filters.propertyFilters.value.some(
(filter) => filter.isApplied
)
if (objectIds.length > 0) {
extension.isolateObjects(objectIds, 'property-filters', false, true)
} else if (hasAppliedFilters) {
extension.isolateObjects(['no-match-ghost-all'], 'property-filters', false, true)
}
filters.isolatedObjectIds.value = objectIds
filters.filteredObjectsCount.value = objectIds.length
}
/**
@@ -181,6 +171,34 @@ export const usePropertyFilteringPostSetup = () => {
{ deep: true }
)
/**
* Watch for property filter results and apply to viewer extension
*/
watchTriggerable(
() => filters.filteredObjectsCount.value,
() => {
const extension = filteringExtension()
const objectIds = dataStore.getFinalObjectIds()
extension.resetFilters()
const hasAppliedFilters = filters.propertyFilters.value.some(
(filter) => filter.isApplied
)
if (objectIds.length > 0) {
extension.isolateObjects(objectIds, 'property-filters', false, true)
} else if (hasAppliedFilters) {
extension.isolateObjects(
['no-match-ghost-all'],
'property-filters',
false,
true
)
}
}
)
/**
* Watch for changes to filter logic (AND/OR)
*/
@@ -61,7 +61,6 @@ import {
usePropertyFilteringPostSetup,
useManualFilteringPostSetup
} from '~/lib/viewer/composables/setup/filters'
import { useFilteredObjectsCountPostSetup } from '~/lib/viewer/composables/setup/filteredObjectsCount'
import { useFilterUtilities } from '~/lib/viewer/composables/filtering/filtering'
import { useFilteringSetup } from '~/lib/viewer/composables/filtering/setup'
@@ -860,7 +859,7 @@ export function useViewerPostSetup() {
useFilterColoringPostSetup()
usePropertyFilteringPostSetup()
useManualFilteringPostSetup()
useFilteredObjectsCountPostSetup()
// useFilteredObjectsCountPostSetup() // Disabled - managing count manually in applyPropertyFilters
useDisableZoomOnEmbed()
useViewerCursorIntegration()
useViewerTreeIntegration()
@@ -35,7 +35,7 @@ export const FILTERS_POPULAR_PROPERTIES = [
'phaseCreated',
'ifcType',
'layer'
] as const
]
// UI Constants
export const PROPERTY_SELECTION_ITEM_HEIGHT = 36
@@ -141,7 +141,7 @@ export type DataSource = {
rootObject: Nullable<SpeckleObject>
objectMap: Record<string, SpeckleObject>
propertyMap: Record<string, PropertyInfoBase>
_propertyIndexCache?: Record<string, Record<string, string[]>>
objectProperties: Record<string, Record<string, unknown>>
}
export type ResourceInfo = {