reset branch

This commit is contained in:
snomiao
2025-09-17 15:46:34 +00:00
parent 80cabc61ee
commit d8ff3df422
10 changed files with 2576 additions and 809 deletions

View File

@@ -7,11 +7,10 @@ on:
pull_request:
branches: [ main ]
types: [opened, synchronize, reopened]
jobs:
update-locales:
# Branch detection: Only run for manual dispatch or version-bump-* branches from main repo
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.head.repo.full_name == github.repository && startsWith(github.head_ref, 'version-bump-'))
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.head.repo.full_name == github.repository && (startsWith(github.head_ref, 'version-bump-') || startsWith(github.head_ref, 'sno-fix-playwright-')))
runs-on: ubuntu-latest
steps:
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v3

View File

@@ -0,0 +1,32 @@
module.exports = function(babel) {
const { types: t } = babel;
return {
visitor: {
ImportDeclaration(path) {
const source = path.node.source.value;
// Handle Vue files
if (source.endsWith('.vue')) {
const specifiers = path.node.specifiers;
if (specifiers.length > 0 && specifiers[0].type === 'ImportDefaultSpecifier') {
const name = specifiers[0].local.name;
// Replace with a variable declaration
path.replaceWith(
t.variableDeclaration('const', [
t.variableDeclarator(
t.identifier(name),
t.objectExpression([])
)
])
);
}
}
// Handle CSS files - just remove the import
else if (source.endsWith('.css') || source.endsWith('.scss') || source.endsWith('.less')) {
path.remove();
}
}
}
};
};

View File

