@tldiagram/core-ui 1.91.0 → 1.93.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.
Files changed (37) hide show
  1. package/dist/api/client.d.ts +13 -1
  2. package/dist/components/ElementNode.d.ts +9 -0
  3. package/dist/config/runtime-vscode.d.ts +1 -0
  4. package/dist/config/runtime.d.ts +1 -0
  5. package/dist/index.d.ts +0 -1
  6. package/dist/index.js +10063 -9512
  7. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +2 -1
  8. package/dist/pages/ViewEditor/hooks/useViewData.d.ts +20 -21
  9. package/dist/shims/empty-node-module.d.ts +2 -0
  10. package/dist/store/useStore.d.ts +78 -0
  11. package/dist/store/useStore.test.d.ts +1 -0
  12. package/package.json +7 -4
  13. package/src/App.tsx +0 -4
  14. package/src/api/client.ts +39 -1
  15. package/src/components/ElementNode.tsx +11 -58
  16. package/src/components/ElementPanel.tsx +2 -2
  17. package/src/components/LayoutSection.tsx +68 -93
  18. package/src/components/ViewGridNode.tsx +1 -4
  19. package/src/components/ZUI/renderer.ts +166 -66
  20. package/src/components/ZUI/useZUIInteraction.ts +235 -81
  21. package/src/config/runtime-vscode.ts +6 -0
  22. package/src/config/runtime.ts +4 -0
  23. package/src/index.ts +0 -1
  24. package/src/main.tsx +26 -14
  25. package/src/pages/ViewEditor/context.tsx +12 -4
  26. package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +172 -121
  27. package/src/pages/ViewEditor/hooks/useViewData.ts +455 -253
  28. package/src/pages/ViewEditor/index.tsx +45 -32
  29. package/src/shims/empty-node-module.ts +1 -0
  30. package/src/store/useStore.test.ts +272 -0
  31. package/src/store/useStore.ts +285 -0
  32. package/dist/demo/DemoPage.d.ts +0 -9
  33. package/dist/demo/seed.d.ts +0 -9
  34. package/dist/demo/store.d.ts +0 -137
  35. package/src/demo/DemoPage.tsx +0 -184
  36. package/src/demo/seed.ts +0 -67
  37. package/src/demo/store.ts +0 -536
@@ -1,13 +1,13 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
- import { MarkerType, type Edge as RFEdge, type Node as RFNode } from 'reactflow'
1
+ import type { CSSProperties } from 'react'
2
+ import { useCallback, useEffect, useMemo, useRef } from 'react'
3
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
4
+ import { MarkerType } from 'reactflow'
3
5
  import { api } from '../../../api/client'
4
6
  import type {
5
7
  ViewTreeNode,
6
8
  PlacedElement,
7
9
  LibraryElement,
8
10
  Connector,
9
- IncomingViewConnector,
10
- ViewConnector,
11
11
  Tag,
12
12
  } from '../../../types'
