mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
feat: vue nodes LOD system (#5631)
## Summary Replaced reactive (Vue-based) widget LOD with CSS visibility control. Performance doesn't dramatically improve, but we avoid the mount/unmount overhead during zoom/pan operations. This PR implements the visual component of LOD—complex widgets that need lifecycle management will be addressed separately. ### Problem & Solution Problem: we want LOD to improve rendering performance and visual feedback but discovered using reactivity in the current setup for it meant mounting/unmounting caused worse lag than the performance it aimed to fix. Switching to render all the details all the time but using css visibility proved to be the best solution. However, it doesn't improve rendering performance by much because the GPU texture size is the bottleneck (from TransformPane.vue CSS transforms) and not rasterization. Solution: Keep all nodes/widgets mounted, use CSS visibility: hidden for LOD. Trade memory for performance stability during zoom/pan/drag operations. ### Technical Decision We chose Performance > Memory: - CSS transforms create a single GPU texture whose size depends on node count, not widget complexity - Mounting/unmounting hundreds of widgets during zoom = noticeable lag from Vue VDOM diffing (since all components are mounted all the time because of viewport culling challenge/trade off see https://github.com/Comfy-Org/ComfyUI_frontend/pull/5510.) - CSS visibility changes = no reactivity overhead, smooth interactions - Result: Similar performance, but without interaction stutters This is the visual layer only. If we want a hook into the LOD state per node / widget that would be the next follow up system to implement. ### Next Steps (maybe) - Chunked (split up single Transform Pane transform layer) when rendering 1000+ nodes (maybe) - ~~Selective unmounting API for widgets that register as "expensive"~~ - ~~Client bound hydration system~~ ## Screenshots (if applicable) <!-- Add screenshots or video recording to help explain your changes --> <img width="1355" height="960" alt="image" src="https://github.com/user-attachments/assets/41474d1b-9dbe-4240-a8cf-f4c9ff51d8e0" /> <img width="1354" height="963" alt="image" src="https://github.com/user-attachments/assets/9f55edaa-5858-41b9-b6a8-c2d37e1649bd" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5631-feat-vue-nodes-LOD-system-2726d73d365081c6a6c4e14aa634f19c) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 54 KiB |
44
browser_tests/tests/vueNodes/lod.spec.ts
Normal file
44
browser_tests/tests/vueNodes/lod.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Nodes - LOD', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('default')
|
||||
})
|
||||
|
||||
test('should toggle LOD based on zoom threshold', async ({ comfyPage }) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(initialNodeCount).toBeGreaterThan(0)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-default.png')
|
||||
|
||||
const vueNodesContainer = comfyPage.vueNodes.nodes
|
||||
const textboxesInNodes = vueNodesContainer.getByRole('textbox')
|
||||
const buttonsInNodes = vueNodesContainer.getByRole('button')
|
||||
|
||||
await expect(textboxesInNodes.first()).toBeVisible()
|
||||
await expect(buttonsInNodes.first()).toBeVisible()
|
||||
|
||||
await comfyPage.zoom(120, 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-lod-active.png')
|
||||
|
||||
await expect(textboxesInNodes.first()).toBeHidden()
|
||||
await expect(buttonsInNodes.first()).toBeHidden()
|
||||
|
||||
await comfyPage.zoom(-120, 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-lod-inactive.png'
|
||||
)
|
||||
await expect(textboxesInNodes.first()).toBeVisible()
|
||||
await expect(buttonsInNodes.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
@@ -929,48 +929,6 @@ audio.comfy-audio.empty-audio-widget {
|
||||
}
|
||||
/* End of [Desktop] Electron window specific styles */
|
||||
|
||||
/* Vue Node LOD (Level of Detail) System */
|
||||
/* These classes control rendering detail based on zoom level */
|
||||
|
||||
/* Minimal LOD (zoom <= 0.4) - Title only for performance */
|
||||
.lg-node--lod-minimal {
|
||||
min-height: 32px;
|
||||
transition: min-height 0.2s ease;
|
||||
/* Performance optimizations */
|
||||
text-shadow: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.lg-node--lod-minimal .lg-node-body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Reduced LOD (0.4 < zoom <= 0.8) - Essential widgets, simplified styling */
|
||||
.lg-node--lod-reduced {
|
||||
transition: opacity 0.1s ease;
|
||||
/* Performance optimizations */
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.lg-node--lod-reduced .lg-widget-label,
|
||||
.lg-node--lod-reduced .lg-slot-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lg-node--lod-reduced .lg-slot {
|
||||
opacity: 0.6;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.lg-node--lod-reduced .lg-widget {
|
||||
margin: 2px 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Full LOD (zoom > 0.8) - Complete detail rendering */
|
||||
.lg-node--lod-full {
|
||||
/* Uses default styling - no overrides needed */
|
||||
}
|
||||
|
||||
.lg-node {
|
||||
/* Disable text selection on all nodes */
|
||||
@@ -996,23 +954,52 @@ audio.comfy-audio.empty-audio-widget {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Global performance optimizations for LOD */
|
||||
.lg-node--lod-minimal,
|
||||
.lg-node--lod-reduced {
|
||||
/* Remove ALL expensive paint effects */
|
||||
box-shadow: none !important;
|
||||
filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
text-shadow: none !important;
|
||||
-webkit-mask-image: none !important;
|
||||
mask-image: none !important;
|
||||
clip-path: none !important;
|
||||
/* START LOD specific styles */
|
||||
/* LOD styles - Custom CSS avoids 100+ Tailwind selectors that would slow style recalculation when .isLOD toggles */
|
||||
|
||||
.isLOD .lg-node {
|
||||
box-shadow: none;
|
||||
filter: none;
|
||||
backdrop-filter: none;
|
||||
text-shadow: none;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
clip-path: none;
|
||||
background-image: none;
|
||||
text-rendering: optimizeSpeed;
|
||||
border-radius: 0;
|
||||
contain: layout style;
|
||||
transition: none;
|
||||
|
||||
}
|
||||
|
||||
/* Reduce paint complexity for minimal LOD */
|
||||
.lg-node--lod-minimal {
|
||||
/* Skip complex borders */
|
||||
border-radius: 0 !important;
|
||||
/* Use solid colors only */
|
||||
background-image: none !important;
|
||||
.isLOD .lg-node > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lod-toggle {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.isLOD .lod-toggle {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
.lod-fallback {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.isLOD .lod-fallback {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.isLOD .image-preview img {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
|
||||
.isLOD .slot-dot {
|
||||
border-radius: 0;
|
||||
}
|
||||
/* END LOD specific styles */
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
class="transform-pane"
|
||||
:class="{ 'transform-pane--interacting': isInteracting }"
|
||||
class="absolute inset-0 w-full h-full pointer-events-none"
|
||||
:class="
|
||||
cn(
|
||||
isInteracting ? 'transform-pane--interacting' : 'will-change-auto',
|
||||
isLOD ? 'isLOD' : ''
|
||||
)
|
||||
"
|
||||
:style="transformStyle"
|
||||
@pointerdown="handlePointerDown"
|
||||
>
|
||||
@@ -18,6 +23,8 @@ import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
|
||||
import { useCanvasTransformSync } from '@/renderer/core/layout/transform/useCanvasTransformSync'
|
||||
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface TransformPaneProps {
|
||||
canvas?: LGraphCanvas
|
||||
@@ -34,6 +41,8 @@ const {
|
||||
isNodeInViewport
|
||||
} = useTransformState()
|
||||
|
||||
const { isLOD } = useLOD(camera)
|
||||
|
||||
const canvasElement = computed(() => props.canvas?.canvas)
|
||||
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
|
||||
settleDelay: 200,
|
||||
|
||||
@@ -94,17 +94,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Dimensions -->
|
||||
<div class="text-white text-xs text-center mt-2">
|
||||
<span v-if="imageError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingImage') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-gray-400">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
<div class="relative">
|
||||
<!-- Image Dimensions -->
|
||||
<div class="text-white text-xs text-center mt-2">
|
||||
<span v-if="imageError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingImage') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-gray-400">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
</div>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -119,6 +122,8 @@ import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
|
||||
interface ImagePreviewProps {
|
||||
/** Array of image URLs to display */
|
||||
readonly imageUrls: readonly string[]
|
||||
|
||||
@@ -10,12 +10,15 @@
|
||||
/>
|
||||
|
||||
<!-- Slot Name -->
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200"
|
||||
>
|
||||
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
|
||||
</span>
|
||||
<div class="relative">
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200 lod-toggle"
|
||||
>
|
||||
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
|
||||
</span>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -38,6 +41,7 @@ import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composabl
|
||||
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
import SlotConnectionDot from './SlotConnectionDot.vue'
|
||||
|
||||
interface InputSlotProps {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
bypassed,
|
||||
'will-change-transform': isDragging
|
||||
},
|
||||
lodCssClass,
|
||||
|
||||
shouldHandleNodePointerEvents
|
||||
? 'pointer-events-auto'
|
||||
: 'pointer-events-none'
|
||||
@@ -48,10 +48,9 @@
|
||||
</template>
|
||||
<!-- Header only updates on title/color changes -->
|
||||
<NodeHeader
|
||||
v-memo="[nodeData.title, lodLevel, isCollapsed]"
|
||||
v-memo="[nodeData.title, isCollapsed]"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
:lod-level="lodLevel"
|
||||
:collapsed="isCollapsed"
|
||||
@collapse="handleCollapse"
|
||||
@update:title="handleHeaderTitleUpdate"
|
||||
@@ -60,9 +59,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
(isMinimalLOD || isCollapsed) && executing && progress !== undefined
|
||||
"
|
||||
v-if="isCollapsed && executing && progress !== undefined"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-x-4 -bottom-[1px] translate-y-1/2 rounded-full',
|
||||
@@ -72,7 +69,7 @@
|
||||
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
|
||||
/>
|
||||
|
||||
<template v-if="!isMinimalLOD && !isCollapsed">
|
||||
<template v-if="!isCollapsed">
|
||||
<div class="mb-4 relative">
|
||||
<div :class="separatorClasses" />
|
||||
<!-- Progress bar for executing state -->
|
||||
@@ -96,28 +93,24 @@
|
||||
>
|
||||
<!-- Slots only rendered at full detail -->
|
||||
<NodeSlots
|
||||
v-if="shouldRenderSlots"
|
||||
v-memo="[nodeData.inputs?.length, nodeData.outputs?.length, lodLevel]"
|
||||
v-memo="[nodeData.inputs?.length, nodeData.outputs?.length]"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
:lod-level="lodLevel"
|
||||
/>
|
||||
|
||||
<!-- Widgets rendered at reduced+ detail -->
|
||||
<NodeWidgets
|
||||
v-if="shouldShowWidgets"
|
||||
v-memo="[nodeData.widgets?.length, lodLevel]"
|
||||
v-if="nodeData.widgets?.length"
|
||||
v-memo="[nodeData.widgets?.length]"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
:lod-level="lodLevel"
|
||||
/>
|
||||
|
||||
<!-- Custom content at reduced+ detail -->
|
||||
<NodeContent
|
||||
v-if="shouldShowContent"
|
||||
v-if="hasCustomContent"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
:lod-level="lodLevel"
|
||||
:image-urls="nodeImageUrls"
|
||||
/>
|
||||
<!-- Live preview image -->
|
||||
@@ -152,7 +145,6 @@ import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/compo
|
||||
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
|
||||
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
|
||||
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
|
||||
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
@@ -180,8 +172,7 @@ interface LGraphNodeProps {
|
||||
const {
|
||||
nodeData,
|
||||
error = null,
|
||||
readonly = false,
|
||||
zoomLevel = 1
|
||||
readonly = false
|
||||
} = defineProps<LGraphNodeProps>()
|
||||
|
||||
const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeSelect } =
|
||||
@@ -218,18 +209,6 @@ const bypassed = computed((): boolean => nodeData.mode === 4)
|
||||
// Use canvas interactions for proper wheel event handling and pointer event capture control
|
||||
const { handleWheel, shouldHandleNodePointerEvents } = useCanvasInteractions()
|
||||
|
||||
// LOD (Level of Detail) system based on zoom level
|
||||
const {
|
||||
lodLevel,
|
||||
shouldRenderWidgets,
|
||||
shouldRenderSlots,
|
||||
shouldRenderContent,
|
||||
lodCssClass
|
||||
} = useLOD(() => zoomLevel)
|
||||
|
||||
// Computed properties for template usage
|
||||
const isMinimalLOD = computed(() => lodLevel.value === LODLevel.MINIMAL)
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
@@ -271,28 +250,17 @@ const hasCustomContent = computed(() => {
|
||||
})
|
||||
|
||||
// Computed classes and conditions for better reusability
|
||||
const separatorClasses = cn(
|
||||
'bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full'
|
||||
)
|
||||
const progressClasses = cn('h-2 bg-primary-500 transition-all duration-300')
|
||||
const separatorClasses =
|
||||
'bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full lod-toggle'
|
||||
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
|
||||
|
||||
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
|
||||
() => nodeData.id,
|
||||
{
|
||||
isMinimalLOD,
|
||||
isCollapsed
|
||||
}
|
||||
)
|
||||
|
||||
// Common condition computations to avoid repetition
|
||||
const shouldShowWidgets = computed(
|
||||
() => shouldRenderWidgets.value && nodeData.widgets?.length
|
||||
)
|
||||
|
||||
const shouldShowContent = computed(
|
||||
() => shouldRenderContent.value && hasCustomContent.value
|
||||
)
|
||||
|
||||
const borderClass = computed(() => {
|
||||
if (hasAnyError.value) {
|
||||
return 'border-error'
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div class="lod-fallback absolute inset-0 w-full h-full bg-zinc-800"></div>
|
||||
</template>
|
||||
@@ -21,7 +21,6 @@ import { computed, onErrorCaptured, ref } from 'vue'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
|
||||
import ImagePreview from './ImagePreview.vue'
|
||||
|
||||
@@ -29,7 +28,6 @@ interface NodeContentProps {
|
||||
node?: LGraphNode // For backwards compatibility
|
||||
nodeData?: VueNodeData // New clean data structure
|
||||
readonly?: boolean
|
||||
lodLevel?: LODLevel
|
||||
imageUrls?: string[]
|
||||
}
|
||||
|
||||
|
||||
@@ -4,41 +4,44 @@
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="lg-node-header flex items-center justify-between p-4 rounded-t-2xl cursor-move w-full"
|
||||
class="lg-node-header p-4 rounded-t-2xl cursor-move"
|
||||
:data-testid="`node-header-${nodeData?.id || ''}`"
|
||||
@dblclick="handleDoubleClick"
|
||||
>
|
||||
<!-- Collapse/Expand Button -->
|
||||
<button
|
||||
v-show="!readonly"
|
||||
class="bg-transparent border-transparent flex items-center"
|
||||
data-testid="node-collapse-button"
|
||||
@click.stop="handleCollapse"
|
||||
@dblclick.stop
|
||||
>
|
||||
<i
|
||||
:class="collapsed ? 'pi pi-chevron-right' : 'pi pi-chevron-down'"
|
||||
class="text-xs leading-none relative top-px text-stone-200 dark-theme:text-slate-300"
|
||||
></i>
|
||||
</button>
|
||||
<div class="flex items-center justify-between relative">
|
||||
<!-- Collapse/Expand Button -->
|
||||
<button
|
||||
v-show="!readonly"
|
||||
class="bg-transparent border-transparent flex items-center lod-toggle"
|
||||
data-testid="node-collapse-button"
|
||||
@click.stop="handleCollapse"
|
||||
@dblclick.stop
|
||||
>
|
||||
<i
|
||||
:class="collapsed ? 'pi pi-chevron-right' : 'pi pi-chevron-down'"
|
||||
class="text-xs leading-none relative top-px text-stone-200 dark-theme:text-slate-300"
|
||||
></i>
|
||||
</button>
|
||||
|
||||
<!-- Node Title -->
|
||||
<div
|
||||
v-tooltip.top="tooltipConfig"
|
||||
class="text-sm font-bold truncate flex-1"
|
||||
data-testid="node-title"
|
||||
>
|
||||
<EditableText
|
||||
:model-value="displayTitle"
|
||||
:is-editing="isEditing"
|
||||
:input-attrs="{ 'data-testid': 'node-title-input' }"
|
||||
@edit="handleTitleEdit"
|
||||
@cancel="handleTitleCancel"
|
||||
/>
|
||||
<!-- Node Title -->
|
||||
<div
|
||||
v-tooltip.top="tooltipConfig"
|
||||
class="text-sm font-bold truncate flex-1 lod-toggle"
|
||||
data-testid="node-title"
|
||||
>
|
||||
<EditableText
|
||||
:model-value="displayTitle"
|
||||
:is-editing="isEditing"
|
||||
:input-attrs="{ 'data-testid': 'node-title-input' }"
|
||||
@edit="handleTitleEdit"
|
||||
@cancel="handleTitleCancel"
|
||||
/>
|
||||
</div>
|
||||
<LODFallback />
|
||||
</div>
|
||||
|
||||
<!-- Title Buttons -->
|
||||
<div v-if="!readonly" class="flex items-center">
|
||||
<div v-if="!readonly" class="flex items-center lod-toggle">
|
||||
<IconButton
|
||||
v-if="isSubgraphNode"
|
||||
size="sm"
|
||||
@@ -69,6 +72,8 @@ import {
|
||||
getNodeByLocatorId
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
|
||||
interface NodeHeaderProps {
|
||||
nodeData?: VueNodeData
|
||||
readonly?: boolean
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
<div
|
||||
v-for="(widget, index) in processedWidgets"
|
||||
:key="`widget-${index}-${widget.name}`"
|
||||
class="lg-widget-container relative flex items-center group"
|
||||
class="lg-widget-container flex items-center group"
|
||||
>
|
||||
<!-- Widget Input Slot Dot -->
|
||||
|
||||
<div
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity duration-150"
|
||||
>
|
||||
@@ -61,12 +62,10 @@ import type {
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
// Import widget components directly
|
||||
import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
|
||||
import {
|
||||
getComponent,
|
||||
isEssential,
|
||||
shouldRenderAsVue
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
@@ -77,10 +76,9 @@ import InputSlot from './InputSlot.vue'
|
||||
interface NodeWidgetsProps {
|
||||
nodeData?: VueNodeData
|
||||
readonly?: boolean
|
||||
lodLevel?: LODLevel
|
||||
}
|
||||
|
||||
const { nodeData, readonly, lodLevel } = defineProps<NodeWidgetsProps>()
|
||||
const { nodeData, readonly } = defineProps<NodeWidgetsProps>()
|
||||
|
||||
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
|
||||
useCanvasInteractions()
|
||||
@@ -125,18 +123,12 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
const widgets = nodeData.widgets as SafeWidgetData[]
|
||||
const result: ProcessedWidget[] = []
|
||||
|
||||
if (lodLevel === LODLevel.MINIMAL) {
|
||||
return []
|
||||
}
|
||||
|
||||
for (const widget of widgets) {
|
||||
if (widget.options?.hidden) continue
|
||||
if (widget.options?.canvasOnly) continue
|
||||
if (!widget.type) continue
|
||||
if (!shouldRenderAsVue(widget)) continue
|
||||
|
||||
if (lodLevel === LODLevel.REDUCED && !isEssential(widget.type)) continue
|
||||
|
||||
const vueComponent = getComponent(widget.type) || WidgetInputText
|
||||
|
||||
const simplified: SimplifiedWidget = {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs">⚠️</div>
|
||||
<div v-else v-tooltip.right="tooltipConfig" :class="slotWrapperClass">
|
||||
<!-- Slot Name -->
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200"
|
||||
>
|
||||
{{ slotData.name || `Output ${index}` }}
|
||||
</span>
|
||||
|
||||
<div class="relative">
|
||||
<!-- Slot Name -->
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200 lod-toggle"
|
||||
>
|
||||
{{ slotData.name || `Output ${index}` }}
|
||||
</span>
|
||||
<LODFallback />
|
||||
</div>
|
||||
<!-- Connection Dot -->
|
||||
<SlotConnectionDot
|
||||
ref="connectionDotRef"
|
||||
@@ -38,6 +40,7 @@ import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composabl
|
||||
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
import SlotConnectionDot from './SlotConnectionDot.vue'
|
||||
|
||||
interface OutputSlotProps {
|
||||
|
||||
@@ -24,6 +24,7 @@ defineExpose({
|
||||
>
|
||||
<div
|
||||
ref="slot-el"
|
||||
class="slot-dot"
|
||||
:style="{ backgroundColor: color }"
|
||||
:class="
|
||||
cn(
|
||||
|
||||
@@ -1,295 +1,141 @@
|
||||
# Level of Detail (LOD) Implementation Guide for Widgets
|
||||
# ComfyUI Widget LOD System: Architecture and Implementation
|
||||
|
||||
## What is Level of Detail (LOD)?
|
||||
## Executive Summary
|
||||
|
||||
Level of Detail is a technique used to optimize performance by showing different amounts of detail based on how zoomed in the user is. Think of it like Google Maps - when you're zoomed out looking at the whole country, you only see major cities and highways. When you zoom in close, you see street names, building details, and restaurants.
|
||||
The ComfyUI widget Level of Detail (LOD) system has evolved from a reactive, Vue-based approach to a CSS-driven, non-reactive implementation. This architectural shift was driven by performance requirements at scale (300-500+ nodes) and a deeper understanding of browser rendering pipelines. The current system prioritizes consistent performance over granular control, leveraging CSS visibility rules rather than component mounting/unmounting.
|
||||
|
||||
For ComfyUI nodes, this means:
|
||||
- **Zoomed out** (viewing many nodes): Show only essential controls, hide labels and descriptions
|
||||
- **Zoomed in** (focusing on specific nodes): Show all details, labels, help text, and visual polish
|
||||
## The Two Approaches: Reactive vs. Static LOD
|
||||
|
||||
## Why LOD Matters
|
||||
### Approach 1: Reactive LOD (Original Design)
|
||||
|
||||
Without LOD optimization:
|
||||
- 1000+ nodes with full detail = browser lag and poor performance
|
||||
- Text that's too small to read still gets rendered (wasted work)
|
||||
- Visual effects that are invisible at distance still consume GPU
|
||||
The original design envisioned a system where each widget would reactively respond to zoom level changes, controlling its own detail level through Vue's reactivity system. Widgets would import LOD utilities, compute what to show based on zoom level, and conditionally render elements using `v-if` and `v-show` directives.
|
||||
|
||||
With LOD optimization:
|
||||
- Smooth performance even with large node graphs
|
||||
- Battery life improvement on laptops
|
||||
- Better user experience across different zoom levels
|
||||
**The promise of this approach was compelling:** widgets could intelligently manage their complexity, progressively revealing detail as users zoomed in, much like how mapping applications work. Developers would have fine-grained control over performance optimization.
|
||||
|
||||
## How to Implement LOD in Your Widget
|
||||
### Approach 2: Static LOD with CSS (Current Implementation)
|
||||
|
||||
### Step 1: Get the LOD Context
|
||||
The implemented system takes a fundamentally different approach. All widget content is loaded and remains in the DOM at all times. Visual simplification happens through CSS rules, primarily using `visibility: hidden` and simplified visual representations (gray rectangles) at distant zoom levels. No reactive updates occur when zoom changes—only CSS rules apply differently.
|
||||
|
||||
Every widget component gets a `zoomLevel` prop. Use this to determine how much detail to show:
|
||||
**This approach seems counterintuitive at first:** aren't we wasting resources by keeping everything loaded? The answer reveals a deeper truth about modern browser rendering.
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import { useLOD } from '@/composables/graph/useLOD'
|
||||
## The GPU Texture Bottleneck
|
||||
|
||||
const props = defineProps<{
|
||||
widget: any
|
||||
zoomLevel: number
|
||||
// ... other props
|
||||
}>()
|
||||
The key insight driving the current architecture comes from understanding how browsers handle CSS transforms:
|
||||
|
||||
// Get LOD information
|
||||
const { lodScore, lodLevel } = useLOD(toRef(() => props.zoomLevel))
|
||||
</script>
|
||||
```
|
||||
When you apply a CSS transform to a parent element (the "transformpane" in ComfyUI's case), the browser promotes that entire subtree to a compositor layer. This creates a single GPU texture containing all the transformed content. Here's where traditional performance intuitions break down:
|
||||
|
||||
**Primary API:** Use `lodScore` (0-1) for granular control and smooth transitions
|
||||
**Convenience API:** Use `lodLevel` ('minimal'|'reduced'|'full') for simple on/off decisions
|
||||
### Traditional Assumption
|
||||
|
||||
### Step 2: Choose What to Show at Different Zoom Levels
|
||||
"If we render less content, we get better performance. Therefore, hiding complex widgets should improve zoom/pan performance."
|
||||
|
||||
#### Understanding the LOD Score
|
||||
- `lodScore` is a number from 0 to 1
|
||||
- 0 = completely zoomed out (show minimal detail)
|
||||
- 1 = fully zoomed in (show everything)
|
||||
- 0.5 = medium zoom (show some details)
|
||||
### Actual Browser Behavior
|
||||
|
||||
#### Understanding LOD Levels
|
||||
- `'minimal'` = zoom level 0.4 or below (very zoomed out)
|
||||
- `'reduced'` = zoom level 0.4 to 0.8 (medium zoom)
|
||||
- `'full'` = zoom level 0.8 or above (zoomed in close)
|
||||
When all nodes are children of a single transformed parent:
|
||||
|
||||
### Step 3: Implement Your Widget's LOD Strategy
|
||||
1. The browser creates one large GPU texture for the entire node graph
|
||||
2. The texture dimensions are determined by the bounding box of all content
|
||||
3. Whether individual pixels are simple (solid rectangles) or complex (detailed widgets) has minimal impact
|
||||
4. The performance bottleneck is the texture size itself, not the complexity of rasterization
|
||||
|
||||
Here's a complete example of a slider widget with LOD:
|
||||
This means that even if we reduce every node to a simple gray rectangle, we're still paying the cost of a massive GPU texture when viewing hundreds of nodes simultaneously. The texture dimensions remain the same whether it contains simple or complex content.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="number-widget">
|
||||
<!-- The main control always shows -->
|
||||
<input
|
||||
v-model="value"
|
||||
type="range"
|
||||
:min="widget.min"
|
||||
:max="widget.max"
|
||||
class="widget-slider"
|
||||
/>
|
||||
|
||||
<!-- Show label only when zoomed in enough to read it -->
|
||||
<label
|
||||
v-if="showLabel"
|
||||
class="widget-label"
|
||||
>
|
||||
{{ widget.name }}
|
||||
</label>
|
||||
|
||||
<!-- Show precise value only when fully zoomed in -->
|
||||
<span
|
||||
v-if="showValue"
|
||||
class="widget-value"
|
||||
>
|
||||
{{ formattedValue }}
|
||||
</span>
|
||||
|
||||
<!-- Show description only at full detail -->
|
||||
<div
|
||||
v-if="showDescription && widget.description"
|
||||
class="widget-description"
|
||||
>
|
||||
{{ widget.description }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
## Two Distinct Performance Concerns
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import { useLOD } from '@/composables/graph/useLOD'
|
||||
The analysis reveals two often-conflated performance considerations that should be understood separately:
|
||||
|
||||
const props = defineProps<{
|
||||
widget: any
|
||||
zoomLevel: number
|
||||
}>()
|
||||
### 1. Rendering Performance
|
||||
|
||||
const { lodScore, lodLevel } = useLOD(toRef(() => props.zoomLevel))
|
||||
**Question:** How fast can the browser paint and composite the node graph during interactions?
|
||||
|
||||
// Define when to show each element
|
||||
const showLabel = computed(() => {
|
||||
// Show label when user can actually read it
|
||||
return lodScore.value > 0.4 // Roughly 12px+ text size
|
||||
})
|
||||
**Traditional thinking:** Show less content → render faster
|
||||
**Reality with CSS transforms:** GPU texture size dominates performance, not content complexity
|
||||
|
||||
const showValue = computed(() => {
|
||||
// Show precise value only when zoomed in close
|
||||
return lodScore.value > 0.7 // User is focused on this specific widget
|
||||
})
|
||||
The CSS transform approach means that zoom, pan, and drag operations are already optimized—they're just transforming an existing GPU texture. The cost is in the initial rasterization and texture upload, which happens regardless of content complexity when texture dimensions are fixed.
|
||||
|
||||
const showDescription = computed(() => {
|
||||
// Description only at full detail
|
||||
return lodLevel.value === 'full' // Maximum zoom level
|
||||
})
|
||||
### 2. Memory and Lifecycle Management
|
||||
|
||||
// You can also use LOD for styling
|
||||
const widgetClasses = computed(() => {
|
||||
const classes = ['number-widget']
|
||||
|
||||
if (lodLevel.value === 'minimal') {
|
||||
classes.push('widget--minimal')
|
||||
}
|
||||
|
||||
return classes
|
||||
})
|
||||
</script>
|
||||
**Question:** How much memory do widget instances consume, and what's the cost of maintaining them?
|
||||
|
||||
<style scoped>
|
||||
/* Apply different styles based on LOD */
|
||||
.widget--minimal {
|
||||
/* Simplified appearance when zoomed out */
|
||||
.widget-slider {
|
||||
height: 4px; /* Thinner slider */
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
This is where unmounting widgets might theoretically help:
|
||||
|
||||
/* Normal styling */
|
||||
.widget-slider {
|
||||
height: 8px;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
- Complex widgets (3D viewers, chart renderers) might hold significant memory
|
||||
- Event listeners and reactive watchers consume resources
|
||||
- Some widgets might run background processes or animations
|
||||
|
||||
.widget-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
However, the cost of mounting/unmounting hundreds of widgets on zoom changes could create worse performance problems than the memory savings provide. Vue's virtual DOM diffing for hundreds of nodes is expensive, potentially causing noticeable lag during zoom transitions.
|
||||
|
||||
.widget-value {
|
||||
font-family: monospace;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-accent);
|
||||
}
|
||||
## Design Philosophy and Trade-offs
|
||||
|
||||
.widget-description {
|
||||
font-size: 0.6rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
The current CSS-based approach makes several deliberate trade-offs:
|
||||
|
||||
## Common LOD Patterns
|
||||
### What We Optimize For
|
||||
|
||||
### Pattern 1: Essential vs. Nice-to-Have
|
||||
```typescript
|
||||
// Always show the main functionality
|
||||
const showMainControl = computed(() => true)
|
||||
1. **Consistent, predictable performance** - No reactivity means no sudden performance cliffs
|
||||
2. **Smooth zoom/pan interactions** - CSS transforms are hardware-accelerated
|
||||
3. **Simple widget development** - Widget authors don't need to implement LOD logic
|
||||
4. **Reliable state preservation** - Widgets never lose state from unmounting
|
||||
|
||||
// Granular control with lodScore
|
||||
const showLabels = computed(() => lodScore.value > 0.4)
|
||||
const labelOpacity = computed(() => Math.max(0.3, lodScore.value))
|
||||
### What We Accept
|
||||
|
||||
// Simple control with lodLevel
|
||||
const showExtras = computed(() => lodLevel.value === 'full')
|
||||
```
|
||||
1. **Higher baseline memory usage** - All widgets remain mounted
|
||||
2. **Less granular control** - Widgets can't optimize their own LOD behavior
|
||||
3. **Potential waste for exotic widgets** - A 3D renderer widget still runs when hidden
|
||||
|
||||
### Pattern 2: Smooth Opacity Transitions
|
||||
```typescript
|
||||
// Gradually fade elements based on zoom
|
||||
const labelOpacity = computed(() => {
|
||||
// Fade in from zoom 0.3 to 0.6
|
||||
return Math.max(0, Math.min(1, (lodScore.value - 0.3) / 0.3))
|
||||
})
|
||||
```
|
||||
## Open Questions and Future Considerations
|
||||
|
||||
### Pattern 3: Progressive Detail
|
||||
```typescript
|
||||
const detailLevel = computed(() => {
|
||||
if (lodScore.value < 0.3) return 'none'
|
||||
if (lodScore.value < 0.6) return 'basic'
|
||||
if (lodScore.value < 0.8) return 'standard'
|
||||
return 'full'
|
||||
})
|
||||
```
|
||||
### Should widgets have any LOD control?
|
||||
|
||||
## LOD Guidelines by Widget Type
|
||||
The current system provides a uniform gray rectangle fallback with CSS visibility hiding. This works for 99% of widgets, but raises questions:
|
||||
|
||||
### Text Input Widgets
|
||||
- **Always show**: The input field itself
|
||||
- **Medium zoom**: Show label
|
||||
- **High zoom**: Show placeholder text, validation messages
|
||||
- **Full zoom**: Show character count, format hints
|
||||
**Scenario:** A widget renders a complex 3D scene or runs expensive computations
|
||||
**Current behavior:** Hidden via CSS but still mounted
|
||||
**Question:** Should such widgets be able to opt into unmounting at distance?
|
||||
|
||||
### Button Widgets
|
||||
- **Always show**: The button
|
||||
- **Medium zoom**: Show button text
|
||||
- **High zoom**: Show button description
|
||||
- **Full zoom**: Show keyboard shortcuts, tooltips
|
||||
The challenge is that introducing selective unmounting would require:
|
||||
|
||||
### Selection Widgets (Dropdown, Radio)
|
||||
- **Always show**: The current selection
|
||||
- **Medium zoom**: Show option labels
|
||||
- **High zoom**: Show all options when expanded
|
||||
- **Full zoom**: Show option descriptions, icons
|
||||
- Maintaining widget state across mount/unmount cycles
|
||||
- Accepting the performance cost of remounting when zooming in
|
||||
- Adding complexity to the widget API
|
||||
|
||||
### Complex Widgets (Color Picker, File Browser)
|
||||
- **Always show**: Simplified representation (color swatch, filename)
|
||||
- **Medium zoom**: Show basic controls
|
||||
- **High zoom**: Show full interface
|
||||
- **Full zoom**: Show advanced options, previews
|
||||
### Could we reduce GPU texture size?
|
||||
|
||||
## Design Collaboration Guidelines
|
||||
Since texture dimensions are the limiting factor, could we:
|
||||
|
||||
### For Designers
|
||||
When designing widgets, consider creating variants for different zoom levels:
|
||||
- Use multiple compositor layers for different regions (chunk the transformpane)?
|
||||
- Render the nodes using the canvas fallback when 500+ nodes and < 30% zoom.
|
||||
|
||||
1. **Minimal Design** (far away view)
|
||||
- Essential elements only
|
||||
- Higher contrast for visibility
|
||||
- Simplified shapes and fewer details
|
||||
These approaches would require significant architectural changes and might introduce their own performance trade-offs.
|
||||
|
||||
2. **Standard Design** (normal view)
|
||||
- Balanced detail and simplicity
|
||||
- Clear labels and readable text
|
||||
- Good for most use cases
|
||||
### Is there a hybrid approach?
|
||||
|
||||
3. **Full Detail Design** (close-up view)
|
||||
- All labels, descriptions, and help text
|
||||
- Rich visual effects and polish
|
||||
- Maximum information density
|
||||
Could we identify specific threshold scenarios where reactive LOD makes sense?
|
||||
|
||||
### Design Handoff Checklist
|
||||
- [ ] Specify which elements are essential vs. nice-to-have
|
||||
- [ ] Define minimum readable sizes for text elements
|
||||
- [ ] Provide simplified versions for distant viewing
|
||||
- [ ] Consider color contrast at different opacity levels
|
||||
- [ ] Test designs at multiple zoom levels
|
||||
- When node count is low (< 50 nodes)
|
||||
- For specifically registered "expensive" widgets
|
||||
- At extreme zoom levels only
|
||||
|
||||
## Testing Your LOD Implementation
|
||||
## Implementation Guidelines
|
||||
|
||||
### Manual Testing
|
||||
1. Create a workflow with your widget
|
||||
2. Zoom out until nodes are very small
|
||||
3. Verify essential functionality still works
|
||||
4. Zoom in gradually and check that details appear smoothly
|
||||
5. Test performance with 50+ nodes containing your widget
|
||||
Given the current architecture, here's how to work within the system:
|
||||
|
||||
### Performance Considerations
|
||||
- Avoid complex calculations in LOD computed properties
|
||||
- Use `v-if` instead of `v-show` for elements that won't render
|
||||
- Consider using `v-memo` for expensive widget content
|
||||
- Test on lower-end devices
|
||||
### For Widget Developers
|
||||
|
||||
### Common Mistakes
|
||||
❌ **Don't**: Hide the main widget functionality at any zoom level
|
||||
❌ **Don't**: Use complex animations that trigger at every zoom change
|
||||
❌ **Don't**: Make LOD thresholds too sensitive (causes flickering)
|
||||
❌ **Don't**: Forget to test with real content and edge cases
|
||||
1. **Build widgets assuming they're always visible** - Don't rely on mount/unmount for cleanup
|
||||
2. **Use CSS classes for zoom-responsive styling** - Let CSS handle visual changes
|
||||
3. **Minimize background processing** - Assume your widget is always running
|
||||
4. **Consider requestAnimationFrame throttling** - For animations that won't be visible when zoomed out
|
||||
|
||||
✅ **Do**: Keep essential functionality always visible
|
||||
✅ **Do**: Use smooth transitions between LOD levels
|
||||
✅ **Do**: Test with varying content lengths and types
|
||||
✅ **Do**: Consider accessibility at all zoom levels
|
||||
### For System Architects
|
||||
|
||||
## Getting Help
|
||||
1. **Monitor GPU memory usage** - The single texture approach has memory implications
|
||||
2. **Consider viewport culling** - Not rendering off-screen nodes could reduce texture size
|
||||
3. **Profile real-world workflows** - Theoretical performance differs from actual usage patterns
|
||||
4. **Document the architecture clearly** - The non-obvious performance characteristics need explanation
|
||||
|
||||
- Check existing widgets in `src/components/graph/vueNodes/widgets/` for examples
|
||||
- Ask in the ComfyUI frontend Discord for LOD implementation questions
|
||||
- Test your changes with the LOD debug panel (top-right in GraphCanvas)
|
||||
- Profile performance impact using browser dev tools
|
||||
## Conclusion
|
||||
|
||||
The ComfyUI LOD system represents a pragmatic choice: accepting higher memory usage and less granular control in exchange for predictable performance and implementation simplicity. By understanding that GPU texture dimensions—not rasterization complexity—drive performance in a CSS-transform-based architecture, the team has chosen an approach that may seem counterintuitive but actually aligns with browser rendering realities.
|
||||
|
||||
The system works well for the common case of hundreds of relatively simple widgets. Edge cases involving genuinely expensive widgets may need future consideration, but the current approach provides a solid foundation that avoids the performance pitfalls of reactive LOD at scale.
|
||||
|
||||
The key insight—that showing less doesn't necessarily mean rendering faster when everything lives in a single GPU texture—challenges conventional web performance wisdom and demonstrates the importance of understanding the full rendering pipeline when making architectural decisions.
|
||||
|
||||
@@ -2,186 +2,33 @@
|
||||
* Level of Detail (LOD) composable for Vue-based node rendering
|
||||
*
|
||||
* Provides dynamic quality adjustment based on zoom level to maintain
|
||||
* performance with large node graphs. Uses zoom thresholds to determine
|
||||
* how much detail to render for each node component.
|
||||
*
|
||||
* ## LOD Levels
|
||||
*
|
||||
* - **FULL** (zoom > 0.8): Complete rendering with all widgets, slots, and content
|
||||
* - **REDUCED** (0.4 < zoom <= 0.8): Essential widgets only, simplified slots
|
||||
* - **MINIMAL** (zoom <= 0.4): Title only, no widgets or slots
|
||||
*
|
||||
* ## Performance Benefits
|
||||
*
|
||||
* - Reduces DOM element count by up to 80% at low zoom levels
|
||||
* - Minimizes layout calculations and paint operations
|
||||
* - Enables smooth performance with 1000+ nodes
|
||||
* - Maintains visual fidelity when detail is actually visible
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { lodLevel, shouldRenderWidgets, shouldRenderSlots } = useLOD(zoomRef)
|
||||
*
|
||||
* // In template
|
||||
* <NodeWidgets v-if="shouldRenderWidgets" />
|
||||
* <NodeSlots v-if="shouldRenderSlots" />
|
||||
* ```
|
||||
*/
|
||||
import { type MaybeRefOrGetter, computed, readonly, toRef } from 'vue'
|
||||
* performance with large node graphs. Uses zoom threshold based on DPR
|
||||
* to determine how much detail to render for each node component.
|
||||
* Default minFontSize = 8px
|
||||
* Default zoomThreshold = 0.57 (On a DPR = 1 monitor)
|
||||
**/
|
||||
import { useDevicePixelRatio } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
export enum LODLevel {
|
||||
MINIMAL = 'minimal', // zoom <= 0.4
|
||||
REDUCED = 'reduced', // 0.4 < zoom <= 0.8
|
||||
FULL = 'full' // zoom > 0.8
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
interface Camera {
|
||||
z: number // zoom level
|
||||
}
|
||||
|
||||
interface LODConfig {
|
||||
renderWidgets: boolean
|
||||
renderSlots: boolean
|
||||
renderContent: boolean
|
||||
renderSlotLabels: boolean
|
||||
renderWidgetLabels: boolean
|
||||
cssClass: string
|
||||
}
|
||||
export function useLOD(camera: Camera) {
|
||||
const isLOD = computed(() => {
|
||||
const { pixelRatio } = useDevicePixelRatio()
|
||||
const baseFontSize = 14
|
||||
const dprAdjustment = Math.sqrt(pixelRatio.value)
|
||||
|
||||
// LOD configuration for each level
|
||||
const LOD_CONFIGS: Record<LODLevel, LODConfig> = {
|
||||
[LODLevel.FULL]: {
|
||||
renderWidgets: true,
|
||||
renderSlots: true,
|
||||
renderContent: true,
|
||||
renderSlotLabels: true,
|
||||
renderWidgetLabels: true,
|
||||
cssClass: 'lg-node--lod-full'
|
||||
},
|
||||
[LODLevel.REDUCED]: {
|
||||
renderWidgets: true,
|
||||
renderSlots: true,
|
||||
renderContent: false,
|
||||
renderSlotLabels: false,
|
||||
renderWidgetLabels: false,
|
||||
cssClass: 'lg-node--lod-reduced'
|
||||
},
|
||||
[LODLevel.MINIMAL]: {
|
||||
renderWidgets: false,
|
||||
renderSlots: false,
|
||||
renderContent: false,
|
||||
renderSlotLabels: false,
|
||||
renderWidgetLabels: false,
|
||||
cssClass: 'lg-node--lod-minimal'
|
||||
}
|
||||
}
|
||||
const settingStore = useSettingStore()
|
||||
const minFontSize = settingStore.get('LiteGraph.Canvas.MinFontSizeForLOD') //default 8
|
||||
const threshold =
|
||||
Math.round((minFontSize / (baseFontSize * dprAdjustment)) * 100) / 100 //round to 2 decimal places i.e 0.86
|
||||
|
||||
/**
|
||||
* Create LOD (Level of Detail) state based on zoom level
|
||||
*
|
||||
* @param zoomRef - Reactive reference to current zoom level (camera.z)
|
||||
* @returns LOD state and configuration
|
||||
*/
|
||||
export function useLOD(zoomRefMaybe: MaybeRefOrGetter<number>) {
|
||||
const zoomRef = toRef(zoomRefMaybe)
|
||||
// Continuous LOD score (0-1) for smooth transitions
|
||||
const lodScore = computed(() => {
|
||||
const zoom = zoomRef.value
|
||||
return Math.max(0, Math.min(1, zoom))
|
||||
return camera.z < threshold
|
||||
})
|
||||
|
||||
// Determine current LOD level based on zoom
|
||||
const lodLevel = computed<LODLevel>(() => {
|
||||
const zoom = zoomRef.value
|
||||
|
||||
if (zoom > 0.8) return LODLevel.FULL
|
||||
if (zoom > 0.4) return LODLevel.REDUCED
|
||||
return LODLevel.MINIMAL
|
||||
})
|
||||
|
||||
// Get configuration for current LOD level
|
||||
const lodConfig = computed<LODConfig>(() => LOD_CONFIGS[lodLevel.value])
|
||||
|
||||
// Convenience computed properties for common rendering decisions
|
||||
const shouldRenderWidgets = computed(() => lodConfig.value.renderWidgets)
|
||||
const shouldRenderSlots = computed(() => lodConfig.value.renderSlots)
|
||||
const shouldRenderContent = computed(() => lodConfig.value.renderContent)
|
||||
const shouldRenderSlotLabels = computed(
|
||||
() => lodConfig.value.renderSlotLabels
|
||||
)
|
||||
const shouldRenderWidgetLabels = computed(
|
||||
() => lodConfig.value.renderWidgetLabels
|
||||
)
|
||||
|
||||
// CSS class for styling based on LOD level
|
||||
const lodCssClass = computed(() => lodConfig.value.cssClass)
|
||||
|
||||
// Get essential widgets for reduced LOD (only interactive controls)
|
||||
const getEssentialWidgets = (widgets: unknown[]): unknown[] => {
|
||||
if (lodLevel.value === LODLevel.FULL) return widgets
|
||||
if (lodLevel.value === LODLevel.MINIMAL) return []
|
||||
|
||||
// For reduced LOD, filter to essential widget types only
|
||||
return widgets.filter((widget: any) => {
|
||||
const type = widget?.type?.toLowerCase()
|
||||
return [
|
||||
'combo',
|
||||
'select',
|
||||
'toggle',
|
||||
'boolean',
|
||||
'slider',
|
||||
'number'
|
||||
].includes(type)
|
||||
})
|
||||
}
|
||||
|
||||
// Performance metrics for debugging
|
||||
const lodMetrics = computed(() => ({
|
||||
level: lodLevel.value,
|
||||
zoom: zoomRef.value,
|
||||
widgetCount: shouldRenderWidgets.value ? 'full' : 'none',
|
||||
slotCount: shouldRenderSlots.value ? 'full' : 'none'
|
||||
}))
|
||||
|
||||
return {
|
||||
// Core LOD state
|
||||
lodLevel: readonly(lodLevel),
|
||||
lodConfig: readonly(lodConfig),
|
||||
lodScore: readonly(lodScore),
|
||||
|
||||
// Rendering decisions
|
||||
shouldRenderWidgets,
|
||||
shouldRenderSlots,
|
||||
shouldRenderContent,
|
||||
shouldRenderSlotLabels,
|
||||
shouldRenderWidgetLabels,
|
||||
|
||||
// Styling
|
||||
lodCssClass,
|
||||
|
||||
// Utilities
|
||||
getEssentialWidgets,
|
||||
lodMetrics
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LOD level thresholds for configuration or debugging
|
||||
*/
|
||||
export const LOD_THRESHOLDS = {
|
||||
FULL_THRESHOLD: 0.8,
|
||||
REDUCED_THRESHOLD: 0.4,
|
||||
MINIMAL_THRESHOLD: 0.0
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Check if zoom level supports a specific feature
|
||||
*/
|
||||
export function supportsFeatureAtZoom(
|
||||
zoom: number,
|
||||
feature: keyof LODConfig
|
||||
): boolean {
|
||||
const level =
|
||||
zoom > 0.8
|
||||
? LODLevel.FULL
|
||||
: zoom > 0.4
|
||||
? LODLevel.REDUCED
|
||||
: LODLevel.MINIMAL
|
||||
return LOD_CONFIGS[level][feature] as boolean
|
||||
return { isLOD }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
export const useNodePreviewState = (
|
||||
nodeIdMaybe: MaybeRefOrGetter<string>,
|
||||
options?: {
|
||||
isMinimalLOD?: Ref<boolean>
|
||||
isCollapsed?: Ref<boolean>
|
||||
}
|
||||
) => {
|
||||
@@ -32,14 +31,10 @@ export const useNodePreviewState = (
|
||||
})
|
||||
|
||||
const shouldShowPreviewImg = computed(() => {
|
||||
if (!options?.isMinimalLOD || !options?.isCollapsed) {
|
||||
if (!options?.isCollapsed) {
|
||||
return hasPreview.value
|
||||
}
|
||||
return (
|
||||
!options.isMinimalLOD.value &&
|
||||
!options.isCollapsed.value &&
|
||||
hasPreview.value
|
||||
)
|
||||
return !options.isCollapsed.value && hasPreview.value
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<template>
|
||||
<Textarea
|
||||
v-model="localValue"
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
|
||||
:placeholder="placeholder || widget.name || ''"
|
||||
size="small"
|
||||
rows="3"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
<div class="relative">
|
||||
<Textarea
|
||||
v-model="localValue"
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs lod-toggle')"
|
||||
:placeholder="placeholder || widget.name || ''"
|
||||
size="small"
|
||||
rows="3"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -23,6 +26,7 @@ import {
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
import LODFallback from '../../components/LODFallback.vue'
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -3,6 +3,8 @@ import { noop } from 'es-toolkit'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import LODFallback from '../../../components/LODFallback.vue'
|
||||
|
||||
defineProps<{
|
||||
widget: Pick<SimplifiedWidget<string | number | undefined>, 'name'>
|
||||
}>()
|
||||
@@ -12,19 +14,25 @@ defineProps<{
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 h-[30px] overscroll-contain"
|
||||
>
|
||||
<p
|
||||
v-if="widget.name"
|
||||
class="text-sm text-stone-200 dark-theme:text-slate-200 font-normal flex-1 truncate w-20"
|
||||
>
|
||||
{{ widget.name }}
|
||||
</p>
|
||||
<div
|
||||
class="w-75 cursor-default"
|
||||
@pointerdown.stop="noop"
|
||||
@pointermove.stop="noop"
|
||||
@pointerup.stop="noop"
|
||||
>
|
||||
<slot />
|
||||
<div class="relative h-6 flex items-center mr-4">
|
||||
<p
|
||||
v-if="widget.name"
|
||||
class="text-sm text-stone-200 dark-theme:text-slate-200 font-normal flex-1 truncate w-20 lod-toggle"
|
||||
>
|
||||
{{ widget.name }}
|
||||
</p>
|
||||
<LODFallback />
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="w-75 cursor-default lod-toggle"
|
||||
@pointerdown.stop="noop"
|
||||
@pointermove.stop="noop"
|
||||
@pointerup.stop="noop"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -57,17 +57,6 @@ vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/lod/useLOD', () => ({
|
||||
useLOD: () => ({
|
||||
lodLevel: { value: 0 },
|
||||
shouldRenderWidgets: { value: true },
|
||||
shouldRenderSlots: { value: true },
|
||||
shouldRenderContent: { value: false },
|
||||
lodCssClass: { value: '' }
|
||||
}),
|
||||
LODLevel: { MINIMAL: 0 }
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/execution/useNodeExecutionState',
|
||||
() => ({
|
||||
|
||||
@@ -1,270 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
|
||||
import {
|
||||
LODLevel,
|
||||
LOD_THRESHOLDS,
|
||||
supportsFeatureAtZoom,
|
||||
useLOD
|
||||
} from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
|
||||
const mockSettingStore = reactive({
|
||||
get: vi.fn(() => 8)
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
|
||||
describe('useLOD', () => {
|
||||
describe('LOD level detection', () => {
|
||||
it('should return MINIMAL for zoom <= 0.4', () => {
|
||||
const zoomRef = ref(0.4)
|
||||
const { lodLevel } = useLOD(zoomRef)
|
||||
expect(lodLevel.value).toBe(LODLevel.MINIMAL)
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
|
||||
zoomRef.value = 0.2
|
||||
expect(lodLevel.value).toBe(LODLevel.MINIMAL)
|
||||
|
||||
zoomRef.value = 0.1
|
||||
expect(lodLevel.value).toBe(LODLevel.MINIMAL)
|
||||
})
|
||||
|
||||
it('should return REDUCED for 0.4 < zoom <= 0.8', () => {
|
||||
const zoomRef = ref(0.5)
|
||||
const { lodLevel } = useLOD(zoomRef)
|
||||
expect(lodLevel.value).toBe(LODLevel.REDUCED)
|
||||
|
||||
zoomRef.value = 0.6
|
||||
expect(lodLevel.value).toBe(LODLevel.REDUCED)
|
||||
|
||||
zoomRef.value = 0.8
|
||||
expect(lodLevel.value).toBe(LODLevel.REDUCED)
|
||||
})
|
||||
|
||||
it('should return FULL for zoom > 0.8', () => {
|
||||
const zoomRef = ref(0.9)
|
||||
const { lodLevel } = useLOD(zoomRef)
|
||||
expect(lodLevel.value).toBe(LODLevel.FULL)
|
||||
|
||||
zoomRef.value = 1.0
|
||||
expect(lodLevel.value).toBe(LODLevel.FULL)
|
||||
|
||||
zoomRef.value = 2.5
|
||||
expect(lodLevel.value).toBe(LODLevel.FULL)
|
||||
})
|
||||
|
||||
it('should be reactive to zoom changes', () => {
|
||||
const zoomRef = ref(0.2)
|
||||
const { lodLevel } = useLOD(zoomRef)
|
||||
|
||||
expect(lodLevel.value).toBe(LODLevel.MINIMAL)
|
||||
|
||||
zoomRef.value = 0.6
|
||||
expect(lodLevel.value).toBe(LODLevel.REDUCED)
|
||||
|
||||
zoomRef.value = 1.0
|
||||
expect(lodLevel.value).toBe(LODLevel.FULL)
|
||||
})
|
||||
mockSettingStore.get.mockReturnValue(8)
|
||||
})
|
||||
|
||||
describe('rendering decisions', () => {
|
||||
it('should disable all rendering for MINIMAL LOD', () => {
|
||||
const zoomRef = ref(0.2)
|
||||
const {
|
||||
shouldRenderWidgets,
|
||||
shouldRenderSlots,
|
||||
shouldRenderContent,
|
||||
shouldRenderSlotLabels,
|
||||
shouldRenderWidgetLabels
|
||||
} = useLOD(zoomRef)
|
||||
it('should calculate isLOD value based on zoom threshold correctly', async () => {
|
||||
vi.stubGlobal('devicePixelRatio', 1)
|
||||
|
||||
expect(shouldRenderWidgets.value).toBe(false)
|
||||
expect(shouldRenderSlots.value).toBe(false)
|
||||
expect(shouldRenderContent.value).toBe(false)
|
||||
expect(shouldRenderSlotLabels.value).toBe(false)
|
||||
expect(shouldRenderWidgetLabels.value).toBe(false)
|
||||
})
|
||||
const camera = reactive({ z: 1 })
|
||||
const { isLOD } = useLOD(camera)
|
||||
|
||||
it('should enable widgets/slots but disable labels for REDUCED LOD', () => {
|
||||
const zoomRef = ref(0.6)
|
||||
const {
|
||||
shouldRenderWidgets,
|
||||
shouldRenderSlots,
|
||||
shouldRenderContent,
|
||||
shouldRenderSlotLabels,
|
||||
shouldRenderWidgetLabels
|
||||
} = useLOD(zoomRef)
|
||||
await nextTick()
|
||||
expect(isLOD.value).toBe(false)
|
||||
|
||||
expect(shouldRenderWidgets.value).toBe(true)
|
||||
expect(shouldRenderSlots.value).toBe(true)
|
||||
expect(shouldRenderContent.value).toBe(false)
|
||||
expect(shouldRenderSlotLabels.value).toBe(false)
|
||||
expect(shouldRenderWidgetLabels.value).toBe(false)
|
||||
})
|
||||
camera.z = 0.55
|
||||
await nextTick()
|
||||
expect(isLOD.value).toBe(true)
|
||||
|
||||
it('should enable all rendering for FULL LOD', () => {
|
||||
const zoomRef = ref(1.0)
|
||||
const {
|
||||
shouldRenderWidgets,
|
||||
shouldRenderSlots,
|
||||
shouldRenderContent,
|
||||
shouldRenderSlotLabels,
|
||||
shouldRenderWidgetLabels
|
||||
} = useLOD(zoomRef)
|
||||
|
||||
expect(shouldRenderWidgets.value).toBe(true)
|
||||
expect(shouldRenderSlots.value).toBe(true)
|
||||
expect(shouldRenderContent.value).toBe(true)
|
||||
expect(shouldRenderSlotLabels.value).toBe(true)
|
||||
expect(shouldRenderWidgetLabels.value).toBe(true)
|
||||
})
|
||||
camera.z = 0.87
|
||||
await nextTick()
|
||||
expect(isLOD.value).toBe(false)
|
||||
})
|
||||
|
||||
describe('CSS classes', () => {
|
||||
it('should return correct CSS class for each LOD level', () => {
|
||||
const zoomRef = ref(0.2)
|
||||
const { lodCssClass } = useLOD(zoomRef)
|
||||
it('should handle a different devicePixelRatio value', async () => {
|
||||
vi.stubGlobal('devicePixelRatio', 3) //Threshold with 8px minFontsize = 0.19
|
||||
|
||||
expect(lodCssClass.value).toBe('lg-node--lod-minimal')
|
||||
const camera = reactive({ z: 1 })
|
||||
const { isLOD } = useLOD(camera)
|
||||
|
||||
zoomRef.value = 0.6
|
||||
expect(lodCssClass.value).toBe('lg-node--lod-reduced')
|
||||
await nextTick()
|
||||
expect(isLOD.value).toBe(false)
|
||||
|
||||
zoomRef.value = 1.0
|
||||
expect(lodCssClass.value).toBe('lg-node--lod-full')
|
||||
})
|
||||
camera.z = 0.18
|
||||
await nextTick()
|
||||
expect(isLOD.value).toBe(true)
|
||||
})
|
||||
|
||||
describe('essential widgets filtering', () => {
|
||||
it('should return all widgets for FULL LOD', () => {
|
||||
const zoomRef = ref(1.0)
|
||||
const { getEssentialWidgets } = useLOD(zoomRef)
|
||||
it('should respond to different minFontSize settings', async () => {
|
||||
vi.stubGlobal('devicePixelRatio', 1)
|
||||
|
||||
const widgets = [
|
||||
{ type: 'combo' },
|
||||
{ type: 'text' },
|
||||
{ type: 'button' },
|
||||
{ type: 'slider' }
|
||||
]
|
||||
mockSettingStore.get.mockReturnValue(16) //Now threshold is 1.14
|
||||
|
||||
expect(getEssentialWidgets(widgets)).toEqual(widgets)
|
||||
})
|
||||
const camera = reactive({ z: 1 })
|
||||
const { isLOD } = useLOD(camera)
|
||||
|
||||
it('should return empty array for MINIMAL LOD', () => {
|
||||
const zoomRef = ref(0.2)
|
||||
const { getEssentialWidgets } = useLOD(zoomRef)
|
||||
await nextTick()
|
||||
expect(isLOD.value).toBe(true)
|
||||
|
||||
const widgets = [{ type: 'combo' }, { type: 'text' }, { type: 'button' }]
|
||||
|
||||
expect(getEssentialWidgets(widgets)).toEqual([])
|
||||
})
|
||||
|
||||
it('should filter to essential types for REDUCED LOD', () => {
|
||||
const zoomRef = ref(0.6)
|
||||
const { getEssentialWidgets } = useLOD(zoomRef)
|
||||
|
||||
const widgets = [
|
||||
{ type: 'combo' },
|
||||
{ type: 'text' },
|
||||
{ type: 'button' },
|
||||
{ type: 'slider' },
|
||||
{ type: 'toggle' },
|
||||
{ type: 'number' }
|
||||
]
|
||||
|
||||
const essential = getEssentialWidgets(widgets)
|
||||
expect(essential).toHaveLength(4)
|
||||
expect(essential.map((w: any) => w.type)).toEqual([
|
||||
'combo',
|
||||
'slider',
|
||||
'toggle',
|
||||
'number'
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle case-insensitive widget types', () => {
|
||||
const zoomRef = ref(0.6)
|
||||
const { getEssentialWidgets } = useLOD(zoomRef)
|
||||
|
||||
const widgets = [
|
||||
{ type: 'COMBO' },
|
||||
{ type: 'Select' },
|
||||
{ type: 'TOGGLE' }
|
||||
]
|
||||
|
||||
const essential = getEssentialWidgets(widgets)
|
||||
expect(essential).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should handle widgets with undefined or missing type', () => {
|
||||
const zoomRef = ref(0.6)
|
||||
const { getEssentialWidgets } = useLOD(zoomRef)
|
||||
|
||||
const widgets = [
|
||||
{ type: 'combo' },
|
||||
{ type: undefined },
|
||||
{},
|
||||
{ type: 'slider' }
|
||||
]
|
||||
|
||||
const essential = getEssentialWidgets(widgets)
|
||||
expect(essential).toHaveLength(2)
|
||||
expect(essential.map((w: any) => w.type)).toEqual(['combo', 'slider'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('performance metrics', () => {
|
||||
it('should provide debug metrics', () => {
|
||||
const zoomRef = ref(0.6)
|
||||
const { lodMetrics } = useLOD(zoomRef)
|
||||
|
||||
expect(lodMetrics.value).toEqual({
|
||||
level: LODLevel.REDUCED,
|
||||
zoom: 0.6,
|
||||
widgetCount: 'full',
|
||||
slotCount: 'full'
|
||||
})
|
||||
})
|
||||
|
||||
it('should update metrics when zoom changes', () => {
|
||||
const zoomRef = ref(0.2)
|
||||
const { lodMetrics } = useLOD(zoomRef)
|
||||
|
||||
expect(lodMetrics.value.level).toBe(LODLevel.MINIMAL)
|
||||
expect(lodMetrics.value.widgetCount).toBe('none')
|
||||
expect(lodMetrics.value.slotCount).toBe('none')
|
||||
|
||||
zoomRef.value = 1.0
|
||||
expect(lodMetrics.value.level).toBe(LODLevel.FULL)
|
||||
expect(lodMetrics.value.widgetCount).toBe('full')
|
||||
expect(lodMetrics.value.slotCount).toBe('full')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('LOD_THRESHOLDS', () => {
|
||||
it('should export correct threshold values', () => {
|
||||
expect(LOD_THRESHOLDS.FULL_THRESHOLD).toBe(0.8)
|
||||
expect(LOD_THRESHOLDS.REDUCED_THRESHOLD).toBe(0.4)
|
||||
expect(LOD_THRESHOLDS.MINIMAL_THRESHOLD).toBe(0.0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('supportsFeatureAtZoom', () => {
|
||||
it('should return correct feature support for different zoom levels', () => {
|
||||
expect(supportsFeatureAtZoom(1.0, 'renderWidgets')).toBe(true)
|
||||
expect(supportsFeatureAtZoom(1.0, 'renderSlots')).toBe(true)
|
||||
expect(supportsFeatureAtZoom(1.0, 'renderContent')).toBe(true)
|
||||
|
||||
expect(supportsFeatureAtZoom(0.6, 'renderWidgets')).toBe(true)
|
||||
expect(supportsFeatureAtZoom(0.6, 'renderSlots')).toBe(true)
|
||||
expect(supportsFeatureAtZoom(0.6, 'renderContent')).toBe(false)
|
||||
|
||||
expect(supportsFeatureAtZoom(0.2, 'renderWidgets')).toBe(false)
|
||||
expect(supportsFeatureAtZoom(0.2, 'renderSlots')).toBe(false)
|
||||
expect(supportsFeatureAtZoom(0.2, 'renderContent')).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle threshold boundary values correctly', () => {
|
||||
expect(supportsFeatureAtZoom(0.8, 'renderWidgets')).toBe(true)
|
||||
expect(supportsFeatureAtZoom(0.8, 'renderContent')).toBe(false)
|
||||
|
||||
expect(supportsFeatureAtZoom(0.81, 'renderContent')).toBe(true)
|
||||
|
||||
expect(supportsFeatureAtZoom(0.4, 'renderWidgets')).toBe(false)
|
||||
expect(supportsFeatureAtZoom(0.41, 'renderWidgets')).toBe(true)
|
||||
camera.z = 1.15
|
||||
await nextTick()
|
||||
expect(isLOD.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user