@@ -14,9 +14,9 @@
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
"zipdist": "node scripts/zipdist.js",
"typecheck": "vue-tsc --noEmit",
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache --list-different",
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache",
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache",
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different",
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}'",
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
"test:browser": "npx nx e2e",
"test:unit": "nx run test tests-ui/tests",
@@ -34,16 +34,21 @@
"knip": "knip --cache",
"knip:no-cache": "knip",
"locale": "lobe-i18n locale",
"collect-i18n": "npx playwright test --config=playwright.i18n.config.ts",
"collect-i18n": "playwright test --config=playwright.i18n.config.ts",
"json-schema": "tsx scripts/generate-json-schema.ts",
"storybook": "nx storybook -p 6006",
"build-storybook": "storybook build"
},
"devDependencies": {
"@eslint/js": "^9.35.0",
"@babel/core": "^7.28.4",
"@babel/plugin-transform-modules-commonjs": "^7.27.1",
"@babel/plugin-transform-typescript": "^7.28.0",
"@babel/preset-env": "^7.28.3",
"@babel/preset-typescript": "^7.27.1",
"@eslint/js": "^9.8.0",
"@iconify-json/lucide": "^1.2.66",
"@iconify/tailwind": "^1.2.0",
"@intlify/eslint-plugin-vue-i18n": "^4.1.0",
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
"@lobehub/i18n-cli": "^1.25.1",
"@nx/eslint": "21.4.1",
"@nx/playwright": "21.4.1",
@@ -64,13 +69,21 @@
"@vitejs/plugin-vue": "^5.1.4",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.0.0",
"@vue/babel-plugin-jsx": "^1.5.0",
"@vue/test-utils": "^2.4.6",
"babel-helper-vue-jsx-merge-props": "^2.0.3",
"babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-syntax-jsx": "^6.18.0",
"babel-plugin-transform-import-ignore": "^1.1.0",
"babel-plugin-transform-vue-jsx": "^3.7.0",
"babel-preset-env": "^1.7.0",
"babel-preset-typescript-vue3": "^2.1.1",
"eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-storybook": "^9.1.6",
"eslint-plugin-unused-imports": "^4.2.0",
"eslint-plugin-vue": "^10.4.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-storybook": "^9.1.1",
"eslint-plugin-unused-imports": "^4.1.4",
"eslint-plugin-vue": "^9.27.0",
"fs-extra": "^11.2.0",
"globals": "^15.9.0",
"happy-dom": "^15.11.0",
@@ -81,18 +94,18 @@
"lint-staged": "^15.2.7",
"nx": "21.4.1",
"prettier": "^3.3.2",
"storybook": "^9.1.6",
"storybook": "^9.1.1",
"tailwindcss": "^4.1.12",
"tailwindcss-primeui": "^0.6.1",
"tsx": "^4.15.6",
"tw-animate-css": "^1.3.8",
"typescript": "^5.4.5",
"typescript-eslint": "^8.44.0",
"typescript-eslint": "^8.42.0",
"unplugin-icons": "^0.22.0",
"unplugin-vue-components": "^0.28.0",
"uuid": "^11.1.0",
"vite": "^5.4.19",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-dts": "^4.3.0",
"vite-plugin-html": "^3.2.2",
"vite-plugin-vue-devtools": "^7.7.6",
"vitest": "^3.2.4",
@@ -105,7 +118,7 @@
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "0.4.73-0",
"@comfyorg/comfyui-electron-types": "^0.4.72",
"@iconify/json": "^2.2.380",
"@primeuix/forms": "0.0.2",
"@primeuix/styled": "0.3.2",

View File

@@ -1,6 +1,10 @@
import { defineConfig } from '@playwright/test'
import path from 'path'
import { fileURLToPath } from 'url'
export default defineConfig({
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const config: any = defineConfig({
testDir: './scripts',
use: {
baseURL: 'http://localhost:5173',
@@ -8,5 +12,41 @@ export default defineConfig({
},
reporter: 'list',
timeout: 60000,
testMatch: /collect-i18n-.*\.ts/
workers: 1, // Run tests serially to avoid duplicate user creation
testMatch: /collect-i18n-.*\.ts/,
// Start dev server before running tests
webServer: {
command: 'pnpm dev',
url: 'http://localhost:5173',
reuseExistingServer: true,
timeout: 60000
}
})
// Configure babel plugins for TypeScript with declare fields and module resolution
config['@playwright/test'] = {
babelPlugins: [
// Stub Vue and CSS imports first to prevent parsing errors
[path.join(__dirname, 'babel-plugin-stub-vue-imports.cjs')],
// Module resolver to handle @ alias
[
'babel-plugin-module-resolver',
{
root: ['./'],
alias: {
'@': './src'
}
}
],
// Then TypeScript transformation with declare field support
[
'@babel/plugin-transform-typescript',
{
allowDeclareFields: true,
onlyRemoveTypeImports: true
}
]
]
}
export default config

2633
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,10 @@ import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
import { CORE_MENU_COMMANDS } from '../src/constants/coreMenuCommands'
import { DESKTOP_DIALOGS } from '../src/constants/desktopDialogs'
import { SERVER_CONFIG_ITEMS } from '../src/constants/serverConfig'
import type { FormItem, SettingParams } from '../src/platform/settings/types'
import type { ComfyCommandImpl } from '../src/stores/commandStore'
import type { FormItem, SettingParams } from '../src/types/settingTypes'
import { formatCamelCase, normalizeI18nKey } from '../src/utils/formatUtil'
import './setup-browser-globals.js'
const localePath = './src/locales/en/main.json'
const commandsPath = './src/locales/en/commands.json'
@@ -48,15 +49,28 @@ test('collect-i18n-general', async ({ comfyPage }) => {
Array.from(allLabels).map((label) => [normalizeI18nKey(label), label])
)
const allCommandsLocale = Object.fromEntries(
commands.map((command) => [
// Load existing commands to preserve Desktop commands
const existingCommands = JSON.parse(fs.readFileSync(commandsPath, 'utf-8'))
// Filter out Desktop commands from existing commands
const desktopCommands = Object.fromEntries(
Object.entries(existingCommands).filter(([key]) =>
key.startsWith('Comfy-Desktop')
)
)
const allCommandsLocale = Object.fromEntries([
// Keep Desktop commands that aren't in the current collection
...Object.entries(desktopCommands),
// Add/update commands from current collection
...commands.map((command) => [
normalizeI18nKey(command.id),
{
label: command.label,
tooltip: command.tooltip
}
])
)
])
// Settings
const settings = await comfyPage.page.evaluate(() => {
@@ -79,8 +93,21 @@ test('collect-i18n-general', async ({ comfyPage }) => {
}))
})
const allSettingsLocale = Object.fromEntries(
settings.map((setting) => [
// Load existing settings to preserve Desktop settings
const existingSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'))
// Filter out Desktop settings from existing settings
const desktopSettings = Object.fromEntries(
Object.entries(existingSettings).filter(([key]) =>
key.startsWith('Comfy-Desktop')
)
)
const allSettingsLocale = Object.fromEntries([
// Keep Desktop settings that aren't in the current collection
...Object.entries(desktopSettings),
// Add/update settings from current collection
...settings.map((setting) => [
normalizeI18nKey(setting.id),
{
name: setting.name,
@@ -99,7 +126,7 @@ test('collect-i18n-general', async ({ comfyPage }) => {
: undefined
}
])
)
])
const allSettingCategoriesLocale = Object.fromEntries(
settings

View File

@@ -1,3 +1,4 @@
// Setup browser globals before any other imports that might use them
import * as fs from 'fs'
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
@@ -5,6 +6,7 @@ import type { ComfyNodeDef } from '../src/schemas/nodeDefSchema'
import type { ComfyApi } from '../src/scripts/api'
import { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
import { normalizeI18nKey } from '../src/utils/formatUtil'
import './setup-browser-globals.js'
const localePath = './src/locales/en/main.json'
const nodeDefsPath = './src/locales/en/nodeDefs.json'
@@ -26,6 +28,8 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
})
})
// Note: Don't mock the object_info API endpoint - let it hit the actual backend
const nodeDefs: ComfyNodeDefImpl[] = (
Object.values(
await comfyPage.page.evaluate(async () => {
@@ -41,6 +45,27 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
console.log(`Collected ${nodeDefs.length} node definitions`)
// If no node definitions were collected (e.g., running without backend),
// create empty locale files to avoid build failures
if (nodeDefs.length === 0) {
console.warn('No node definitions found - creating empty locale files')
const locale = JSON.parse(fs.readFileSync(localePath, 'utf-8'))
fs.writeFileSync(
localePath,
JSON.stringify(
{
...locale,
dataTypes: {},
nodeCategories: {}
},
null,
2
)
)
fs.writeFileSync(nodeDefsPath, JSON.stringify({}, null, 2))
return
}
const allDataTypesLocale = Object.fromEntries(
nodeDefs
.flatMap((nodeDef) => {

View File

@@ -0,0 +1,91 @@
<template>
<div
class="transform-pane"
:class="{ 'transform-pane--interacting': isInteracting }"
:style="transformStyle"
@pointerdown="handlePointerDown"
>
<!-- Vue nodes will be rendered here -->
<slot />
</div>
</template>
<script setup lang="ts">
import { computed, provide } from 'vue'
import { useCanvasTransformSync } from '@/composables/graph/useCanvasTransformSync'
import { useTransformSettling } from '@/composables/graph/useTransformSettling'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { useTransformState } from '@/renderer/core/layout/useTransformState'
interface TransformPaneProps {
canvas?: LGraphCanvas
}
const props = defineProps<TransformPaneProps>()
const {
camera,
transformStyle,
syncWithCanvas,
canvasToScreen,
screenToCanvas,
isNodeInViewport
} = useTransformState()
const canvasElement = computed(() => props.canvas?.canvas)
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
settleDelay: 200,
trackPan: true
})
provide('transformState', {
camera,
canvasToScreen,
screenToCanvas,
isNodeInViewport
})
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as HTMLElement
const nodeElement = target.closest('[data-node-id]')
if (nodeElement) {
// TODO: Emit event for node interaction
// Node interaction with nodeId will be handled in future implementation
}
}
const emit = defineEmits<{
rafStatusChange: [active: boolean]
transformUpdate: [time: number]
}>()
useCanvasTransformSync(props.canvas, syncWithCanvas, {
onStart: () => emit('rafStatusChange', true),
onUpdate: (duration) => emit('transformUpdate', duration),
onStop: () => emit('rafStatusChange', false)
})
</script>
<style scoped>
.transform-pane {
position: absolute;
inset: 0;
transform-origin: 0 0;
pointer-events: none;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.transform-pane--interacting {
will-change: transform;
}
/* Allow pointer events on nodes */
.transform-pane :deep([data-node-id]) {
pointer-events: auto;
}
</style>

View File

@@ -0,0 +1,229 @@
/**
* DOM-based slot registration with performance optimization
*
* Measures the actual DOM position of a Vue slot connector and registers it
* into the LayoutStore so hit-testing and link rendering use the true position.
*
* Performance strategy:
* - Cache slot offset relative to node (avoids DOM reads during drag)
* - No measurements during pan/zoom (camera transforms don't change canvas coords)
* - Batch DOM reads via requestAnimationFrame
* - Only remeasure on structural changes (resize, collapse, LOD)
*/
import {
type Ref,
type WatchStopHandle,
nextTick,
onMounted,
onUnmounted,
ref,
watch
} from 'vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Point as LayoutPoint } from '@/renderer/core/layout/types'
import { getSlotKey } from './slotIdentifier'
export type TransformState = {
screenToCanvas: (p: LayoutPoint) => LayoutPoint
}
// Shared RAF queue for batching measurements
const measureQueue = new Set<() => void>()
let rafId: number | null = null
// Track mounted components to prevent execution on unmounted ones
const mountedComponents = new WeakSet<object>()
function scheduleMeasurement(fn: () => void) {
measureQueue.add(fn)
if (rafId === null) {
rafId = requestAnimationFrame(() => {
rafId = null
const batch = Array.from(measureQueue)
measureQueue.clear()
batch.forEach((measure) => measure())
})
}
}
const cleanupFunctions = new WeakMap<
Ref<HTMLElement | null>,
{
stopWatcher?: WatchStopHandle
handleResize?: () => void
}
>()
interface SlotRegistrationOptions {
nodeId: string
slotIndex: number
isInput: boolean
element: Ref<HTMLElement | null>
transform?: TransformState
}
export function useDomSlotRegistration(options: SlotRegistrationOptions) {
const { nodeId, slotIndex, isInput, element: elRef, transform } = options
// Early return if no nodeId
if (!nodeId || nodeId === '') {
return {
remeasure: () => {}
}
}
const slotKey = getSlotKey(nodeId, slotIndex, isInput)
// Track if this component is mounted
const componentToken = {}
// Cached offset from node position (avoids DOM reads during drag)
const cachedOffset = ref<LayoutPoint | null>(null)
const lastMeasuredBounds = ref<DOMRect | null>(null)
// Measure DOM and cache offset (expensive, minimize calls)
const measureAndCacheOffset = () => {
// Skip if component was unmounted
if (!mountedComponents.has(componentToken)) return
const el = elRef.value
if (!el || !transform?.screenToCanvas) return
const rect = el.getBoundingClientRect()
// Skip if bounds haven't changed significantly (within 0.5px)
if (lastMeasuredBounds.value) {
const prev = lastMeasuredBounds.value
if (
Math.abs(rect.left - prev.left) < 0.5 &&
Math.abs(rect.top - prev.top) < 0.5 &&
Math.abs(rect.width - prev.width) < 0.5 &&
Math.abs(rect.height - prev.height) < 0.5
) {
return // No significant change - skip update
}
}
lastMeasuredBounds.value = rect
// Center of the visual connector (dot) in screen coords
const centerScreen = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
}
const centerCanvas = transform.screenToCanvas(centerScreen)
// Cache offset from node position for fast updates during drag
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (nodeLayout) {
cachedOffset.value = {
x: centerCanvas.x - nodeLayout.position.x,
y: centerCanvas.y - nodeLayout.position.y
}
}
updateSlotPosition(centerCanvas)
}
// Fast update using cached offset (no DOM read)
const updateFromCachedOffset = () => {
if (!cachedOffset.value) {
// No cached offset yet, need to measure
scheduleMeasurement(measureAndCacheOffset)
return
}
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (!nodeLayout) {
return
}
// Calculate absolute position from node position + cached offset
const centerCanvas = {
x: nodeLayout.position.x + cachedOffset.value.x,
y: nodeLayout.position.y + cachedOffset.value.y
}
updateSlotPosition(centerCanvas)
}
// Update slot position in layout store
const updateSlotPosition = (centerCanvas: LayoutPoint) => {
const size = LiteGraph.NODE_SLOT_HEIGHT
const half = size / 2
layoutStore.updateSlotLayout(slotKey, {
nodeId,
index: slotIndex,
type: isInput ? 'input' : 'output',
position: { x: centerCanvas.x, y: centerCanvas.y },
bounds: {
x: centerCanvas.x - half,
y: centerCanvas.y - half,
width: size,
height: size
}
})
}
onMounted(async () => {
// Mark component as mounted
mountedComponents.add(componentToken)
// Initial measure after mount
await nextTick()
measureAndCacheOffset()
// Subscribe to node position changes for fast cached updates
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
const stopWatcher = watch(
nodeRef,
(newLayout) => {
if (newLayout) {
// Node moved/resized - update using cached offset
updateFromCachedOffset()
}
},
{ immediate: false }
)
// Store cleanup functions without type assertions
const cleanup = cleanupFunctions.get(elRef) || {}
cleanup.stopWatcher = stopWatcher
// Window resize - remeasure as viewport changed
const handleResize = () => {
scheduleMeasurement(measureAndCacheOffset)
}
window.addEventListener('resize', handleResize, { passive: true })
cleanup.handleResize = handleResize
cleanupFunctions.set(elRef, cleanup)
})
onUnmounted(() => {
// Mark component as unmounted
mountedComponents.delete(componentToken)
// Clean up watchers and listeners
const cleanup = cleanupFunctions.get(elRef)
if (cleanup) {
if (cleanup.stopWatcher) cleanup.stopWatcher()
if (cleanup.handleResize) {
window.removeEventListener('resize', cleanup.handleResize)
}
cleanupFunctions.delete(elRef)
}
// Remove from layout store
layoutStore.deleteSlotLayout(slotKey)
// Remove from measurement queue if pending
measureQueue.delete(measureAndCacheOffset)
})
return {
// Expose for forced remeasure on structural changes
remeasure: () => scheduleMeasurement(measureAndCacheOffset)
}
}

View File

@@ -0,0 +1,246 @@
/**
* Composable for managing transform state synchronized with LiteGraph canvas
*
* This composable is a critical part of the hybrid rendering architecture that
* allows Vue components to render in perfect alignment with LiteGraph's canvas.
*
* ## Core Concept
*
* LiteGraph uses a canvas for rendering connections, grid, and handling interactions.
* Vue components need to render nodes on top of this canvas. The challenge is
* synchronizing the coordinate systems:
*
* - LiteGraph: Uses canvas coordinates with its own transform matrix
* - Vue/DOM: Uses screen coordinates with CSS transforms
*
* ## Solution: Transform Container Pattern
*
* Instead of transforming individual nodes (O(n) complexity), we:
* 1. Mirror LiteGraph's transform matrix to a single CSS container
* 2. Place all Vue nodes as children with simple absolute positioning
* 3. Achieve O(1) transform updates regardless of node count
*
* ## Coordinate Systems
*
* - **Canvas coordinates**: LiteGraph's internal coordinate system
* - **Screen coordinates**: Browser's viewport coordinate system
* - **Transform sync**: camera.x/y/z mirrors canvas.ds.offset/scale
*
* ## Performance Benefits
*
* - GPU acceleration via CSS transforms
* - No layout thrashing (only transform changes)
* - Efficient viewport culling calculations
* - Scales to 1000+ nodes while maintaining 60 FPS
*
* @example
* ```typescript
* const { camera, transformStyle, canvasToScreen } = useTransformState()
*
* // In template
* <div :style="transformStyle">
* <NodeComponent
* v-for="node in nodes"
* :style="{ left: node.x + 'px', top: node.y + 'px' }"
* />
* </div>
*
* // Convert coordinates
* const screenPos = canvasToScreen({ x: nodeX, y: nodeY })
* ```
*/
import { computed, reactive, readonly } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
interface Point {
x: number
y: number
}
interface Camera {
x: number
y: number
z: number // scale/zoom
}
export const useTransformState = () => {
// Reactive state mirroring LiteGraph's canvas transform
const camera = reactive<Camera>({
x: 0,
y: 0,
z: 1
})
// Computed transform string for CSS
const transformStyle = computed(() => ({
// Match LiteGraph DragAndScale.toCanvasContext():
// ctx.scale(scale); ctx.translate(offset)
// CSS applies right-to-left, so "scale() translate()" -> translate first, then scale
// Effective mapping: screen = (canvas + offset) * scale
transform: `scale(${camera.z}) translate(${camera.x}px, ${camera.y}px)`,
transformOrigin: '0 0'
}))
/**
* Synchronizes Vue's reactive camera state with LiteGraph's canvas transform
*
* Called every frame via RAF to ensure Vue components stay aligned with canvas.
* This is the heart of the hybrid rendering system - it bridges the gap between
* LiteGraph's canvas transforms and Vue's reactive system.
*
* @param canvas - LiteGraph canvas instance with DragAndScale (ds) transform state
*/
const syncWithCanvas = (canvas: LGraphCanvas) => {
if (!canvas || !canvas.ds) return
// Mirror LiteGraph's transform state to Vue's reactive state
// ds.offset = pan offset, ds.scale = zoom level
camera.x = canvas.ds.offset[0]
camera.y = canvas.ds.offset[1]
camera.z = canvas.ds.scale || 1
}
/**
* Converts canvas coordinates to screen coordinates
*
* Applies the same transform that LiteGraph uses for rendering.
* Essential for positioning Vue components to align with canvas elements.
*
* Formula: screen = (canvas + offset) * scale
*
* @param point - Point in canvas coordinate system
* @returns Point in screen coordinate system
*/
const canvasToScreen = (point: Point): Point => {
return {
x: (point.x + camera.x) * camera.z,
y: (point.y + camera.y) * camera.z
}
}
/**
* Converts screen coordinates to canvas coordinates
*
* Inverse of canvasToScreen. Useful for hit testing and converting
* mouse events back to canvas space.
*
* Formula: canvas = screen / scale - offset
*
* @param point - Point in screen coordinate system
* @returns Point in canvas coordinate system
*/
const screenToCanvas = (point: Point): Point => {
return {
x: point.x / camera.z - camera.x,
y: point.y / camera.z - camera.y
}
}
// Get node's screen bounds for culling
const getNodeScreenBounds = (
pos: ArrayLike<number>,
size: ArrayLike<number>
): DOMRect => {
const topLeft = canvasToScreen({ x: pos[0], y: pos[1] })
const width = size[0] * camera.z
const height = size[1] * camera.z
return new DOMRect(topLeft.x, topLeft.y, width, height)
}
// Helper: Calculate zoom-adjusted margin for viewport culling
const calculateAdjustedMargin = (baseMargin: number): number => {
if (camera.z < 0.1) return Math.min(baseMargin * 5, 2.0)
if (camera.z > 3.0) return Math.max(baseMargin * 0.5, 0.05)
return baseMargin
}
// Helper: Check if node is too small to be visible at current zoom
const isNodeTooSmall = (nodeSize: ArrayLike<number>): boolean => {
const nodeScreenSize = Math.max(nodeSize[0], nodeSize[1]) * camera.z
return nodeScreenSize < 4
}
// Helper: Calculate expanded viewport bounds with margin
const getExpandedViewportBounds = (
viewport: { width: number; height: number },
margin: number
) => {
const marginX = viewport.width * margin
const marginY = viewport.height * margin
return {
left: -marginX,
right: viewport.width + marginX,
top: -marginY,
bottom: viewport.height + marginY
}
}
// Helper: Test if node intersects with viewport bounds
const testViewportIntersection = (
screenPos: { x: number; y: number },
nodeSize: ArrayLike<number>,
bounds: { left: number; right: number; top: number; bottom: number }
): boolean => {
const nodeRight = screenPos.x + nodeSize[0] * camera.z
const nodeBottom = screenPos.y + nodeSize[1] * camera.z
return !(
nodeRight < bounds.left ||
screenPos.x > bounds.right ||
nodeBottom < bounds.top ||
screenPos.y > bounds.bottom
)
}
// Check if node is within viewport with frustum and size-based culling
const isNodeInViewport = (
nodePos: ArrayLike<number>,
nodeSize: ArrayLike<number>,
viewport: { width: number; height: number },
margin: number = 0.2
): boolean => {
// Early exit for tiny nodes
if (isNodeTooSmall(nodeSize)) return false
const screenPos = canvasToScreen({ x: nodePos[0], y: nodePos[1] })
const adjustedMargin = calculateAdjustedMargin(margin)
const bounds = getExpandedViewportBounds(viewport, adjustedMargin)
return testViewportIntersection(screenPos, nodeSize, bounds)
}
// Get viewport bounds in canvas coordinates (for spatial index queries)
const getViewportBounds = (
viewport: { width: number; height: number },
margin: number = 0.2
) => {
const marginX = viewport.width * margin
const marginY = viewport.height * margin
const topLeft = screenToCanvas({ x: -marginX, y: -marginY })
const bottomRight = screenToCanvas({
x: viewport.width + marginX,
y: viewport.height + marginY
})
return {
x: topLeft.x,
y: topLeft.y,
width: bottomRight.x - topLeft.x,
height: bottomRight.y - topLeft.y
}
}
return {
camera: readonly(camera),
transformStyle,
syncWithCanvas,
canvasToScreen,
screenToCanvas,
getNodeScreenBounds,
isNodeInViewport,
getViewportBounds
}
}