13
13
  import {
@@ -17,6 +17,7 @@ import {
17
17
  getVisualHandleIdForGroup,
18
18
  getVisualHandleSlot,
19
19
  } from '../../../utils/edgeDistribution'
20
+ import { buildViewContentLinks, useStore } from '../../../store/useStore'
20
21
 
21
22
  interface ViewDataOptions {
22
23
  viewId: number | null
@@ -47,6 +48,106 @@ function alphaColor(color: string, opacity: number): string {
47
48
  return `color-mix(in srgb, ${color} ${Math.round(opacity * 100)}%, transparent)`
48
49
  }
49
50
 
51
+ // Stable style refs so unchanged nodes keep identical style references across renders,
52
+ // letting structural-sharing fast-path bail out without rebuilding the node.
53
+ const HIDDEN_STYLE: CSSProperties = { opacity: 0.1, pointerEvents: 'none' }
54
+ const SOFT_FOCUS_STYLE: CSSProperties = { opacity: 0.2 }
55
+ const EMPTY_ARRAY: readonly never[] = Object.freeze([])
56
+ const EMPTY_NODE_CONNECTION_META = Object.freeze({
57
+ key: '',
58
+ connectedHandleIds: EMPTY_ARRAY as readonly string[],
59
+ selectedHandleIds: EMPTY_ARRAY as readonly string[],
60
+ reconnectCandidates: EMPTY_ARRAY as readonly NodeReconnectCandidate[],
61
+ isConnectorHighlighted: false,
62
+ })
63
+
64
+ type NodeReconnectCandidate = {
65
+ handleId: string
66
+ edgeId: string
67
+ endpoint: 'source' | 'target'
68
+ selected: boolean
69
+ }
70
+
71
+ type NodeConnectionMeta = {
72
+ key: string
73
+ connectedHandleIds: readonly string[]
74
+ selectedHandleIds: readonly string[]
75
+ reconnectCandidates: readonly NodeReconnectCandidate[]
76
+ isConnectorHighlighted: boolean
77
+ }
78
+
79
+ type ConnectorLayout = {
80
+ connector: Connector
81
+ sourceHandle: string
82
+ targetHandle: string
83
+ sourceGroupIndex: number
84
+ sourceGroupCount: number
85
+ targetGroupIndex: number
86
+ targetGroupCount: number
87
+ sourceHandleSide: string
88
+ targetHandleSide: string
89
+ sourceHandleSlot: number
90
+ targetHandleSlot: number
91
+ }
92
+
93
+ function buildConnectorLayouts(connectors: Connector[], elementMap: Map<number, PlacedElement>): ConnectorLayout[] {
94
+ const filtered = connectors.filter((connector) =>
95
+ elementMap.has(connector.source_element_id) && elementMap.has(connector.target_element_id),
96
+ )
97
+
98
+ const handleUsage: Record<string, { id: string; type: 'source' | 'target'; otherNodeCoord: number }[]> = {}
99
+ filtered.forEach((connector) => {
100
+ const srcNode = elementMap.get(connector.source_element_id)
101
+ const tgtNode = elementMap.get(connector.target_element_id)
102
+ if (!srcNode || !tgtNode) return
103
+
104
+ const sourceSide = getLogicalHandleId(connector.source_handle, DEFAULT_SOURCE_HANDLE_SIDE) ?? DEFAULT_SOURCE_HANDLE_SIDE
105
+ const targetSide = getLogicalHandleId(connector.target_handle, DEFAULT_TARGET_HANDLE_SIDE) ?? DEFAULT_TARGET_HANDLE_SIDE
106
+
107
+ const srcKey = `${connector.source_element_id}-${sourceSide}`
108
+ handleUsage[srcKey] ??= []
109
+ const srcCoord = (sourceSide === 'left' || sourceSide === 'right') ? (tgtNode.position_y ?? 0) : (tgtNode.position_x ?? 0)
110
+ handleUsage[srcKey].push({ id: String(connector.id), type: 'source', otherNodeCoord: srcCoord })
111
+
112
+ const tgtKey = `${connector.target_element_id}-${targetSide}`
113
+ handleUsage[tgtKey] ??= []
114
+ const tgtCoord = (targetSide === 'left' || targetSide === 'right') ? (srcNode.position_y ?? 0) : (srcNode.position_x ?? 0)
115
+ handleUsage[tgtKey].push({ id: String(connector.id), type: 'target', otherNodeCoord: tgtCoord })
116
+ })
117
+
118
+ Object.values(handleUsage).forEach((usages) => {
119
+ usages.sort((a, b) => a.otherNodeCoord - b.otherNodeCoord)
120
+ })
121
+
122
+ return filtered.map((connector) => {
123
+ const edgeId = String(connector.id)
124
+ const sourceSide = getLogicalHandleId(connector.source_handle, DEFAULT_SOURCE_HANDLE_SIDE) ?? DEFAULT_SOURCE_HANDLE_SIDE
125
+ const targetSide = getLogicalHandleId(connector.target_handle, DEFAULT_TARGET_HANDLE_SIDE) ?? DEFAULT_TARGET_HANDLE_SIDE
126
+ const srcGroup = handleUsage[`${connector.source_element_id}-${sourceSide}`] ?? []
127
+ const tgtGroup = handleUsage[`${connector.target_element_id}-${targetSide}`] ?? []
128
+ const sourceGroupIndex = srcGroup.findIndex((usage) => usage.id === edgeId && usage.type === 'source')
129
+ const targetGroupIndex = tgtGroup.findIndex((usage) => usage.id === edgeId && usage.type === 'target')
130
+ const sourceGroupCount = Math.max(srcGroup.length, 1)
131
+ const targetGroupCount = Math.max(tgtGroup.length, 1)
132
+ const sourceHandleSlot = getVisualHandleSlot(sourceGroupIndex, sourceGroupCount)
133
+ const targetHandleSlot = getVisualHandleSlot(targetGroupIndex, targetGroupCount)
134
+
135
+ return {
136
+ connector,
137
+ sourceHandle: getVisualHandleIdForGroup(sourceSide, sourceGroupIndex, sourceGroupCount),
138
+ targetHandle: getVisualHandleIdForGroup(targetSide, targetGroupIndex, targetGroupCount),
139
+ sourceGroupIndex,
140
+ sourceGroupCount: srcGroup.length,
141
+ targetGroupIndex,
142
+ targetGroupCount: tgtGroup.length,
143
+ sourceHandleSide: sourceSide,
144
+ targetHandleSide: targetSide,
145
+ sourceHandleSlot,
146
+ targetHandleSlot,
147
+ }
148
+ })
149
+ }
150
+
50
151
  export function useViewData({
51
152
  viewId,
52
153
  interactionSourceId,
@@ -69,17 +170,32 @@ export function useViewData({
69
170
  stableOnHoverZoom,
70
171
  hoveredZoomRef,
71
172
  }: ViewDataOptions) {
72
- const [view, setView] = useState<ViewTreeNode | null | undefined>(undefined)
73
- const [viewElements, setViewElements] = useState<PlacedElement[]>([])
74
- const [connectors, setConnectors] = useState<Connector[]>([])
75
- const [rfNodes, setRfNodes] = useState<RFNode[]>([])
76
- const [rfEdges, setRfEdges] = useState<RFEdge[]>([])
77
- const [linksMap, setLinksMap] = useState<Record<number, ViewConnector[]>>({})
78
- const [parentLinksMap, setParentLinksMap] = useState<Record<number, ViewConnector[]>>({})
79
- const [incomingLinks, setIncomingLinks] = useState<IncomingViewConnector[]>([])
80
- const [treeData, setTreeData] = useState<ViewTreeNode[]>([])
81
- const [allElements, setAllElements] = useState<LibraryElement[]>([])
82
- const [libraryRefresh, setLibraryRefresh] = useState(0)
173
+ const queryClient = useQueryClient()
174
+ const view = useStore((state) => state.view)
175
+ const setView = useStore((state) => state.setView)
176
+ const viewElements = useStore((state) => state.viewElements)
177
+ const setViewElements = useStore((state) => state.setViewElements)
178
+ const connectors = useStore((state) => state.connectors)
179
+ const setConnectors = useStore((state) => state.setConnectors)
180
+ const rfNodes = useStore((state) => state.nodes)
181
+ const setRfNodes = useStore((state) => state.setNodes)
182
+ const rfEdges = useStore((state) => state.edges)
183
+ const setRfEdges = useStore((state) => state.setEdges)
184
+ const linksMap = useStore((state) => state.linksMap)
185
+ const setLinksMap = useStore((state) => state.setLinksMap)
186
+ const parentLinksMap = useStore((state) => state.parentLinksMap)
187
+ const setParentLinksMap = useStore((state) => state.setParentLinksMap)
188
+ const incomingLinks = useStore((state) => state.incomingLinks)
189
+ const treeData = useStore((state) => state.treeData)
190
+ const allElements = useStore((state) => state.allElements)
191
+ const setAllElements = useStore((state) => state.setAllElements)
192
+ const libraryRefresh = useStore((state) => state.libraryRefresh)
193
+ const setLibraryRefresh = useStore((state) => state.setLibraryRefresh)
194
+ const hydrateViewContent = useStore((state) => state.hydrateViewContent)
195
+ const resetCanvas = useStore((state) => state.resetCanvas)
196
+ const removeElementPlacement = useStore((state) => state.removeElementPlacement)
197
+ const removeElementEverywhere = useStore((state) => state.removeElementEverywhere)
198
+ const mergeSavedElement = useStore((state) => state.mergeSavedElement)
83
199
 
84
200
  // Mutable refs for stable callbacks
85
201
  const viewElementsRef = useRef(viewElements)
@@ -101,158 +217,90 @@ export function useViewData({
101
217
 
102
218
  // ── Fetch tree ─────────────────────────────────────────────────────────────
103
219
  const refreshGrid = useCallback(async () => {
104
- const tree = await api.workspace.views.tree().catch(() => null)
105
- if (tree) setTreeData(tree)
106
- }, [])
220
+ const tree = await queryClient.fetchQuery({
221
+ queryKey: ['workspace', 'views', 'tree'],
222
+ queryFn: () => api.workspace.views.tree(),
223
+ staleTime: 0,
224
+ }).catch(() => null)
225
+ if (tree) useStore.getState().setTreeData(tree)
226
+ }, [queryClient])
107
227
 
108
228
  // ── Fetch view content ──────────────────────────────────────────────────
229
+ const viewContentQuery = useQuery({
230
+ queryKey: ['workspace', 'views', viewId, 'editor-content'],
231
+ enabled: viewId !== null,
232
+ queryFn: async () => {
233
+ if (viewId === null) throw new Error('Missing view id')
234
+ const [diag, content, tree] = await Promise.all([
235
+ api.workspace.views.get(viewId),
236
+ api.workspace.views.content(viewId),
237
+ api.workspace.views.tree(),
238
+ ])
239
+ const viewElements = content.placements || []
240
+ const connectors = content.connectors || []
241
+ return {
242
+ view: diag,
243
+ viewElements,
244
+ connectors,
245
+ treeData: tree,
246
+ ...buildViewContentLinks(tree, viewId, viewElements),
247
+ }
248
+ },
249
+ })
250
+
109
251
  useEffect(() => {
110
252
  if (viewId === null) return
111
- let active = true
112
-
113
- const load = async () => {
114
- try {
115
- const [diag, content, tree] = await Promise.all([
116
- api.workspace.views.get(viewId),
117
- api.workspace.views.content(viewId),
118
- api.workspace.views.tree(),
119
- ])
120
- if (!active) return
121
-
122
- const safeObjs = content.placements || []
123
- const safeConnectors = content.connectors || []
124
-
125
- const linksObj: Record<number, ViewConnector[]> = {}
126
- const parentLinksObj: Record<number, ViewConnector[]> = {}
127
-
128
- // Helper: recursively find nodes in tree that are owned by elements on canvas (zoom-in)
129
- // OR the parent view of the current view (zoom-out)
130
- const findViewByOwner = (nodes: ViewTreeNode[], elementId: number): ViewTreeNode | null => {
131
- for (const node of nodes) {
132
- if (node.owner_element_id !== null && Number(node.owner_element_id) === Number(elementId)) return node
133
- const found = findViewByOwner(node.children, elementId)
134
- if (found) return found
135
- }
136
- return null
137
- }
138
-
139
- const findViewPath = (nodes: ViewTreeNode[], targetId: number, path: ViewTreeNode[] = []): ViewTreeNode[] | null => {
140
- for (const node of nodes) {
141
- if (node.id === targetId) return [...path, node]
142
- const found = findViewPath(node.children, targetId, [...path, node])
143
- if (found) return found
144
- }
145
- return null
146
- }
253
+ if (viewContentQuery.data) hydrateViewContent(viewContentQuery.data)
254
+ }, [hydrateViewContent, viewContentQuery.data, viewId])
147
255
 
148
- const viewPath = findViewPath(tree, viewId)
149
- const parentView = viewPath && viewPath.length > 1 ? viewPath[viewPath.length - 2] : null
150
- const currentViewInTree = viewPath ? viewPath[viewPath.length - 1] : null
151
-
152
- const incoming: IncomingViewConnector[] = []
153
- if (parentView && currentViewInTree?.owner_element_id) {
154
- incoming.push({
155
- id: 0,
156
- element_id: currentViewInTree.owner_element_id,
157
- element_name: 'Parent', // Optional: could find name in parentView.placements
158
- from_view_id: parentView.id,
159
- from_view_name: parentView.name,
160
- to_view_id: viewId,
161
- })
162
- }
163
-
164
- for (const obj of safeObjs) {
165
- // Child Link: if there exists a view owned by this element
166
- const childView = findViewByOwner(tree, obj.element_id)
167
- if (childView) {
168
- linksObj[obj.element_id] = [{
169
- id: 0,
170
- element_id: obj.element_id,
171
- from_view_id: viewId,
172
- to_view_id: childView.id,
173
- to_view_name: childView.name,
174
- relation_type: 'child',
175
- }]
176
- }
177
-
178
- // Parent Link: all elements in a view can 'zoom out' to its structural parent
179
- if (parentView) {
180
- parentLinksObj[obj.element_id] = [{
181
- id: 0,
182
- element_id: obj.element_id,
183
- from_view_id: parentView.id, // we go TO the parentView, coming FROM the parentView context?
184
- to_view_id: parentView.id,
185
- to_view_name: parentView.name,
186
- relation_type: 'parent',
187
- }]
188
- }
189
- }
190
-
191
- setLinksMap(linksObj)
192
- setParentLinksMap(parentLinksObj)
193
- setConnectors(safeConnectors)
194
- setViewElements(safeObjs)
195
- setIncomingLinks(incoming)
196
- setView(diag)
197
- setTreeData(tree)
198
- } catch (err) {
199
- console.error('DIAGRAM EDITOR LOAD ERROR:', err)
200
- if (active) setView(null)
201
- }
256
+ useEffect(() => {
257
+ if (viewContentQuery.isError) {
258
+ console.error('DIAGRAM EDITOR LOAD ERROR:', viewContentQuery.error)
259
+ setView(null)
202
260
  }
203
-
204
- load()
205
- return () => { active = false }
206
- }, [viewId])
261
+ }, [setView, viewContentQuery.error, viewContentQuery.isError])
207
262
 
208
263
  // ── Clear canvas on navigation ─────────────────────────────────────────────
209
264
  useEffect(() => {
210
- setRfNodes([])
211
- setRfEdges([])
212
- }, [viewId])
265
+ resetCanvas()
266
+ }, [resetCanvas, viewId])
213
267
 
214
268
  // ── Keep all-org elements for inline adder ──────────────────────────────────
269
+ const allElementsQuery = useQuery({
270
+ queryKey: ['elements', 'list', libraryRefresh],
271
+ queryFn: () => api.elements.list(),
272
+ })
273
+
215
274
  useEffect(() => {
216
- api.elements.list().then(setAllElements).catch(() => { /* intentionally empty */ })
217
- }, [libraryRefresh])
275
+ if (allElementsQuery.data) setAllElements(allElementsQuery.data)
276
+ }, [allElementsQuery.data, setAllElements])
218
277
 
