@tldiagram/core-ui 1.94.4 → 1.95.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,11 @@
1
+ import { type ZUICameraFrame } from '../components/ZUI';
1
2
  interface Props {
2
3
  sharedToken?: string;
3
4
  shareSlot?: React.ReactNode;
4
5
  }
5
6
  export interface InfiniteZoomHandle {
6
7
  focusDiagram(viewId: number): boolean;
8
+ setCameraFrame(frame: ZUICameraFrame): boolean;
7
9
  }
8
10
  declare const InfiniteZoom: import("react").ForwardRefExoticComponent<Props & import("react").RefAttributes<InfiniteZoomHandle>>;
9
11
  export default InfiniteZoom;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tldiagram/core-ui",
3
- "version": "1.94.4",
3
+ "version": "1.95.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,7 +1,7 @@
1
1
  import React from 'react'
2
- import { Box } from '@chakra-ui/react'
2
+ import { Box, BoxProps } from '@chakra-ui/react'
3
3
 
4
- export function KbdHint({ children }: { children: string }) {
4
+ export function KbdHint({ children, ...props }: { children: string } & BoxProps) {
5
5
  return (
6
6
  <Box
7
7
  as="span"
@@ -17,6 +17,7 @@ export function KbdHint({ children }: { children: string }) {
17
17
  color="whiteAlpha.900"
18
18
  flexShrink={0}
19
19
  ml={2}
20
+ {...props}
20
21
  >
21
22
  {children}
22
23
  </Box>
@@ -60,6 +60,7 @@ export const TagManager: React.FC<Props> = ({
60
60
  layerCounts,
61
61
  }) => {
62
62
  const [isCollapsed, setIsCollapsed] = useState(false)
63
+ const [isUnusedCollapsed, setIsUnusedCollapsed] = useState(true)
63
64
  const [expandedLayerIds, setExpandedLayerIds] = useState<Set<number>>(new Set())
64
65
  const [namingPopover, setNamingPopover] = useState<{
65
66
  isOpen: boolean;
@@ -122,7 +123,7 @@ export const TagManager: React.FC<Props> = ({
122
123
  const handleConfirmNaming = async (name: string) => {
123
124
  const { tags } = namingPopover
124
125
  if (!tags || tags.length === 0) return
125
-
126
+
126
127
  const color = pickUnusedColor(Object.values(tagColors).map(t => t.color))
127
128
  await onCreateLayer(name, tags, color)
128
129
  setNamingPopover(prev => ({ ...prev, isOpen: false, targetTag: null, targetLayerId: null }))
@@ -300,28 +301,53 @@ export const TagManager: React.FC<Props> = ({
300
301
  <Divider borderColor="whiteAlpha.100" />
301
302
  )}
302
303
  {unusedTags.length > 0 && (
303
- <Wrap spacing={1} opacity={0.6}>
304
- {unusedTags.map((tag) => (
305
- <WrapItem key={tag}>
306
- <TagItem
307
- tag={tag}
308
- color={tagColors[tag]?.color || '#A0AEC0'}
309
- description={tagColors[tag]?.description || null}
310
- isAssigned={(selectedElement.tags || []).includes(tag)}
311
- tagCount={tagCounts[tag]}
312
- onToggle={() => onToggleTagOnElement(tag)}
313
- onHover={(active) => onHoverLayer(active ? [tag] : null, tagColors[tag]?.color)}
314
- onDropTag={(dragged: string) => handleCreateGroup(tag, dragged, tag)}
315
- onDropLayer={(draggedId: number) => handleCreateGroupFromLayer(tag, draggedId)}
316
- namingPopover={namingPopover.targetTag === tag ? namingPopover : undefined}
317
- onConfirmNaming={handleConfirmNaming}
318
- onCloseNaming={() => setNamingPopover(prev => ({ ...prev, isOpen: false, targetTag: null }))}
319
- onSetColor={(color) => onCreateTag(tag, color)}
320
- onSetDescription={(desc) => onCreateTag(tag, tagColors[tag]?.color, desc)}
321
- />
322
- </WrapItem>
323
- ))}
324
- </Wrap>
304
+ <VStack align="stretch" spacing={2}>
305
+ <HStack
306
+ spacing={1.5}
307
+ cursor="pointer"
308
+ onClick={() => setIsUnusedCollapsed(!isUnusedCollapsed)}
309
+ role="button"
310
+ opacity={0.6}
311
+ _hover={{ opacity: 1 }}
312
+ transition="opacity 0.2s"
313
+ >
314
+ <Box
315
+ transform={isUnusedCollapsed ? 'rotate(-90deg)' : 'none'}
316
+ transition="transform 0.2s"
317
+ display="flex"
318
+ alignItems="center"
319
+ >
320
+ <ChevronDownIcon size={10} />
321
+ </Box>
322
+ <Text fontSize="10px" fontWeight="bold" color="gray.500" textTransform="uppercase" letterSpacing="0.05em">
323
+ Unused on view ({unusedTags.length})
324
+ </Text>
325
+ </HStack>
326
+ {!isUnusedCollapsed && (
327
+ <Wrap spacing={1} opacity={0.6}>
328
+ {unusedTags.map((tag) => (
329
+ <WrapItem key={tag}>
330
+ <TagItem
331
+ tag={tag}
332
+ color={tagColors[tag]?.color || '#A0AEC0'}
333
+ description={tagColors[tag]?.description || null}
334
+ isAssigned={(selectedElement.tags || []).includes(tag)}
335
+ tagCount={tagCounts[tag]}
336
+ onToggle={() => onToggleTagOnElement(tag)}
337
+ onHover={(active) => onHoverLayer(active ? [tag] : null, tagColors[tag]?.color)}
338
+ onDropTag={(dragged: string) => handleCreateGroup(tag, dragged, tag)}
339
+ onDropLayer={(draggedId: number) => handleCreateGroupFromLayer(tag, draggedId)}
340
+ namingPopover={namingPopover.targetTag === tag ? namingPopover : undefined}
341
+ onConfirmNaming={handleConfirmNaming}
342
+ onCloseNaming={() => setNamingPopover(prev => ({ ...prev, isOpen: false, targetTag: null }))}
343
+ onSetColor={(color) => onCreateTag(tag, color)}
344
+ onSetDescription={(desc) => onCreateTag(tag, tagColors[tag]?.color, desc)}
345
+ />
346
+ </WrapItem>
347
+ ))}
348
+ </Wrap>
349
+ )}
350
+ </VStack>
325
351
  )}
326
352
  </VStack>
327
353
  </Box>
@@ -385,26 +411,51 @@ export const TagManager: React.FC<Props> = ({
385
411
  <Divider borderColor="whiteAlpha.100" />
386
412
  )}
387
413
  {unusedTags.length > 0 && (
388
- <Wrap spacing={2} opacity={0.6}>
389
- {unusedTags.map((tag) => (
390
- <WrapItem key={tag}>
391
- <TagItem
392
- tag={tag}
393
- color={tagColors[tag]?.color || '#A0AEC0'}
394
- description={tagColors[tag]?.description || null}
395
- tagCount={tagCounts[tag]}
396
- onHover={(active) => onHoverLayer(active ? [tag] : null, tagColors[tag]?.color)}
397
- onDropTag={(dragged: string) => handleCreateGroup(tag, dragged, tag)}
398
- onDropLayer={(draggedId: number) => handleCreateGroupFromLayer(tag, draggedId)}
399
- namingPopover={namingPopover.targetTag === tag ? namingPopover : undefined}
400
- onConfirmNaming={handleConfirmNaming}
401
- onCloseNaming={() => setNamingPopover(prev => ({ ...prev, isOpen: false, targetTag: null }))}
402
- onSetColor={(color) => onCreateTag(tag, color)}
403
- onSetDescription={(desc) => onCreateTag(tag, tagColors[tag]?.color, desc)}
404
- />
405
- </WrapItem>
406
- ))}
407
- </Wrap>
414
+ <VStack align="stretch" spacing={2}>
415
+ <HStack
416
+ spacing={1.5}
417
+ cursor="pointer"
418
+ onClick={() => setIsUnusedCollapsed(!isUnusedCollapsed)}
419
+ role="button"
420
+ opacity={0.6}
421
+ _hover={{ opacity: 1 }}
422
+ transition="opacity 0.2s"
423
+ >
424
+ <Box
425
+ transform={isUnusedCollapsed ? 'rotate(-90deg)' : 'none'}
426
+ transition="transform 0.2s"
427
+ display="flex"
428
+ alignItems="center"
429
+ >
430
+ <ChevronDownIcon size={10} />
431
+ </Box>
432
+ <Text fontSize="10px" fontWeight="bold" color="gray.500" textTransform="uppercase" letterSpacing="0.05em">
433
+ Other tags ({unusedTags.length})
434
+ </Text>
435
+ </HStack>
436
+ {!isUnusedCollapsed && (
437
+ <Wrap spacing={2} opacity={0.6}>
438
+ {unusedTags.map((tag) => (
439
+ <WrapItem key={tag}>
440
+ <TagItem
441
+ tag={tag}
442
+ color={tagColors[tag]?.color || '#A0AEC0'}
443
+ description={tagColors[tag]?.description || null}
444
+ tagCount={tagCounts[tag]}
445
+ onHover={(active) => onHoverLayer(active ? [tag] : null, tagColors[tag]?.color)}
446
+ onDropTag={(dragged: string) => handleCreateGroup(tag, dragged, tag)}
447
+ onDropLayer={(draggedId: number) => handleCreateGroupFromLayer(tag, draggedId)}
448
+ namingPopover={namingPopover.targetTag === tag ? namingPopover : undefined}
449
+ onConfirmNaming={handleConfirmNaming}
450
+ onCloseNaming={() => setNamingPopover(prev => ({ ...prev, isOpen: false, targetTag: null }))}
451
+ onSetColor={(color) => onCreateTag(tag, color)}
452
+ onSetDescription={(desc) => onCreateTag(tag, tagColors[tag]?.color, desc)}
453
+ />
454
+ </WrapItem>
455
+ ))}
456
+ </Wrap>
457
+ )}
458
+ </VStack>
408
459
  )}
409
460
  </VStack>
410
461
  </Box>
@@ -1,5 +1,5 @@
1
- import React from 'react'
2
- import { Box, Tooltip, VStack, Text, Divider } from '@chakra-ui/react'
1
+ import React, { useState } from 'react'
2
+ import { Box, Tooltip, VStack, Text, HStack } from '@chakra-ui/react'
3
3
  import { ZoomInIcon, ZoomOutIcon, ChevronDownIcon } from '../Icons'
4
4
  import { KbdHint } from '../PanelUI'
5
5
  import { NavItem } from './types'
@@ -20,6 +20,8 @@ export const ViewNavigator: React.FC<Props> = ({
20
20
  onFilterToggle,
21
21
  onHoverZoom,
22
22
  }) => {
23
+ const [hoveredType, setHoveredType] = useState<'out' | 'in' | null>(null)
24
+
23
25
  const renderNavButton = (type: 'out' | 'in', items: NavItem[]) => {
24
26
  const isOut = type === 'out'
25
27
  const label = isOut ? 'Zoom Out' : 'Zoom In'
@@ -28,6 +30,7 @@ export const ViewNavigator: React.FC<Props> = ({
28
30
  const disabled = items.length === 0
29
31
  const isActive = activeFilter === type
30
32
  const accentColor = isOut ? PARENT_VIEW_COLOR : CHILD_VIEW_COLOR
33
+ const isHovered = hoveredType === type
31
34
 
32
35
  const subtitle = disabled
33
36
  ? isOut
@@ -46,7 +49,7 @@ export const ViewNavigator: React.FC<Props> = ({
46
49
  : 'No child views'
47
50
  : `Navigate to ${isOut ? 'Parent' : 'Child'} View [${shortcut}]`
48
51
  }
49
- placement="left"
52
+ placement="top"
50
53
  openDelay={400}
51
54
  >
52
55
  <Box
@@ -56,41 +59,125 @@ export const ViewNavigator: React.FC<Props> = ({
56
59
  disabled={disabled}
57
60
  onClick={() => onFilterToggle(type, items)}
58
61
  onMouseEnter={() => {
62
+ setHoveredType(type)
59
63
  if (disabled || items.length !== 1) return
60
64
  onHoverZoom?.(items[0].elementId ?? null, type)
61
65
  }}
62
66
  onMouseLeave={() => {
67
+ setHoveredType(null)
63
68
  if (disabled || items.length !== 1) return
64
69
  onHoverZoom?.(null, null)
65
70
  }}
66
71
  opacity={disabled ? 0.4 : 1}
72
+ flex={hoveredType === null ? 1 : (isHovered ? 4 : 1)}
73
+ transition="all 0.4s cubic-bezier(0.4, 0, 0.2, 1)"
74
+ minW={0}
75
+ overflow="hidden"
76
+ position="relative"
77
+ px={isHovered || hoveredType === null ? 3 : 1}
78
+ sx={{
79
+ '.panel-action-icon-container': {
80
+ transition: 'all 0.3s ease',
81
+ width: '28px',
82
+ height: '28px',
83
+ marginRight: isOut ? '0.5rem' : 0,
84
+ marginLeft: isOut ? 0 : '0.5rem',
85
+ position: 'relative',
86
+ }
87
+ }}
67
88
  >
68
- <Box className="panel-action-icon-container" color={disabled ? 'gray.600' : accentColor}>
69
- <IconCmp />
70
- </Box>
71
- <VStack align="start" spacing={0} flex={1} minW={0}>
72
- <Text
73
- fontSize="sm"
74
- color={disabled ? 'gray.500' : 'white'}
75
- fontWeight="medium"
76
- isTruncated
77
- w="full"
78
- textAlign="left"
79
- >
80
- {label}
81
- </Text>
82
- <Text
83
- fontSize="xs"
84
- color={disabled ? 'gray.600' : isActive ? accentColor : 'gray.400'}
85
- isTruncated
86
- w="full"
87
- transition="color 0.15s"
88
- textAlign="left"
89
- >
90
- {subtitle}
91
- </Text>
92
- </VStack>
93
- {items.length > 1 && (
89
+ {isOut ? (
90
+ <>
91
+ <Box className="panel-action-icon-container" color={disabled ? 'gray.600' : accentColor}>
92
+ <IconCmp />
93
+ {items.length > 1 && (
94
+ <Box
95
+ position="absolute"
96
+ bottom="-5px"
97
+ right="-5px"
98
+ color="white"
99
+ fontSize="12px"
100
+ fontWeight="bold"
101
+ minW="14px"
102
+ h="14px"
103
+ display="flex"
104
+ alignItems="center"
105
+ justifyContent="center"
106
+ zIndex={2}
107
+ >
108
+ {items.length}
109
+ </Box>
110
+ )}
111
+ </Box>
112
+ <VStack
113
+ align="start"
114
+ spacing={0}
115
+ flex={1}
116
+ minW={0}
117
+ opacity={hoveredType === 'in' ? 0 : 1}
118
+ transition="opacity 0.2s, transform 0.3s"
119
+ transform={hoveredType === 'in' ? 'translateX(-10px)' : 'none'}
120
+ >
121
+ <Text fontSize="xs" color={disabled ? 'gray.500' : 'white'} fontWeight="bold" isTruncated w="full" textAlign="left">
122
+ {label}
123
+ </Text>
124
+ {isHovered && (
125
+ <Text fontSize="10px" color={disabled ? 'gray.600' : isActive ? accentColor : 'gray.500'} isTruncated w="full" transition="color 0.15s" textAlign="left">
126
+ {subtitle}
127
+ </Text>
128
+ )}
129
+ </VStack>
130
+ <Box opacity={hoveredType === 'in' ? 0 : 1} transition="opacity 0.2s">
131
+ <KbdHint ml={1}>{shortcut}</KbdHint>
132
+ </Box>
133
+ </>
134
+ ) : (
135
+ <>
136
+ <Box opacity={hoveredType === 'out' ? 0 : 1} transition="opacity 0.2s">
137
+ <KbdHint ml={0} mr={1}>{shortcut}</KbdHint>
138
+ </Box>
139
+ <VStack
140
+ align="end"
141
+ spacing={0}
142
+ flex={1}
143
+ minW={0}
144
+ opacity={hoveredType === 'out' ? 0 : 1}
145
+ transition="opacity 0.2s, transform 0.3s"
146
+ transform={hoveredType === 'out' ? 'translateX(10px)' : 'none'}
147
+ >
148
+ <Text fontSize="xs" color={disabled ? 'gray.500' : 'white'} fontWeight="bold" isTruncated w="full" textAlign="right">
149
+ {label}
150
+ </Text>
151
+ {isHovered && (
152
+ <Text fontSize="10px" color={disabled ? 'gray.600' : isActive ? accentColor : 'gray.500'} isTruncated w="full" transition="color 0.15s" textAlign="right">
153
+ {subtitle}
154
+ </Text>
155
+ )}
156
+ </VStack>
157
+ <Box className="panel-action-icon-container" color={disabled ? 'gray.600' : accentColor}>
158
+ <IconCmp />
159
+ {items.length > 1 && (
160
+ <Box
161
+ position="absolute"
162
+ bottom="-5px"
163
+ right="-5px"
164
+ color="white"
165
+ fontSize="12px"
166
+ fontWeight="bold"
167
+ minW="14px"
168
+ h="14px"
169
+ display="flex"
170
+ alignItems="center"
171
+ justifyContent="center"
172
+ zIndex={2}
173
+ >
174
+ {items.length}
175
+ </Box>
176
+ )}
177
+ </Box>
178
+ </>
179
+ )}
180
+ {items.length > 1 && isHovered && (
94
181
  <Box
95
182
  color="whiteAlpha.400"
96
183
  _groupHover={{ color: 'white' }}
@@ -102,20 +189,19 @@ export const ViewNavigator: React.FC<Props> = ({
102
189
  <ChevronDownIcon size={12} strokeWidth={3.5} />
103
190
  </Box>
104
191
  )}
105
- {isActive && items.length <= 1 && (
192
+ {isActive && items.length <= 1 && isHovered && (
106
193
  <Box w="5px" h="5px" rounded="full" bg={accentColor} flexShrink={0} mx={1} />
107
194
  )}
108
- <KbdHint>{shortcut}</KbdHint>
109
195
  </Box>
110
196
  </Tooltip>
111
197
  )
112
198
  }
113
199
 
114
200
  return (
115
- <VStack spacing={0} align="stretch" flexShrink={0} py={1}>
116
- <Box>{renderNavButton('out', parents)}</Box>
117
- <Divider borderColor="whiteAlpha.100" />
118
- <Box>{renderNavButton('in', children)}</Box>
119
- </VStack>
201
+ <HStack spacing={0} align="stretch" flexShrink={0} height="60px" borderColor="whiteAlpha.100">
202
+ {renderNavButton('out', parents)}
203
+ <Box w="1px" bg="whiteAlpha.100" />
204
+ {renderNavButton('in', children)}
205
+ </HStack>
120
206
  )
121
207
  }
@@ -142,23 +142,17 @@ function ViewExplorer({
142
142
  const children = useMemo(() => {
143
143
  if (viewId == null) return []
144
144
  const diagMap = new Map<number, NavItem>()
145
- flat.forEach((n) => {
146
- if (n.parent_view_id === viewId)
147
- diagMap.set(n.id, { id: n.id, name: n.name, subtitle: 'Child View' })
148
- })
145
+
149
146
  const objMap = new Map(viewElements.map((o) => [o.element_id, o.name]))
150
147
  Object.entries(linksMap).forEach(([elementIdStr, links]) => {
151
148
  const elementId = Number(elementIdStr)
152
- if (!Number.isFinite(elementId)) return
153
- const isOnCanvas = onCanvasIds.has(elementId)
149
+ if (!Number.isFinite(elementId) || !onCanvasIds.has(elementId)) return
150
+
154
151
  const objName = objMap.get(elementId) || 'Element'
155
152
  links.forEach((link) => {
156
153
  const existing = diagMap.get(link.to_view_id)
157
154
  if (existing) {
158
- if (!existing.elementId && isOnCanvas) existing.elementId = elementId
159
- if (existing.subtitle === 'Child View') {
160
- existing.subtitle = `Child View (Via ${objName})`
161
- } else if (!existing.subtitle?.includes(objName)) {
155
+ if (!existing.subtitle?.includes(objName)) {
162
156
  existing.subtitle = `${existing.subtitle}, ${objName}`
163
157
  }
164
158
  } else {
@@ -166,13 +160,13 @@ function ViewExplorer({
166
160
  id: link.to_view_id,
167
161
  name: link.to_view_name,
168
162
  subtitle: `Via ${objName}`,
169
- elementId: isOnCanvas ? elementId : undefined,
163
+ elementId: elementId,
170
164
  })
171
165
  }
172
166
  })
173
167
  })
174
168
  return Array.from(diagMap.values())
175
- }, [viewId, flat, linksMap, viewElements, onCanvasIds])
169
+ }, [viewId, linksMap, viewElements, onCanvasIds])
176
170
 
177
171
  const viewHoverMap = useMemo(() => {
178
172
  const map = new Map<number, { elementId: number | undefined; type: 'in' | 'out' }>()
@@ -184,32 +178,14 @@ function ViewExplorer({
184
178
  const filteredByMode = useMemo(() => {
185
179
  if (activeFilter === 'out') {
186
180
  const parentIds = new Set(parents.map((p) => p.id))
187
- const ancestors = new Set<number>()
188
- const findAncestors = (id: number) => {
189
- const node = treeNodes.find(n => n.id === id)
190
- if (node?.parent_view_id) {
191
- ancestors.add(node.parent_view_id)
192
- findAncestors(node.parent_view_id)
193
- }
194
- }
195
- if (viewId) findAncestors(viewId)
196
- return flat.filter((n) => parentIds.has(n.id) || ancestors.has(n.id) || n.id === viewId)
181
+ return flat.filter((n) => parentIds.has(n.id) || n.id === viewId)
197
182
  }
198
183
  if (activeFilter === 'in') {
199
184
  const childIds = new Set(children.map((c) => c.id))
200
- const subtree = new Set<number>()
201
- const traverse = (nodes: TreeNode[]) => {
202
- nodes.forEach(n => {
203
- subtree.add(n.id)
204
- traverse(n.children)
205
- })
206
- }
207
- const current = flat.find(n => n.id === viewId)
208
- if (current) traverse(current.children)
209
- return flat.filter((n) => childIds.has(n.id) || subtree.has(n.id) || n.id === viewId)
185
+ return flat.filter((n) => childIds.has(n.id) || n.id === viewId)
210
186
  }
211
187
  return flat
212
- }, [flat, parents, children, activeFilter, viewId, treeNodes])
188
+ }, [flat, parents, children, activeFilter, viewId])
213
189
 
214
190
  const filtered = useMemo(() => {
215
191
  if (!query.trim()) return filteredByMode
@@ -38,6 +38,12 @@ import { buildVisibleProxyConnectors, collectVisibleNodeAnchors, drawVisibleProx
38
38
  export interface ZUICanvasHandle {
39
39
  fitView(): void
40
40
  focusDiagram(viewId: number): boolean
41
+ setCameraFrame(frame: ZUICameraFrame): boolean
42
+ }
43
+
44
+ export interface ZUICameraFrame {
45
+ profile: 'detail-to-overview'
46
+ progress: number
41
47
  }
42
48
 
43
49
  interface Props {
@@ -238,6 +244,81 @@ function easeOutQuart(t: number): number {
238
244
  return 1 - Math.pow(1 - t, 4)
239
245
  }
240
246
 
247
+ function clamp01(value: number): number {
248
+ return Math.max(0, Math.min(1, value))
249
+ }
250
+
251
+ function fitWorldRect(
252
+ rect: { x: number; y: number; w: number; h: number },
253
+ canvasW: number,
254
+ canvasH: number,
255
+ maxZoom: number,
256
+ padding: number,
257
+ ): ZUIViewState | null {
258
+ const bboxW = Math.max(1, rect.w)
259
+ const bboxH = Math.max(1, rect.h)
260
+ const zoom = Math.min(
261
+ (canvasW * (1 - padding * 2)) / bboxW,
262
+ (canvasH * (1 - padding * 2)) / bboxH,
263
+ maxZoom,
264
+ )
265
+ if (!Number.isFinite(zoom) || zoom <= 0) return null
266
+
267
+ return {
268
+ x: (canvasW - bboxW * zoom) / 2 - rect.x * zoom,
269
+ y: (canvasH - bboxH * zoom) / 2 - rect.y * zoom,
270
+ zoom,
271
+ }
272
+ }
273
+
274
+ function findFirstExpandableNode(groups: DiagramGroupLayout[]): PathItem | null {
275
+ for (const group of groups) {
276
+ const found = findFirstExpandableNodeInTree(group.nodes, 0, 0, 1, 0, 0)
277
+ if (found) return found
278
+ }
279
+ return null
280
+ }
281
+
282
+ function findFirstExpandableNodeInTree(
283
+ nodes: DiagramGroupLayout['nodes'],
284
+ parentAbsX: number,
285
+ parentAbsY: number,
286
+ parentAbsScale: number,
287
+ parentChildOffsetX: number,
288
+ parentChildOffsetY: number,
289
+ ): PathItem | null {
290
+ for (const node of nodes) {
291
+ const absX = parentAbsX + (node.worldX - parentChildOffsetX) * parentAbsScale
292
+ const absY = parentAbsY + (node.worldY - parentChildOffsetY) * parentAbsScale
293
+ const absW = node.worldW * parentAbsScale
294
+ const absH = node.worldH * parentAbsScale
295
+
296
+ if (node.children.length > 0) {
297
+ return {
298
+ id: node.id,
299
+ label: node.linkedDiagramLabel || node.label,
300
+ type: 'node',
301
+ isCircular: node.isCircular,
302
+ absX,
303
+ absY,
304
+ absW,
305
+ absH,
306
+ }
307
+ }
308
+
309
+ const found = findFirstExpandableNodeInTree(
310
+ node.children,
311
+ absX,
312
+ absY,
313
+ parentAbsScale * node.childScale,
314
+ node.childOffsetX,
315
+ node.childOffsetY,
316
+ )
317
+ if (found) return found
318
+ }
319
+ return null
320
+ }
321
+
241
322
  export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({ data, onReady, onZoom, onPan, highlightedTags, highlightColor, hiddenTags, crossBranchSettings, hoverLocked = false }, ref) {
242
323
  const canvasRef = useRef<HTMLCanvasElement>(null)
243
324
  const containerRef = useRef<HTMLDivElement>(null)
@@ -445,6 +526,63 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
445
526
  return true
446
527
  }, [isMobileLayout, layout.groups, maxZoom, setHoveredItem, setViewState, viewStateRef])
447
528
 
529
+ const setCameraFrame = useCallback((frame: ZUICameraFrame) => {
530
+ if (frame.profile !== 'detail-to-overview') return false
531
+
532
+ const el = containerRef.current
533
+ if (!el) return false
534
+
535
+ const canvasW = el.offsetWidth
536
+ const canvasH = el.offsetHeight
537
+ if (canvasW === 0 || canvasH === 0) return false
538
+
539
+ const detailTarget = findFirstExpandableNode(layout.groups)
540
+ const overviewTarget = layout.groups[0]
541
+ if (!detailTarget || !overviewTarget) return false
542
+
543
+ const detail = fitWorldRect(
544
+ {
545
+ x: detailTarget.absX,
546
+ y: detailTarget.absY,
547
+ w: detailTarget.absW,
548
+ h: detailTarget.absH,
549
+ },
550
+ canvasW,
551
+ canvasH,
552
+ maxZoom,
553
+ 0.28,
554
+ )
555
+
556
+ const overview = fitWorldRect(
557
+ {
558
+ x: overviewTarget.worldX,
559
+ y: overviewTarget.worldY,
560
+ w: overviewTarget.worldW,
561
+ h: overviewTarget.worldH,
562
+ },
563
+ canvasW,
564
+ canvasH,
565
+ maxZoom,
566
+ 0.18,
567
+ )
568
+
569
+ if (!detail || !overview) return false
570
+
571
+ if (cameraTransitionRef.current !== null) {
572
+ cancelAnimationFrame(cameraTransitionRef.current)
573
+ cameraTransitionRef.current = null
574
+ }
575
+
576
+ setHoveredItem(null, true)
577
+ const t = easeOutQuart(clamp01(frame.progress))
578
+ setViewState({
579
+ x: detail.x + (overview.x - detail.x) * t,
580
+ y: detail.y + (overview.y - detail.y) * t,
581
+ zoom: detail.zoom + (overview.zoom - detail.zoom) * t,
582
+ })
583
+ return true
584
+ }, [layout.groups, maxZoom, setHoveredItem, setViewState])
585
+
448
586
  useEffect(() => {
449
587
  return () => {
450
588
  if (cameraTransitionRef.current !== null) {
@@ -482,8 +620,9 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
482
620
  fitView(el.offsetWidth, el.offsetHeight, layout.bbox)
483
621
  },
484
622
  focusDiagram,
623
+ setCameraFrame,
485
624
  }),
486
- [fitView, focusDiagram, layout.bbox, setHoveredItem],
625
+ [fitView, focusDiagram, layout.bbox, setCameraFrame, setHoveredItem],
487
626
  )
488
627
 
489
628
  // ── RAF render loop ──────────────────────────────────────────────