mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
Add Centralized Vue Node Size/Pos Tracking (#5442)
* add dom element resize observer registry for vue node components * Update src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts Co-authored-by: AustinMroz <austin@comfy.org> * refactor(vue-nodes): typed TransformState InjectionKey, safer ResizeObserver sizing, centralized slot tracking, and small readability updates * chore: make TransformState interface non-exported to satisfy knip pre-push * Revert "chore: make TransformState interface non-exported to satisfy knip pre-push" This reverts commit110ecf31da. * Revert "refactor(vue-nodes): typed TransformState InjectionKey, safer ResizeObserver sizing, centralized slot tracking, and small readability updates" This reverts commit428752619c. * [refactor] Improve resize tracking composable documentation and test utilities - Rename parameters in useVueElementTracking for clarity (appIdentifier, trackingType) - Add comprehensive docstring with examples to prevent DOM attribute confusion - Extract mountLGraphNode test utility to eliminate repetitive mock setup - Add technical implementation notes documenting optimization decisions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * remove typo comment * convert to functional bounds collection * remove inline import * add interfaces for bounds mutations * remove change log * fix bounds collection when vue nodes turned off * fix title offset on y * move from resize observer to selection toolbox bounds --------- Co-authored-by: AustinMroz <austin@comfy.org> Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,12 @@ import type { Ref } from 'vue'
|
||||
|
||||
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { createBounds } from '@/lib/litegraph/src/litegraph'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { computeUnionBounds } from '@/utils/mathUtil'
|
||||
|
||||
/**
|
||||
* Manages the position of the selection toolbox independently.
|
||||
@@ -16,6 +20,7 @@ export function useSelectionToolboxPosition(
|
||||
const canvasStore = useCanvasStore()
|
||||
const lgCanvas = canvasStore.getCanvas()
|
||||
const { getSelectableItems } = useSelectedLiteGraphItems()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
// World position of selection center
|
||||
const worldPosition = ref({ x: 0, y: 0 })
|
||||
@@ -34,17 +39,40 @@ export function useSelectionToolboxPosition(
|
||||
}
|
||||
|
||||
visible.value = true
|
||||
const bounds = createBounds(selectableItems)
|
||||
|
||||
if (!bounds) {
|
||||
return
|
||||
// Get bounds for all selected items
|
||||
const allBounds: ReadOnlyRect[] = []
|
||||
for (const item of selectableItems) {
|
||||
// Skip items without valid IDs
|
||||
if (item.id == null) continue
|
||||
|
||||
if (shouldRenderVueNodes.value && typeof item.id === 'string') {
|
||||
// Use layout store for Vue nodes (only works with string IDs)
|
||||
const layout = layoutStore.getNodeLayoutRef(item.id).value
|
||||
if (layout) {
|
||||
allBounds.push([
|
||||
layout.bounds.x,
|
||||
layout.bounds.y,
|
||||
layout.bounds.width,
|
||||
layout.bounds.height
|
||||
])
|
||||
}
|
||||
} else {
|
||||
// Fallback to LiteGraph bounds for regular nodes or non-string IDs
|
||||
if (item instanceof LGraphNode) {
|
||||
const bounds = item.getBounding()
|
||||
allBounds.push([bounds[0], bounds[1], bounds[2], bounds[3]] as const)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [xBase, y, width] = bounds
|
||||
// Compute union bounds
|
||||
const unionBounds = computeUnionBounds(allBounds)
|
||||
if (!unionBounds) return
|
||||
|
||||
worldPosition.value = {
|
||||
x: xBase + width / 2,
|
||||
y: y
|
||||
x: unionBounds.x + unionBounds.width / 2,
|
||||
y: unionBounds.y - 10
|
||||
}
|
||||
|
||||
updateTransform()
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
LayoutOperation,
|
||||
MoveNodeOperation,
|
||||
MoveRerouteOperation,
|
||||
NodeBoundsUpdate,
|
||||
ResizeNodeOperation,
|
||||
SetNodeZIndexOperation
|
||||
} from '@/renderer/core/layout/types'
|
||||
@@ -1425,6 +1426,31 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
getStateAsUpdate(): Uint8Array {
|
||||
return Y.encodeStateAsUpdate(this.ydoc)
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch update node bounds using Yjs transaction for atomicity.
|
||||
*/
|
||||
batchUpdateNodeBounds(updates: NodeBoundsUpdate[]): void {
|
||||
if (updates.length === 0) return
|
||||
|
||||
// Set source to Vue for these DOM-driven updates
|
||||
const originalSource = this.currentSource
|
||||
this.currentSource = LayoutSource.Vue
|
||||
|
||||
this.ydoc.transact(() => {
|
||||
for (const { nodeId, bounds } of updates) {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (!ynode) continue
|
||||
|
||||
this.spatialIndex.update(nodeId, bounds)
|
||||
ynode.set('bounds', bounds)
|
||||
ynode.set('size', { width: bounds.width, height: bounds.height })
|
||||
}
|
||||
}, this.currentActor)
|
||||
|
||||
// Restore original source
|
||||
this.currentSource = originalSource
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
|
||||
@@ -31,6 +31,11 @@ export interface Bounds {
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface NodeBoundsUpdate {
|
||||
nodeId: NodeId
|
||||
bounds: Bounds
|
||||
}
|
||||
|
||||
export type NodeId = string
|
||||
export type LinkId = number
|
||||
export type RerouteId = number
|
||||
@@ -320,4 +325,9 @@ export interface LayoutStore {
|
||||
setActor(actor: string): void
|
||||
getCurrentSource(): LayoutSource
|
||||
getCurrentActor(): string
|
||||
|
||||
// Batch updates
|
||||
batchUpdateNodeBounds(
|
||||
updates: Array<{ nodeId: NodeId; bounds: Bounds }>
|
||||
): void
|
||||
}
|
||||
|
||||
@@ -113,7 +113,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onErrorCaptured, ref, toRef, watch } from 'vue'
|
||||
|
||||
// Import the VueNodeData type
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -122,6 +121,7 @@ import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayo
|
||||
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useVueElementTracking } from '../composables/useVueNodeResizeTracking'
|
||||
import NodeContent from './NodeContent.vue'
|
||||
import NodeHeader from './NodeHeader.vue'
|
||||
import NodeSlots from './NodeSlots.vue'
|
||||
@@ -154,6 +154,8 @@ const emit = defineEmits<{
|
||||
'update:title': [nodeId: string, newTitle: string]
|
||||
}>()
|
||||
|
||||
useVueElementTracking(props.nodeData.id, 'node')
|
||||
|
||||
// Inject selection state from parent
|
||||
const selectedNodeIds = inject(SelectedNodeIdsKey)
|
||||
if (!selectedNodeIds) {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Generic Vue Element Tracking System
|
||||
*
|
||||
* Automatically tracks DOM size and position changes for Vue-rendered elements
|
||||
* and syncs them to the layout store. Uses a single shared ResizeObserver for
|
||||
* performance, with elements identified by configurable data attributes.
|
||||
*
|
||||
* Supports different element types (nodes, slots, widgets, etc.) with
|
||||
* customizable data attributes and update handlers.
|
||||
*/
|
||||
import { getCurrentInstance, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* Generic update item for element bounds tracking
|
||||
*/
|
||||
interface ElementBoundsUpdate {
|
||||
/** Element identifier (could be nodeId, widgetId, slotId, etc.) */
|
||||
id: string
|
||||
/** Updated bounds */
|
||||
bounds: Bounds
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for different types of tracked elements
|
||||
*/
|
||||
interface ElementTrackingConfig {
|
||||
/** Data attribute name (e.g., 'nodeId') */
|
||||
dataAttribute: string
|
||||
/** Handler for processing bounds updates */
|
||||
updateHandler: (updates: ElementBoundsUpdate[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of tracking configurations by element type
|
||||
*/
|
||||
const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
|
||||
[
|
||||
'node',
|
||||
{
|
||||
dataAttribute: 'nodeId',
|
||||
updateHandler: (updates) => {
|
||||
const nodeUpdates = updates.map(({ id, bounds }) => ({
|
||||
nodeId: id as NodeId,
|
||||
bounds
|
||||
}))
|
||||
layoutStore.batchUpdateNodeBounds(nodeUpdates)
|
||||
}
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
// Single ResizeObserver instance for all Vue elements
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
// Group updates by element type
|
||||
const updatesByType = new Map<string, ElementBoundsUpdate[]>()
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!(entry.target instanceof HTMLElement)) continue
|
||||
const element = entry.target
|
||||
|
||||
// Find which type this element belongs to
|
||||
let elementType: string | undefined
|
||||
let elementId: string | undefined
|
||||
|
||||
for (const [type, config] of trackingConfigs) {
|
||||
const id = element.dataset[config.dataAttribute]
|
||||
if (id) {
|
||||
elementType = type
|
||||
elementId = id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!elementType || !elementId) continue
|
||||
|
||||
const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0]
|
||||
const rect = element.getBoundingClientRect()
|
||||
|
||||
const bounds: Bounds = {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
width,
|
||||
height: height
|
||||
}
|
||||
|
||||
if (!updatesByType.has(elementType)) {
|
||||
updatesByType.set(elementType, [])
|
||||
}
|
||||
const updates = updatesByType.get(elementType)
|
||||
if (updates) {
|
||||
updates.push({ id: elementId, bounds })
|
||||
}
|
||||
}
|
||||
|
||||
// Process updates by type
|
||||
for (const [type, updates] of updatesByType) {
|
||||
const config = trackingConfigs.get(type)
|
||||
if (config && updates.length > 0) {
|
||||
config.updateHandler(updates)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Tracks DOM element size/position changes for a Vue component and syncs to layout store
|
||||
*
|
||||
* Sets up automatic ResizeObserver tracking when the component mounts and cleans up
|
||||
* when unmounted. The tracked element is identified by a data attribute set on the
|
||||
* component's root DOM element.
|
||||
*
|
||||
* @param appIdentifier - Application-level identifier for this tracked element (not a DOM ID)
|
||||
* Example: node ID like 'node-123', widget ID like 'widget-456'
|
||||
* @param trackingType - Type of element being tracked, determines which tracking config to use
|
||||
* Example: 'node' for Vue nodes, 'widget' for UI widgets
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Track a Vue node component with ID 'my-node-123'
|
||||
* useVueElementTracking('my-node-123', 'node')
|
||||
*
|
||||
* // Would set data-node-id="my-node-123" on the component's root element
|
||||
* // and sync size changes to layoutStore.batchUpdateNodeBounds()
|
||||
* ```
|
||||
*/
|
||||
export function useVueElementTracking(
|
||||
appIdentifier: string,
|
||||
trackingType: string
|
||||
) {
|
||||
onMounted(() => {
|
||||
const element = getCurrentInstance()?.proxy?.$el
|
||||
if (!(element instanceof HTMLElement) || !appIdentifier) return
|
||||
|
||||
const config = trackingConfigs.get(trackingType)
|
||||
if (config) {
|
||||
// Set the appropriate data attribute
|
||||
element.dataset[config.dataAttribute] = appIdentifier
|
||||
resizeObserver.observe(element)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
const element = getCurrentInstance()?.proxy?.$el
|
||||
if (!(element instanceof HTMLElement)) return
|
||||
|
||||
const config = trackingConfigs.get(trackingType)
|
||||
if (config) {
|
||||
// Remove the data attribute
|
||||
delete element.dataset[config.dataAttribute]
|
||||
resizeObserver.unobserve(element)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* Finds the greatest common divisor (GCD) for two numbers.
|
||||
*
|
||||
@@ -5,7 +8,7 @@
|
||||
* @param b - The second number.
|
||||
* @returns The GCD of the two numbers.
|
||||
*/
|
||||
const gcd = (a: number, b: number): number => {
|
||||
export const gcd = (a: number, b: number): number => {
|
||||
return b === 0 ? a : gcd(b, a % b)
|
||||
}
|
||||
|
||||
@@ -19,3 +22,48 @@ const gcd = (a: number, b: number): number => {
|
||||
export const lcm = (a: number, b: number): number => {
|
||||
return Math.abs(a * b) / gcd(a, b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the union (bounding box) of multiple rectangles using a single-pass algorithm.
|
||||
*
|
||||
* Finds the minimum and maximum x/y coordinates across all rectangles to create
|
||||
* a single bounding rectangle that contains all input rectangles. Optimized for
|
||||
* performance with V8-friendly tuple access patterns.
|
||||
*
|
||||
* @param rectangles - Array of rectangle tuples in [x, y, width, height] format
|
||||
* @returns Bounds object with union rectangle, or null if no rectangles provided
|
||||
*/
|
||||
export function computeUnionBounds(
|
||||
rectangles: readonly ReadOnlyRect[]
|
||||
): Bounds | null {
|
||||
const n = rectangles.length
|
||||
if (n === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const r0 = rectangles[0]
|
||||
let minX = r0[0]
|
||||
let minY = r0[1]
|
||||
let maxX = minX + r0[2]
|
||||
let maxY = minY + r0[3]
|
||||
|
||||
for (let i = 1; i < n; i++) {
|
||||
const r = rectangles[i]
|
||||
const x1 = r[0]
|
||||
const y1 = r[1]
|
||||
const x2 = x1 + r[2]
|
||||
const y2 = y1 + r[3]
|
||||
|
||||
if (x1 < minX) minX = x1
|
||||
if (y1 < minY) minY = y1
|
||||
if (x2 > maxX) maxX = x2
|
||||
if (y2 > maxY) maxY = y2
|
||||
}
|
||||
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
|
||||
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking',
|
||||
() => ({
|
||||
useVueElementTracking: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
toastErrorHandler: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
|
||||
useNodeLayout: () => ({
|
||||
position: { x: 100, y: 50 },
|
||||
startDrag: vi.fn(),
|
||||
handleDrag: vi.fn(),
|
||||
endDrag: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
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 }
|
||||
}))
|
||||
|
||||
describe('LGraphNode', () => {
|
||||
const mockNodeData: VueNodeData = {
|
||||
id: 'test-node-123',
|
||||
title: 'Test Node',
|
||||
type: 'TestNode',
|
||||
mode: 0,
|
||||
flags: {},
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
selected: false,
|
||||
executing: false
|
||||
}
|
||||
|
||||
const mountLGraphNode = (props: any, selectedNodeIds = new Set()) => {
|
||||
return mount(LGraphNode, {
|
||||
props,
|
||||
global: {
|
||||
provide: {
|
||||
[SelectedNodeIdsKey as symbol]: ref(selectedNodeIds)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should call resize tracking composable with node ID', () => {
|
||||
mountLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
expect(useVueElementTracking).toHaveBeenCalledWith('test-node-123', 'node')
|
||||
})
|
||||
|
||||
it('should render with data-node-id attribute', () => {
|
||||
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
expect(wrapper.attributes('data-node-id')).toBe('test-node-123')
|
||||
})
|
||||
|
||||
it('should render node title', () => {
|
||||
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
expect(wrapper.text()).toContain('Test Node')
|
||||
})
|
||||
|
||||
it('should apply selected styling when selected prop is true', () => {
|
||||
const wrapper = mountLGraphNode(
|
||||
{ nodeData: mockNodeData, selected: true },
|
||||
new Set(['test-node-123'])
|
||||
)
|
||||
|
||||
expect(wrapper.classes()).toContain('border-blue-500')
|
||||
expect(wrapper.classes()).toContain('ring-2')
|
||||
expect(wrapper.classes()).toContain('ring-blue-300')
|
||||
})
|
||||
|
||||
it('should apply executing animation when executing prop is true', () => {
|
||||
const wrapper = mountLGraphNode({ nodeData: mockNodeData, executing: true })
|
||||
|
||||
expect(wrapper.classes()).toContain('animate-pulse')
|
||||
})
|
||||
|
||||
it('should emit node-click event on pointer down', async () => {
|
||||
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
await wrapper.trigger('pointerdown')
|
||||
|
||||
expect(wrapper.emitted('node-click')).toHaveLength(1)
|
||||
expect(wrapper.emitted('node-click')?.[0]).toHaveLength(2)
|
||||
expect(wrapper.emitted('node-click')?.[0][1]).toEqual(mockNodeData)
|
||||
})
|
||||
})
|
||||
97
tests-ui/tests/utils/mathUtil.test.ts
Normal file
97
tests-ui/tests/utils/mathUtil.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import { computeUnionBounds, gcd, lcm } from '@/utils/mathUtil'
|
||||
|
||||
describe('mathUtil', () => {
|
||||
describe('gcd', () => {
|
||||
it('should compute greatest common divisor correctly', () => {
|
||||
expect(gcd(48, 18)).toBe(6)
|
||||
expect(gcd(100, 25)).toBe(25)
|
||||
expect(gcd(17, 13)).toBe(1)
|
||||
expect(gcd(0, 5)).toBe(5)
|
||||
expect(gcd(5, 0)).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('lcm', () => {
|
||||
it('should compute least common multiple correctly', () => {
|
||||
expect(lcm(4, 6)).toBe(12)
|
||||
expect(lcm(15, 20)).toBe(60)
|
||||
expect(lcm(7, 11)).toBe(77)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeUnionBounds', () => {
|
||||
it('should return null for empty input', () => {
|
||||
expect(computeUnionBounds([])).toBe(null)
|
||||
})
|
||||
|
||||
// Tests for tuple format (ReadOnlyRect)
|
||||
it('should work with ReadOnlyRect tuple format', () => {
|
||||
const tuples: ReadOnlyRect[] = [
|
||||
[10, 20, 30, 40] as const, // bounds: 10,20 to 40,60
|
||||
[50, 10, 20, 30] as const // bounds: 50,10 to 70,40
|
||||
]
|
||||
|
||||
const result = computeUnionBounds(tuples)
|
||||
|
||||
expect(result).toEqual({
|
||||
x: 10, // min(10, 50)
|
||||
y: 10, // min(20, 10)
|
||||
width: 60, // max(40, 70) - min(10, 50) = 70 - 10
|
||||
height: 50 // max(60, 40) - min(20, 10) = 60 - 10
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle single ReadOnlyRect tuple', () => {
|
||||
const tuple: ReadOnlyRect = [10, 20, 30, 40] as const
|
||||
const result = computeUnionBounds([tuple])
|
||||
|
||||
expect(result).toEqual({
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 30,
|
||||
height: 40
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle tuple format with negative dimensions', () => {
|
||||
const tuples: ReadOnlyRect[] = [
|
||||
[100, 50, -20, -10] as const, // x+width=80, y+height=40
|
||||
[90, 45, 15, 20] as const // x+width=105, y+height=65
|
||||
]
|
||||
|
||||
const result = computeUnionBounds(tuples)
|
||||
|
||||
expect(result).toEqual({
|
||||
x: 90, // min(100, 90)
|
||||
y: 45, // min(50, 45)
|
||||
width: 15, // max(80, 105) - min(100, 90) = 105 - 90
|
||||
height: 20 // max(40, 65) - min(50, 45) = 65 - 45
|
||||
})
|
||||
})
|
||||
|
||||
it('should maintain optimal performance with SoA tuples', () => {
|
||||
// Test that array access is as expected for typical selection sizes
|
||||
const tuples: ReadOnlyRect[] = Array.from(
|
||||
{ length: 10 },
|
||||
(_, i) =>
|
||||
[
|
||||
i * 20, // x
|
||||
i * 15, // y
|
||||
100 + i * 5, // width
|
||||
80 + i * 3 // height
|
||||
] as const
|
||||
)
|
||||
|
||||
const result = computeUnionBounds(tuples)
|
||||
|
||||
expect(result).toBeTruthy()
|
||||
expect(result!.x).toBe(0)
|
||||
expect(result!.y).toBe(0)
|
||||
expect(result!.width).toBe(325)
|
||||
expect(result!.height).toBe(242)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user