219
278
  // ── Refresh elements ────────────────────────────────────────────────────────
220
279
  const refreshElements = useCallback(async () => {
221
280
  if (viewId === null) return
222
- const fresh = await api.workspace.views.content(viewId).catch(() => null)
223
- if (fresh) setViewElements(fresh.placements)
224
- }, [viewId])
281
+ const fresh = await queryClient.fetchQuery({
282
+ queryKey: ['workspace', 'views', viewId, 'content'],
283
+ queryFn: () => api.workspace.views.content(viewId),
284
+ staleTime: 0,
285
+ }).catch(() => null)
286
+ if (fresh) {
287
+ setViewElements(fresh.placements)
288
+ setConnectors(fresh.connectors)
289
+ }
290
+ }, [queryClient, setConnectors, setViewElements, viewId])
225
291
 
226
- // ── CRDT-aware element mutation helpers ────────────────────────────────────
292
+ // ── Element mutation helpers ───────────────────────────────────────────────
227
293
  const handleElementDeleted = useCallback((deletedId: number) => {
228
- setViewElements((prev) => prev.filter((o) => o.element_id !== deletedId))
229
- }, [])
294
+ removeElementPlacement(deletedId)
295
+ }, [removeElementPlacement])
230
296
 
231
297
  const handleElementPermanentlyDeleted = useCallback((deletedId: number) => {
232
- setViewElements((prev) => prev.filter((o) => o.element_id !== deletedId))
233
- setLibraryRefresh((n) => n + 1)
234
- }, [])
298
+ removeElementEverywhere(deletedId)
299
+ }, [removeElementEverywhere])
235
300
 
