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