236
301
  const handleElementSaved = useCallback((saved: LibraryElement) => {
237
- setLibraryRefresh((n) => n + 1)
238
- setViewElements((prev) =>
239
- prev.map((o) =>
240
- o.element_id === saved.id
241
- ? {
242
- ...o,
243
- name: saved.name,
244
- description: saved.description,
245
- kind: saved.kind,
246
- technology: saved.technology,
247
- url: saved.url,
248
- logo_url: saved.logo_url,
249
- technology_connectors: saved.technology_connectors,
250
- tags: saved.tags,
251
- }
252
- : o,
253
- ),
254
- )
255
- }, [])
302
+ mergeSavedElement(saved)
303
+ }, [mergeSavedElement])
256
304
 
257
305
  // ── Stable element ID set ───────────────────────────────────────────────────
258
306
  const existingElementIdsRef = useRef<Set<number>>(new Set())
@@ -268,51 +316,197 @@ export function useViewData({
268
316
  return nextIds
269
317
  }, [viewElements])
270
318
 
319
+ // Stable-ref fallback parent links: flatten only when the underlying map changes so
320
+ // nodes without their own parent link entry can still pass the data-equality fast path.
321
+ const viewParentLinks = useMemo(
322
+ () => Object.values(parentLinksMap).flat(),
323
+ [parentLinksMap],
324
+ )
325
+
326
+ const parentViewId = useMemo(() => {
327
+ const findInTreeById = (nodes: ViewTreeNode[], id: number): ViewTreeNode | null => {
328
+ for (const node of nodes) {
329
+ if (node.id === id) return node
330
+ const found = findInTreeById(node.children, id)
331
+ if (found) return found
332
+ }
333
+ return null
334
+ }
335
+ const currentView = findInTreeById(treeData, viewId || -1)
336
+ return currentView?.parent_view_id
337
+ }, [treeData, viewId])
338
+
339
+ const elementMap = useMemo(() => {
340
+ const next = new Map<number, PlacedElement>()
341
+ for (const element of viewElements) next.set(element.element_id, element)
342
+ return next
343
+ }, [viewElements])
344
+
345
+ const connectorLayouts = useMemo(
346
+ () => buildConnectorLayouts(connectors, elementMap),
347
+ [connectors, elementMap],
348
+ )
349
+
350
+ const connectionMetaCacheRef = useRef<Map<number, NodeConnectionMeta>>(new Map())
351
+ const nodeConnectionMetaByElementId = useMemo(() => {
352
+ const drafts = new Map<number, {
353
+ connected: Set<string>
354
+ selected: Set<string>
355
+ reconnect: NodeReconnectCandidate[]
356
+ highlighted: boolean
357
+ }>()
358
+
359
+ const draftFor = (elementId: number) => {
360
+ let draft = drafts.get(elementId)
361
+ if (!draft) {
362
+ draft = { connected: new Set(), selected: new Set(), reconnect: [], highlighted: false }
363
+ drafts.set(elementId, draft)
364
+ }
365
+ return draft
366
+ }
367
+
368
+ const selectedId = selectedEdgeId === null ? null : String(selectedEdgeId)
369
+ for (const layout of connectorLayouts) {
370
+ const connector = layout.connector
371
+ const edgeId = String(connector.id)
372
+ const isSelected = selectedId === edgeId
373
+ const sourceDraft = draftFor(connector.source_element_id)
374
+ const targetDraft = draftFor(connector.target_element_id)
375
+
376
+ sourceDraft.connected.add(layout.sourceHandle)
377
+ targetDraft.connected.add(layout.targetHandle)
378
+ sourceDraft.reconnect.push({ handleId: layout.sourceHandle, edgeId, endpoint: 'source', selected: isSelected })
379
+ targetDraft.reconnect.push({ handleId: layout.targetHandle, edgeId, endpoint: 'target', selected: isSelected })
380
+
381
+ if (isSelected) {
382
+ sourceDraft.selected.add(layout.sourceHandle)
383
+ targetDraft.selected.add(layout.targetHandle)
384
+ sourceDraft.highlighted = true
385
+ targetDraft.highlighted = true
386
+ }
387
+ }
388
+
389
+ const prev = connectionMetaCacheRef.current
390
+ const next = new Map<number, NodeConnectionMeta>()
391
+ for (const [elementId, draft] of drafts) {
392
+ const connectedHandleIds = Array.from(draft.connected).sort()
393
+ const selectedHandleIds = Array.from(draft.selected).sort()
394
+ const reconnectCandidates = draft.reconnect.sort((left, right) => {
395
+ if (left.handleId !== right.handleId) return left.handleId.localeCompare(right.handleId)
396
+ if (left.selected !== right.selected) return left.selected ? -1 : 1
397
+ return left.edgeId.localeCompare(right.edgeId)
398
+ })
399
+ const reconnectKey = reconnectCandidates
400
+ .map((candidate) => `${candidate.handleId}:${candidate.edgeId}:${candidate.endpoint}:${candidate.selected ? 1 : 0}`)
401
+ .join(',')
402
+ const key = [
403
+ connectedHandleIds.join('|'),
404
+ selectedHandleIds.join('|'),
405
+ reconnectKey,
406
+ draft.highlighted ? 1 : 0,
407
+ ].join('::')
408
+ const existing = prev.get(elementId)
409
+ next.set(elementId, existing?.key === key
410
+ ? existing
411
+ : {
412
+ key,
413
+ connectedHandleIds,
414
+ selectedHandleIds,
415
+ reconnectCandidates,
416
+ isConnectorHighlighted: draft.highlighted,
417
+ })
418
+ }
419
+ connectionMetaCacheRef.current = next
420
+ return next
421
+ }, [connectorLayouts, selectedEdgeId])
422
+
271
423
  // ── Derive RF nodes ────────────────────────────────────────────────────────
272
424
  useEffect(() => {
273
425
  setRfNodes((prevNodes) => {
274
- // The structural parent of the current view is stored under the view's
275
- // owner element ID which differs from the IDs of elements placed inside
276
- // the view. Pre-compute it so every node in the view shows the correct
277
- // zoom-out state even when their own element_id has no direct parent link.
278
- const viewParentLinks = Object.values(parentLinksMap).flat()
279
- const findInTreeById = (nodes: ViewTreeNode[], id: number): ViewTreeNode | null => {
280
- for (const node of nodes) {
281
- if (node.id === id) return node
282
- const found = findInTreeById(node.children, id)
283
- if (found) return found
284
- }
285
- return null
286
- }
287
- const currentView = findInTreeById(treeData, viewId || -1)
288
- const parentViewId = currentView?.parent_view_id
289
-
426
+
427
+ const prevNodeMap = new Map(prevNodes.map((n) => [n.id, n]))
428
+ const hiddenSet = hiddenLayerTags.length > 0 ? new Set(hiddenLayerTags) : null
429
+ const activeSet = activeTags.length > 0 ? new Set(activeTags) : null
430
+ const hoveredSet = hoveredLayerTags !== null ? new Set(hoveredLayerTags) : null
431
+ const isClickConnectMode = clickConnectMode !== null
432
+
290
433
  return viewElements.map((obj) => {
291
- const existing = prevNodes.find((n) => n.id === String(obj.element_id))
434
+ const nodeId = String(obj.element_id)
435
+ const existing = prevNodeMap.get(nodeId)
292
436
  const objTags = obj.tags || []
293
- const isHiddenByLayer = hiddenLayerTags.length > 0 && objTags.some((t) => hiddenLayerTags.includes(t))
294
- const isInactive = isHiddenByLayer || (activeTags.length > 0 && !objTags.some((t) => activeTags.includes(t)))
295
- const isLayerHighlighted = hoveredLayerTags !== null && objTags.some((t) => hoveredLayerTags.includes(t))
296
- const isSoftFocused = hoveredLayerTags !== null && !isLayerHighlighted
437
+
438
+ const isHiddenByLayer = hiddenSet !== null && objTags.some((t) => hiddenSet.has(t))
439
+ const isInactive = isHiddenByLayer || (activeSet !== null && !objTags.some((t) => activeSet.has(t)))
440
+ const isLayerHighlighted = hoveredSet !== null && objTags.some((t) => hoveredSet.has(t))
441
+ const isSoftFocused = hoveredSet !== null && !isLayerHighlighted
442
+
443
+ const newZIndex = isLayerHighlighted ? 10 : interactionSourceId === obj.element_id ? 1000 : 0
444
+ const newStyle = isInactive
445
+ ? HIDDEN_STYLE
446
+ : isSoftFocused
447
+ ? SOFT_FOCUS_STYLE
448
+ : undefined
449
+ const layerHighlightColor = isLayerHighlighted ? (hoveredLayerColor ?? undefined) : undefined
450
+ const position = existing?.dragging ? existing.position : { x: obj.position_x ?? 0, y: obj.position_y ?? 0 }
451
+ const isZoomHovered = hoveredZoomRef.current?.elementId === obj.element_id ? hoveredZoomRef.current.type : null
452
+ const links = linksMap[obj.element_id] || EMPTY_ARRAY
453
+ const parentLinks = parentLinksMap[obj.element_id] || viewParentLinks
454
+ const connectionMeta = nodeConnectionMetaByElementId.get(obj.element_id) ?? EMPTY_NODE_CONNECTION_META
455
+
456
+ // Structural sharing: if every input that would produce the same output matches the
457
+ // previous node, return the previous reference so React Flow skips this node's work.
458
+ if (
459
+ existing &&
460
+ existing.style === newStyle &&
461
+ existing.zIndex === newZIndex &&
462
+ existing.position.x === position.x &&
463
+ existing.position.y === position.y &&
464
+ existing.data &&
465
+ existing.data.element_id === obj.element_id &&
466
+ existing.data.tags === obj.tags &&
467
+ existing.data.name === obj.name &&
468
+ existing.data.position_x === obj.position_x &&
469
+ existing.data.position_y === obj.position_y &&
470
+ existing.data.description === obj.description &&
471
+ existing.data.kind === obj.kind &&
472
+ existing.data.technology === obj.technology &&
473
+ existing.data.url === obj.url &&
474
+ existing.data.logo_url === obj.logo_url &&
475
+ existing.data.repo === obj.repo &&
476
+ existing.data.branch === obj.branch &&
477
+ existing.data.file_path === obj.file_path &&
478
+ existing.data.technology_connectors === obj.technology_connectors &&
479
+ existing.data.links === links &&
480
+ existing.data.parentLinks === parentLinks &&
481
+ existing.data.parentViewId === parentViewId &&
482
+ existing.data.interactionSourceId === interactionSourceId &&
483
+ existing.data.isClickConnectMode === isClickConnectMode &&
484
+ existing.data.tagColors === tagColors &&
485
+ existing.data.layerHighlightColor === layerHighlightColor &&
486
+ existing.data.forceShowTagPopup === isLayerHighlighted &&
487
+ existing.data.isZoomHovered === isZoomHovered &&
488
+ existing.data.connectedHandleIds === connectionMeta.connectedHandleIds &&
489
+ existing.data.selectedHandleIds === connectionMeta.selectedHandleIds &&
490
+ existing.data.reconnectCandidates === connectionMeta.reconnectCandidates &&
491
+ existing.data.isConnectorHighlighted === connectionMeta.isConnectorHighlighted
492
+ ) {
493
+ return existing
494
+ }
297
495
 
298
496
  return {
299
- id: String(obj.element_id),
497
+ id: nodeId,
300
498
  type: 'elementNode',
301
- position: existing?.dragging ? existing.position : { x: obj.position_x ?? 0, y: obj.position_y ?? 0 },
499
+ position,
302
500
  width: existing?.width,
303
501
  height: existing?.height,
304
502
  selected: existing?.selected,
305
503
  dragging: existing?.dragging,
306
- zIndex: isLayerHighlighted ? 10 : interactionSourceId === obj.element_id ? 1000 : 0,
307
- style: isInactive
308
- ? { opacity: 0.1, pointerEvents: 'none' }
309
- : isSoftFocused
310
- ? { opacity: 0.2 }
311
- : undefined,
504
+ zIndex: newZIndex,
505
+ style: newStyle,
312
506
  data: {
313
507
  ...obj,
314
- links: linksMap[obj.element_id] || [],
315
- parentLinks: parentLinksMap[obj.element_id] || viewParentLinks,
508
+ links,
509
+ parentLinks,
316
510
  parentViewId,
317
511
  onZoomIn: stableOnZoomIn,
318
512
  onZoomOut: stableOnZoomOut,
@@ -324,130 +518,138 @@ export function useViewData({
324
518
  onStartHandleReconnect: stableOnStartHandleReconnect,
325
519
  onRemove: stableOnRemoveElement,
326
520
  onHoverZoom: stableOnHoverZoom,
327
- isZoomHovered: hoveredZoomRef.current?.elementId === obj.element_id ? hoveredZoomRef.current.type : null,
521
+ isZoomHovered,
328
522
  interactionSourceId,
329
- isClickConnectMode: clickConnectMode !== null,
523
+ isClickConnectMode,
330
524
  tagColors,
331
- layerHighlightColor: isLayerHighlighted ? (hoveredLayerColor ?? undefined) : undefined,
525
+ layerHighlightColor,
332
526
  forceShowTagPopup: isLayerHighlighted,
527
+ connectedHandleIds: connectionMeta.connectedHandleIds,
528
+ selectedHandleIds: connectionMeta.selectedHandleIds,
529
+ reconnectCandidates: connectionMeta.reconnectCandidates,
530
+ isConnectorHighlighted: connectionMeta.isConnectorHighlighted,
333
531
  },
334
532
  }
335
533
  })
336
534
  })
337
535
  }, [
338
- viewElements, viewId, linksMap, parentLinksMap, treeData,
536
+ viewElements, linksMap, parentLinksMap, viewParentLinks, parentViewId,
339
537
  interactionSourceId, clickConnectMode,
340
538
  stableOnZoomIn, stableOnZoomOut, stableOnNavigateToView, stableOnSelect,
341
539
  stableOnInteractionStart, stableOnConnectTo, stableOnStartHandleReconnect, stableOnRemoveElement, stableOnHoverZoom,
342
540
  stableOnOpenCodePreview, hoveredZoomRef, activeTags, hiddenLayerTags, hoveredLayerTags, hoveredLayerColor, tagColors,
541
+ nodeConnectionMetaByElementId, setRfNodes,
343
542
  ])
344
543
 
345
544
  // ── Derive RF connectors ────────────────────────────────────────────────────────
346
545
  useEffect(() => {
347
- const visibleElementIds = new Set(viewElements.map((element) => element.element_id))
348
- const filtered = connectors.filter((connector) => visibleElementIds.has(connector.source_element_id) && visibleElementIds.has(connector.target_element_id))
349
-
350
- const handleUsage: Record<string, { id: string; type: 'source' | 'target'; otherNodeCoord: number }[]> = {}
351
- filtered.forEach((c) => {
352
- const srcNode = viewElements.find((o) => o.element_id === c.source_element_id)
353
- const tgtNode = viewElements.find((o) => o.element_id === c.target_element_id)
354
- if (!srcNode || !tgtNode) return
355
-
356
- const sourceSide = getLogicalHandleId(c.source_handle, DEFAULT_SOURCE_HANDLE_SIDE)
357
- const targetSide = getLogicalHandleId(c.target_handle, DEFAULT_TARGET_HANDLE_SIDE)
358
-
359
- const srcKey = `${c.source_element_id}-${sourceSide}`
360
- handleUsage[srcKey] ??= []
361
- const srcCoord = (sourceSide === 'left' || sourceSide === 'right') ? (tgtNode.position_y ?? 0) : (tgtNode.position_x ?? 0)
362
- handleUsage[srcKey].push({ id: String(c.id), type: 'source', otherNodeCoord: srcCoord })
363
-
364
- const tgtKey = `${c.target_element_id}-${targetSide}`
365
- handleUsage[tgtKey] ??= []
366
- const tgtCoord = (targetSide === 'left' || targetSide === 'right') ? (srcNode.position_y ?? 0) : (srcNode.position_x ?? 0)
367
- handleUsage[tgtKey].push({ id: String(c.id), type: 'target', otherNodeCoord: tgtCoord })
368
- })
369
-
370
- Object.values(handleUsage).forEach((usages) => {
371
- usages.sort((a, b) => a.otherNodeCoord - b.otherNodeCoord)
372
- })
546
+ const hiddenSet = hiddenLayerTags.length > 0 ? new Set(hiddenLayerTags) : null
547
+ const activeSet = activeTags.length > 0 ? new Set(activeTags) : null
548
+ const hoveredSet = hoveredLayerTags !== null ? new Set(hoveredLayerTags) : null
373
549
 
550
+ setRfEdges((prevConnectors) => {
551
+ const prevEdgeMap = new Map(prevConnectors.map((e) => [e.id, e]))
374
552
 
375
- setRfEdges((prevConnectors) =>
376
- filtered.map((e) => {
377
- const existing = prevConnectors.find((re) => re.id === String(e.id))
553
+ return connectorLayouts.map((layout) => {
554
+ const e = layout.connector
555
+ const edgeId = String(e.id)
556
+ const existing = prevEdgeMap.get(edgeId)
378
557
  const dir = e.direction ?? 'forward'
379
- const arrowMarker = { type: MarkerType.ArrowClosed, width: 14, height: 14 }
380
558
 
381
- const sourceObj = viewElements.find((o) => o.element_id === e.source_element_id)
382
- const targetObj = viewElements.find((o) => o.element_id === e.target_element_id)
559
+ const sourceObj = elementMap.get(e.source_element_id)
560
+ const targetObj = elementMap.get(e.target_element_id)
383
561
  const srcTags = sourceObj?.tags || []
384
562
  const tgtTags = targetObj?.tags || []
385
- const isInactiveByLayer = hiddenLayerTags.length > 0 && (
386
- srcTags.some((t) => hiddenLayerTags.includes(t)) ||
387
- tgtTags.some((t) => hiddenLayerTags.includes(t))
563
+ const isInactiveByLayer = hiddenSet !== null && (
564
+ srcTags.some((t) => hiddenSet.has(t)) ||
565
+ tgtTags.some((t) => hiddenSet.has(t))
388
566
  )
389
- const isInactiveByFilter = activeTags.length > 0 && (
390
- !srcTags.some((t) => activeTags.includes(t)) ||
391
- !tgtTags.some((t) => activeTags.includes(t))
567
+ const isInactiveByFilter = activeSet !== null && (
568
+ !srcTags.some((t) => activeSet.has(t)) ||
569
+ !tgtTags.some((t) => activeSet.has(t))
392
570
  )
393
571
  const isInactive = isInactiveByLayer || isInactiveByFilter
394
- const isSoftFocused = hoveredLayerTags !== null && (
395
- !sourceObj?.tags?.some((t) => hoveredLayerTags.includes(t)) ||
396
- !targetObj?.tags?.some((t) => hoveredLayerTags.includes(t))
572
+ const isSoftFocused = hoveredSet !== null && (
573
+ !srcTags.some((t) => hoveredSet.has(t)) ||
574
+ !tgtTags.some((t) => hoveredSet.has(t))
397
575
  )
398
576
  const edgeOpacity = isInactive ? 0.1 : isSoftFocused ? 0.2 : 0.8
399
577
  const markerOpacity = isInactive ? 0.1 : isSoftFocused ? 0.2 : 1
400
- const sourceSide = getLogicalHandleId(e.source_handle, DEFAULT_SOURCE_HANDLE_SIDE) ?? DEFAULT_SOURCE_HANDLE_SIDE
401
- const targetSide = getLogicalHandleId(e.target_handle, DEFAULT_TARGET_HANDLE_SIDE) ?? DEFAULT_TARGET_HANDLE_SIDE
402
-
403
- const srcKey = `${e.source_element_id}-${sourceSide}`
404
- const tgtKey = `${e.target_element_id}-${targetSide}`
405
- const srcGroup = handleUsage[srcKey] ?? []
406
- const tgtGroup = handleUsage[tgtKey] ?? []
407
- const sourceGroupIndex = srcGroup.findIndex((u) => u.id === String(e.id) && u.type === 'source')
408
- const targetGroupIndex = tgtGroup.findIndex((u) => u.id === String(e.id) && u.type === 'target')
409
- const sourceHandleSlot = getVisualHandleSlot(sourceGroupIndex, Math.max(srcGroup.length, 1))
410
- const targetHandleSlot = getVisualHandleSlot(targetGroupIndex, Math.max(tgtGroup.length, 1))
578
+ const newZIndex = selectedEdgeId !== null && edgeId === String(selectedEdgeId) ? 1000 : 100
579
+ const pointerEvents = (isInactive || isSoftFocused) ? 'none' : 'auto'
580
+ const labelBgOpacity = isInactive ? 0.1 : isSoftFocused ? 0.2 : 0.95
581
+
582
+ // Structural sharing: when all user-visible outputs match prev exactly, reuse prev ref.
583
+ // We match on the underlying connector ref plus every computed visibility/layout value.
584
+ if (
585
+ existing &&
586
+ existing.data &&
587
+ (existing.data as Connector & { __src?: unknown }).__src === e &&
588
+ existing.sourceHandle === layout.sourceHandle &&
589
+ existing.targetHandle === layout.targetHandle &&
590
+ existing.zIndex === newZIndex &&
591
+ (existing.style as CSSProperties | undefined)?.opacity === edgeOpacity &&
592
+ (existing.style as CSSProperties | undefined)?.pointerEvents === pointerEvents &&
593
+ (existing.labelStyle as CSSProperties | undefined)?.opacity === markerOpacity &&
594
+ (existing.labelBgStyle as CSSProperties | undefined)?.fillOpacity === labelBgOpacity &&
595
+ (existing.data as { sourceGroupIndex?: number }).sourceGroupIndex === layout.sourceGroupIndex &&
596
+ (existing.data as { targetGroupIndex?: number }).targetGroupIndex === layout.targetGroupIndex &&
597
+ (existing.data as { sourceGroupCount?: number }).sourceGroupCount === layout.sourceGroupCount &&
598
+ (existing.data as { targetGroupCount?: number }).targetGroupCount === layout.targetGroupCount
599
+ ) {
600
+ return existing
601
+ }
602
+
603
+ const arrowMarker = { type: MarkerType.ArrowClosed, width: 14, height: 14 }
411
604
 
412
605
  return {
413
- id: String(e.id),
606
+ id: edgeId,
414
607
  source: String(e.source_element_id),
415
608
  target: String(e.target_element_id),
416
- sourceHandle: getVisualHandleIdForGroup(sourceSide, sourceGroupIndex, Math.max(srcGroup.length, 1)),
417
- targetHandle: getVisualHandleIdForGroup(targetSide, targetGroupIndex, Math.max(tgtGroup.length, 1)),
609
+ sourceHandle: layout.sourceHandle,
610
+ targetHandle: layout.targetHandle,
418
611
  type: e.style === 'bezier' ? 'default' : (e.style || 'default'),
419
612
  label: e.label ?? '',
420
613
  data: {
421
614
  ...e,
422
- sourceGroupIndex,
423
- sourceGroupCount: srcGroup.length,
424
- targetGroupIndex,
425
- targetGroupCount: tgtGroup.length,
426
- sourceHandleSide: sourceSide,
427
- targetHandleSide: targetSide,
428
- sourceHandleSlot,
429
- targetHandleSlot,
615
+ __src: e,
616
+ sourceGroupIndex: layout.sourceGroupIndex,
617
+ sourceGroupCount: layout.sourceGroupCount,
618
+ targetGroupIndex: layout.targetGroupIndex,
619
+ targetGroupCount: layout.targetGroupCount,
620
+ sourceHandleSide: layout.sourceHandleSide,
621
+ targetHandleSide: layout.targetHandleSide,
622
+ sourceHandleSlot: layout.sourceHandleSlot,
623
+ targetHandleSlot: layout.targetHandleSlot,
430
624
  },
431
625
 
432
- style: { stroke: 'var(--accent)', strokeWidth: 2, opacity: edgeOpacity, pointerEvents: (isInactive || isSoftFocused) ? 'none' : 'auto' },
626
+ style: { stroke: 'var(--accent)', strokeWidth: 2, opacity: edgeOpacity, pointerEvents },
433
627
  labelStyle: { fontSize: 11, fill: 'var(--accent)', opacity: markerOpacity },
434
- labelBgStyle: { fill: 'var(--chakra-colors-gray-900)', fillOpacity: isInactive ? 0.1 : isSoftFocused ? 0.2 : 0.95 },
628
+ labelBgStyle: { fill: 'var(--chakra-colors-gray-900)', fillOpacity: labelBgOpacity },
435
629
  markerEnd: (dir === 'forward' || dir === 'both') ? { ...arrowMarker, color: alphaColor('var(--accent)', markerOpacity) } : undefined,
436
630
  markerStart: (dir === 'backward' || dir === 'both') ? { ...arrowMarker, color: alphaColor('var(--accent)', markerOpacity) } : undefined,
437
631
  selected: existing?.selected,
438
- zIndex: selectedEdgeId !== null && existing?.id === String(selectedEdgeId) ? 1000 : 100,
632
+ zIndex: newZIndex,
439
633
  }
440
- }),
441
- )
442
- }, [connectors, selectedEdgeId, activeTags, hiddenLayerTags, hoveredLayerTags, viewElements])
634
+ })
635
+ })
636
+ }, [connectorLayouts, selectedEdgeId, activeTags, hiddenLayerTags, hoveredLayerTags, elementMap, setRfEdges])
443
637
 
444
638
 
445
639
  // ── Boost z-index of selected connector ────────────────────────────────────────
446
640
  useEffect(() => {
447
- setRfEdges((prev) =>
448
- prev.map((e) => ({ ...e, zIndex: selectedEdgeId !== null && e.id === String(selectedEdgeId) ? 1000 : 100 })),
449
- )
450
- }, [selectedEdgeId])
641
+ setRfEdges((prev) => {
642
+ let changed = false
643
+ const selectedId = selectedEdgeId !== null ? String(selectedEdgeId) : null
644
+ const next = prev.map((edge) => {
645
+ const nextZIndex = selectedId !== null && edge.id === selectedId ? 1000 : 100
646
+ if (edge.zIndex === nextZIndex) return edge
647
+ changed = true
648
+ return { ...edge, zIndex: nextZIndex }
649
+ })
650
+ return changed ? next : prev
651
+ })
652
+ }, [selectedEdgeId, setRfEdges])
451
653
 
452
654
  return {
453
655
  // State