@tldiagram/core-ui 1.87.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.
- package/dist/App.d.ts +1 -0
- package/dist/api/client.d.ts +143 -0
- package/dist/api/transport-vscode.d.ts +8 -0
- package/dist/api/transport.d.ts +1 -0
- package/dist/components/CodePreviewPanel-vscode.d.ts +7 -0
- package/dist/components/CodePreviewPanel.d.ts +9 -0
- package/dist/components/ConfirmDialog.d.ts +12 -0
- package/dist/components/ConnectorPanel.d.ts +21 -0
- package/dist/components/ContextBoundaryElement.d.ts +11 -0
- package/dist/components/ContextNeighborElement.d.ts +29 -0
- package/dist/components/ContextStraightConnector.d.ts +4 -0
- package/dist/components/CrossBranchControls.d.ts +9 -0
- package/dist/components/DependenciesOnboarding.d.ts +5 -0
- package/dist/components/DrawingCanvas.d.ts +39 -0
- package/dist/components/ElementLibrary-vscode.d.ts +7 -0
- package/dist/components/ElementLibrary.d.ts +22 -0
- package/dist/components/ElementNode.d.ts +36 -0
- package/dist/components/ElementPanel.d.ts +25 -0
- package/dist/components/ExploreOnboarding.d.ts +5 -0
- package/dist/components/ExplorePageOnboarding.d.ts +5 -0
- package/dist/components/ExportModal.d.ts +16 -0
- package/dist/components/FloatingEdge.d.ts +9 -0
- package/dist/components/GitSourceLinker.d.ts +8 -0
- package/dist/components/HeaderContext.d.ts +16 -0
- package/dist/components/Icons.d.ts +95 -0
- package/dist/components/ImportModal.d.ts +10 -0
- package/dist/components/InlineElementAdder.d.ts +17 -0
- package/dist/components/LayoutSection.d.ts +7 -0
- package/dist/components/LocalSourceLinker.d.ts +8 -0
- package/dist/components/MiniZoomOnboarding.d.ts +5 -0
- package/dist/components/NavBreadcrumb.d.ts +6 -0
- package/dist/components/NodeBody.d.ts +12 -0
- package/dist/components/NodeContainer.d.ts +8 -0
- package/dist/components/NodeHoverCard.d.ts +10 -0
- package/dist/components/PanelHeader.d.ts +8 -0
- package/dist/components/PanelUI.d.ts +3 -0
- package/dist/components/ProxyConnectorEdge.d.ts +4 -0
- package/dist/components/ProxyConnectorPanel.d.ts +9 -0
- package/dist/components/SafeBackground.d.ts +13 -0
- package/dist/components/ScrollIndicatorWrapper.d.ts +8 -0
- package/dist/components/SetChildModal.d.ts +10 -0
- package/dist/components/SetParentModal.d.ts +10 -0
- package/dist/components/SlidingPanel.d.ts +16 -0
- package/dist/components/TagUpsert.d.ts +8 -0
- package/dist/components/TopMenuBar.d.ts +8 -0
- package/dist/components/ViewBezierConnector.d.ts +4 -0
- package/dist/components/ViewDrawMenu.d.ts +22 -0
- package/dist/components/ViewEditorEdgeLabelLayout.d.ts +16 -0
- package/dist/components/ViewEditorOnboarding.d.ts +5 -0
- package/dist/components/ViewExplorer/TagManager/ColorPicker.d.ts +7 -0
- package/dist/components/ViewExplorer/TagManager/GroupNamingPopover.d.ts +10 -0
- package/dist/components/ViewExplorer/TagManager/LayerItem.d.ts +27 -0
- package/dist/components/ViewExplorer/TagManager/TagItem.d.ts +25 -0
- package/dist/components/ViewExplorer/TagManager/index.d.ts +21 -0
- package/dist/components/ViewExplorer/ViewNavigator.d.ts +11 -0
- package/dist/components/ViewExplorer/ViewSearch.d.ts +8 -0
- package/dist/components/ViewExplorer/ViewTree.d.ts +18 -0
- package/dist/components/ViewExplorer/index.d.ts +31 -0
- package/dist/components/ViewExplorer/types.d.ts +11 -0
- package/dist/components/ViewExplorer/utils.d.ts +6 -0
- package/dist/components/ViewExplorer-vscode.d.ts +6 -0
- package/dist/components/ViewFloatingMenu-vscode.d.ts +27 -0
- package/dist/components/ViewFloatingMenu.d.ts +39 -0
- package/dist/components/ViewGridNode.d.ts +29 -0
- package/dist/components/ViewHeaderButton.d.ts +11 -0
- package/dist/components/ViewPanel.d.ts +18 -0
- package/dist/components/ViewsGridOnboarding.d.ts +5 -0
- package/dist/components/ZUI/ZUICanvas.d.ts +18 -0
- package/dist/components/ZUI/index.d.ts +2 -0
- package/dist/components/ZUI/layout.d.ts +18 -0
- package/dist/components/ZUI/proxy.d.ts +25 -0
- package/dist/components/ZUI/renderer.d.ts +30 -0
- package/dist/components/ZUI/types.d.ts +140 -0
- package/dist/components/ZUI/useZUIInteraction.d.ts +21 -0
- package/dist/config/runtime-vscode.d.ts +22 -0
- package/dist/config/runtime.d.ts +5 -0
- package/dist/constants/colors.d.ts +27 -0
- package/dist/constants/diagramColors.d.ts +1 -0
- package/dist/context/ThemeContext.d.ts +27 -0
- package/dist/crossBranch/graph.d.ts +13 -0
- package/dist/crossBranch/resolve.d.ts +22 -0
- package/dist/crossBranch/settings.d.ts +6 -0
- package/dist/crossBranch/store.d.ts +11 -0
- package/dist/crossBranch/types.d.ts +96 -0
- package/dist/demo/DemoPage.d.ts +9 -0
- package/dist/demo/seed.d.ts +9 -0
- package/dist/demo/store.d.ts +137 -0
- package/dist/demo/viewEditor.d.ts +26 -0
- package/dist/favicon.svg +35 -0
- package/dist/hooks/useSafeFitView.d.ts +16 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +115 -0
- package/dist/index.js +19966 -0
- package/dist/lib/vscodeBridge-vscode.d.ts +13 -0
- package/dist/lib/vscodeBridge.d.ts +5 -0
- package/dist/logo-120.png +0 -0
- package/dist/logo-bw.png +0 -0
- package/dist/logo-bw.svg +15 -0
- package/dist/logo-text.svg +51 -0
- package/dist/logo.svg +35 -0
- package/dist/pages/AppearanceSettings.d.ts +3 -0
- package/dist/pages/Dependencies.d.ts +1 -0
- package/dist/pages/InfiniteZoom.d.ts +7 -0
- package/dist/pages/Settings.d.ts +7 -0
- package/dist/pages/ViewEditor/components/EditorMenus.d.ts +24 -0
- package/dist/pages/ViewEditor/components/EditorOverlays.d.ts +30 -0
- package/dist/pages/ViewEditor/components/EmptyCanvasState.d.ts +7 -0
- package/dist/pages/ViewEditor/context.d.ts +13 -0
- package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +201 -0
- package/dist/pages/ViewEditor/hooks/useDrawingEngine.d.ts +40 -0
- package/dist/pages/ViewEditor/hooks/useViewContextNeighbours.d.ts +20 -0
- package/dist/pages/ViewEditor/hooks/useViewData.d.ts +74 -0
- package/dist/pages/ViewEditor/index.d.ts +8 -0
- package/dist/pages/ViewEditor/utils.d.ts +14 -0
- package/dist/pages/Views.d.ts +6 -0
- package/dist/pages/ViewsGrid.d.ts +6 -0
- package/dist/pkg/importer/mermaid.d.ts +7 -0
- package/dist/pkg/importer/mermaid.test.d.ts +1 -0
- package/dist/platform/PlatformContext.d.ts +6 -0
- package/dist/platform/context.d.ts +3 -0
- package/dist/platform/local.d.ts +2 -0
- package/dist/platform/types.d.ts +17 -0
- package/dist/slots.d.ts +67 -0
- package/dist/theme.d.ts +2 -0
- package/dist/types/index.d.ts +193 -0
- package/dist/types/vscode-messages.d.ts +60 -0
- package/dist/utils/edgeDistribution.d.ts +34 -0
- package/dist/utils/githubApi.d.ts +4 -0
- package/dist/utils/githubCache.d.ts +17 -0
- package/dist/utils/ids.d.ts +2 -0
- package/dist/utils/technologyCatalog.d.ts +15 -0
- package/dist/utils/toast.d.ts +15 -0
- package/dist/utils/treesitter.d.ts +13 -0
- package/dist/utils/url.d.ts +12 -0
- package/package.json +159 -0
- package/src/App.tsx +141 -0
- package/src/api/client.ts +618 -0
- package/src/api/transport-vscode.ts +28 -0
- package/src/api/transport.ts +7 -0
- package/src/assets/logo-mark.svg +31 -0
- package/src/assets/logo-wordmark.svg +22 -0
- package/src/assets/logo.svg +35 -0
- package/src/components/CodePreviewPanel-vscode.tsx +85 -0
- package/src/components/CodePreviewPanel.tsx +384 -0
- package/src/components/ConfirmDialog.tsx +66 -0
- package/src/components/ConnectorPanel.tsx +403 -0
- package/src/components/ContextBoundaryElement.tsx +35 -0
- package/src/components/ContextNeighborElement.tsx +282 -0
- package/src/components/ContextStraightConnector.tsx +144 -0
- package/src/components/CrossBranchControls.tsx +105 -0
- package/src/components/DependenciesOnboarding.tsx +427 -0
- package/src/components/DrawingCanvas.tsx +391 -0
- package/src/components/ElementLibrary-vscode.tsx +9 -0
- package/src/components/ElementLibrary.tsx +512 -0
- package/src/components/ElementNode.tsx +1033 -0
- package/src/components/ElementPanel.tsx +928 -0
- package/src/components/ExploreOnboarding.tsx +347 -0
- package/src/components/ExplorePageOnboarding.tsx +383 -0
- package/src/components/ExportModal.tsx +132 -0
- package/src/components/FloatingEdge.tsx +115 -0
- package/src/components/GitSourceLinker.tsx +1053 -0
- package/src/components/HeaderContext.tsx +30 -0
- package/src/components/Icons.tsx +245 -0
- package/src/components/ImportModal.tsx +219 -0
- package/src/components/InlineElementAdder.tsx +216 -0
- package/src/components/LayoutSection.tsx +624 -0
- package/src/components/LocalSourceLinker.tsx +330 -0
- package/src/components/MiniZoomOnboarding.tsx +78 -0
- package/src/components/NavBreadcrumb.tsx +24 -0
- package/src/components/NodeBody.tsx +89 -0
- package/src/components/NodeContainer.tsx +58 -0
- package/src/components/NodeHoverCard.tsx +135 -0
- package/src/components/PanelHeader.tsx +36 -0
- package/src/components/PanelUI.tsx +24 -0
- package/src/components/ProxyConnectorEdge.tsx +169 -0
- package/src/components/ProxyConnectorPanel.tsx +130 -0
- package/src/components/SafeBackground.tsx +19 -0
- package/src/components/ScrollIndicatorWrapper.tsx +117 -0
- package/src/components/SetChildModal.tsx +191 -0
- package/src/components/SetParentModal.tsx +187 -0
- package/src/components/SlidingPanel.tsx +114 -0
- package/src/components/TagUpsert.tsx +142 -0
- package/src/components/TopMenuBar.tsx +380 -0
- package/src/components/ViewBezierConnector.tsx +143 -0
- package/src/components/ViewDrawMenu.tsx +270 -0
- package/src/components/ViewEditorEdgeLabelLayout.ts +189 -0
- package/src/components/ViewEditorOnboarding.tsx +445 -0
- package/src/components/ViewExplorer/TagManager/ColorPicker.tsx +49 -0
- package/src/components/ViewExplorer/TagManager/GroupNamingPopover.tsx +96 -0
- package/src/components/ViewExplorer/TagManager/LayerItem.tsx +228 -0
- package/src/components/ViewExplorer/TagManager/TagItem.tsx +242 -0
- package/src/components/ViewExplorer/TagManager/index.tsx +418 -0
- package/src/components/ViewExplorer/ViewNavigator.tsx +121 -0
- package/src/components/ViewExplorer/ViewSearch.tsx +33 -0
- package/src/components/ViewExplorer/ViewTree.tsx +98 -0
- package/src/components/ViewExplorer/index.tsx +384 -0
- package/src/components/ViewExplorer/types.ts +13 -0
- package/src/components/ViewExplorer/utils.ts +56 -0
- package/src/components/ViewExplorer-vscode.tsx +8 -0
- package/src/components/ViewFloatingMenu-vscode.tsx +248 -0
- package/src/components/ViewFloatingMenu.tsx +379 -0
- package/src/components/ViewGridNode.tsx +451 -0
- package/src/components/ViewHeaderButton.tsx +60 -0
- package/src/components/ViewPanel.tsx +162 -0
- package/src/components/ViewsGridOnboarding.tsx +400 -0
- package/src/components/ZUI/ZUICanvas.tsx +853 -0
- package/src/components/ZUI/index.ts +3 -0
- package/src/components/ZUI/layout.ts +323 -0
- package/src/components/ZUI/proxy.ts +278 -0
- package/src/components/ZUI/renderer.ts +1189 -0
- package/src/components/ZUI/types.ts +150 -0
- package/src/components/ZUI/useZUIInteraction.ts +720 -0
- package/src/config/runtime-vscode.ts +46 -0
- package/src/config/runtime.ts +30 -0
- package/src/constants/colors.ts +80 -0
- package/src/constants/diagramColors.ts +9 -0
- package/src/context/ThemeContext.tsx +158 -0
- package/src/crossBranch/graph.ts +207 -0
- package/src/crossBranch/resolve.ts +643 -0
- package/src/crossBranch/settings.ts +59 -0
- package/src/crossBranch/store.ts +71 -0
- package/src/crossBranch/types.ts +102 -0
- package/src/demo/DemoPage.tsx +184 -0
- package/src/demo/seed.ts +67 -0
- package/src/demo/store.ts +536 -0
- package/src/demo/viewEditor.ts +110 -0
- package/src/hooks/useSafeFitView.ts +60 -0
- package/src/index.css +309 -0
- package/src/index.ts +184 -0
- package/src/kafka-ss.png +0 -0
- package/src/lib/vscodeBridge-vscode.ts +27 -0
- package/src/lib/vscodeBridge.ts +7 -0
- package/src/main.tsx +46 -0
- package/src/pages/AppearanceSettings.tsx +135 -0
- package/src/pages/Dependencies.tsx +926 -0
- package/src/pages/InfiniteZoom.tsx +404 -0
- package/src/pages/Settings.tsx +91 -0
- package/src/pages/ViewEditor/EDGE_DISTRIBUTION.md +64 -0
- package/src/pages/ViewEditor/components/EditorMenus.tsx +112 -0
- package/src/pages/ViewEditor/components/EditorOverlays.tsx +172 -0
- package/src/pages/ViewEditor/components/EmptyCanvasState.tsx +42 -0
- package/src/pages/ViewEditor/context.tsx +21 -0
- package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +1349 -0
- package/src/pages/ViewEditor/hooks/useDrawingEngine.ts +127 -0
- package/src/pages/ViewEditor/hooks/useViewContextNeighbours.ts +501 -0
- package/src/pages/ViewEditor/hooks/useViewData.ts +491 -0
- package/src/pages/ViewEditor/index.tsx +1366 -0
- package/src/pages/ViewEditor/utils.ts +88 -0
- package/src/pages/Views.tsx +171 -0
- package/src/pages/ViewsGrid.tsx +1310 -0
- package/src/pkg/importer/mermaid.test.ts +141 -0
- package/src/pkg/importer/mermaid.ts +76 -0
- package/src/platform/PlatformContext.tsx +17 -0
- package/src/platform/context.ts +9 -0
- package/src/platform/local.tsx +15 -0
- package/src/platform/types.ts +19 -0
- package/src/slots.ts +92 -0
- package/src/styles/editor-panels.css +66 -0
- package/src/styles/theme.css +56 -0
- package/src/theme.ts +336 -0
- package/src/types/index.ts +234 -0
- package/src/types/offline-ambient.d.ts +14 -0
- package/src/types/vscode-messages.ts +32 -0
- package/src/utils/edgeDistribution.ts +103 -0
- package/src/utils/githubApi.ts +121 -0
- package/src/utils/githubCache.ts +108 -0
- package/src/utils/ids.ts +9 -0
- package/src/utils/technologyCatalog.ts +143 -0
- package/src/utils/toast.ts +100 -0
- package/src/utils/treesitter.ts +147 -0
- package/src/utils/url.ts +72 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
// src/components/ZUI/useZUIInteraction.ts
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState, useMemo } from 'react'
|
|
4
|
+
import type { BBox, DiagramGroupLayout, LayoutNode, ZUIViewState, HoveredItem } from './types'
|
|
5
|
+
import { getExpandThresholds } from './renderer'
|
|
6
|
+
|
|
7
|
+
function constrainViewState(view: ZUIViewState, canvasW: number, canvasH: number, bbox: BBox): ZUIViewState {
|
|
8
|
+
const padding = 600 // pixels
|
|
9
|
+
const minX = padding - bbox.maxX * view.zoom
|
|
10
|
+
const maxX = canvasW - padding - bbox.minX * view.zoom
|
|
11
|
+
const minY = padding - bbox.maxY * view.zoom
|
|
12
|
+
const maxY = canvasH - padding - bbox.minY * view.zoom
|
|
13
|
+
|
|
14
|
+
let { x, y } = view
|
|
15
|
+
if (maxX >= minX) x = Math.max(minX, Math.min(maxX, x))
|
|
16
|
+
else x = (minX + maxX) / 2
|
|
17
|
+
|
|
18
|
+
if (maxY >= minY) y = Math.max(minY, Math.min(maxY, y))
|
|
19
|
+
else y = (minY + maxY) / 2
|
|
20
|
+
|
|
21
|
+
return { ...view, x, y }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface DeepestNodeResult {
|
|
25
|
+
node: LayoutNode
|
|
26
|
+
absX: number
|
|
27
|
+
absY: number
|
|
28
|
+
absW: number
|
|
29
|
+
absH: number
|
|
30
|
+
cumulativeScale: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function findDeepestAt(worldX: number, worldY: number, groups: DiagramGroupLayout[], view: ZUIViewState, thresholds: { start: number, end: number }): DeepestNodeResult | null {
|
|
34
|
+
for (const group of groups) {
|
|
35
|
+
if (worldX >= group.worldX && worldX <= group.worldX + group.worldW &&
|
|
36
|
+
worldY >= group.worldY && worldY <= group.worldY + group.worldH) {
|
|
37
|
+
// Root nodes in the group have absolute world coordinates already
|
|
38
|
+
return findDeepestInNodes(worldX, worldY, group.nodes, 0, 0, 1, 0, 0, view, thresholds)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function findDeepestInNodes(
|
|
45
|
+
worldX: number,
|
|
46
|
+
worldY: number,
|
|
47
|
+
nodes: LayoutNode[],
|
|
48
|
+
parentAbsX: number,
|
|
49
|
+
parentAbsY: number,
|
|
50
|
+
parentAbsScale: number,
|
|
51
|
+
parentChildOffsetX: number,
|
|
52
|
+
parentChildOffsetY: number,
|
|
53
|
+
view: ZUIViewState,
|
|
54
|
+
thresholds: { start: number, end: number }
|
|
55
|
+
): DeepestNodeResult | null {
|
|
56
|
+
for (const node of nodes) {
|
|
57
|
+
if (worldX >= node.worldX && worldX <= node.worldX + node.worldW &&
|
|
58
|
+
worldY >= node.worldY && worldY <= node.worldY + node.worldH) {
|
|
59
|
+
|
|
60
|
+
// Screen width of this node at current zoom level
|
|
61
|
+
const worldW = node.worldW * parentAbsScale
|
|
62
|
+
const screenW = worldW * view.zoom
|
|
63
|
+
|
|
64
|
+
// Visibility check: if node is too small to be drawn, skip it
|
|
65
|
+
if (screenW < 2) continue
|
|
66
|
+
|
|
67
|
+
// Absolute world position of THIS node
|
|
68
|
+
const absX = parentAbsX + (node.worldX - parentChildOffsetX) * parentAbsScale
|
|
69
|
+
const absY = parentAbsY + (node.worldY - parentChildOffsetY) * parentAbsScale
|
|
70
|
+
const absW = worldW
|
|
71
|
+
const absH = node.worldH * parentAbsScale
|
|
72
|
+
|
|
73
|
+
const childX = (worldX - node.worldX) / node.childScale + node.childOffsetX
|
|
74
|
+
const childY = (worldY - node.worldY) / node.childScale + node.childOffsetY
|
|
75
|
+
|
|
76
|
+
const hasChildren = node.children && node.children.length > 0
|
|
77
|
+
const t = hasChildren ? Math.max(0, Math.min(1, (screenW - thresholds.start) / (thresholds.end - thresholds.start))) : 0
|
|
78
|
+
|
|
79
|
+
// If children are significantly visible, descend
|
|
80
|
+
if (t > 0.05) {
|
|
81
|
+
const deeper = findDeepestInNodes(
|
|
82
|
+
childX,
|
|
83
|
+
childY,
|
|
84
|
+
node.children,
|
|
85
|
+
absX,
|
|
86
|
+
absY,
|
|
87
|
+
parentAbsScale * node.childScale,
|
|
88
|
+
node.childOffsetX,
|
|
89
|
+
node.childOffsetY,
|
|
90
|
+
view,
|
|
91
|
+
thresholds
|
|
92
|
+
)
|
|
93
|
+
if (deeper) return deeper
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// If the node has fully transitioned to its children, the parent itself is no longer hoverable
|
|
97
|
+
if (t > 0.95) return null
|
|
98
|
+
|
|
99
|
+
return { node, absX, absY, absW, absH, cumulativeScale: parentAbsScale }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function findHoveredGroup(worldX: number, worldY: number, groups: DiagramGroupLayout[], view: ZUIViewState): DiagramGroupLayout | null {
|
|
106
|
+
for (const group of groups) {
|
|
107
|
+
// Check if mouse is near the diagram label (placed above the main diagram box)
|
|
108
|
+
const labelCenterX = group.worldX + group.diagramX + group.diagramW / 2
|
|
109
|
+
const labelTop = group.worldY + group.diagramY - 50 / view.zoom
|
|
110
|
+
const labelBot = group.worldY + group.diagramY
|
|
111
|
+
|
|
112
|
+
// Estimated width for the label hit-target
|
|
113
|
+
const labelHalfW = 100 / view.zoom
|
|
114
|
+
|
|
115
|
+
if (worldX >= labelCenterX - labelHalfW && worldX <= labelCenterX + labelHalfW &&
|
|
116
|
+
worldY >= labelTop && worldY <= labelBot) {
|
|
117
|
+
return group
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function findHoveredEdge(
|
|
124
|
+
worldX: number,
|
|
125
|
+
worldY: number,
|
|
126
|
+
groups: DiagramGroupLayout[],
|
|
127
|
+
view: ZUIViewState
|
|
128
|
+
): HoveredItem | null {
|
|
129
|
+
const threshold = 18 / view.zoom // 18 screen pixels converted to world distance
|
|
130
|
+
|
|
131
|
+
for (const group of groups) {
|
|
132
|
+
const nodeMap = new Map<string, LayoutNode>()
|
|
133
|
+
for (const node of group.nodes) {
|
|
134
|
+
nodeMap.set(node.id, node)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const edge of group.edges) {
|
|
138
|
+
const source = nodeMap.get(edge.sourceId)
|
|
139
|
+
const target = nodeMap.get(edge.targetId)
|
|
140
|
+
if (!source || !target) continue
|
|
141
|
+
|
|
142
|
+
// Node centers
|
|
143
|
+
const x1 = source.worldX + source.worldW / 2
|
|
144
|
+
const y1 = source.worldY + source.worldH / 2
|
|
145
|
+
const x2 = target.worldX + target.worldW / 2
|
|
146
|
+
const y2 = target.worldY + target.worldH / 2
|
|
147
|
+
|
|
148
|
+
// Midpoint for popover placement
|
|
149
|
+
const midX = (x1 + x2) / 2
|
|
150
|
+
const midY = (y1 + y2) / 2
|
|
151
|
+
|
|
152
|
+
// Distance from point to line segment
|
|
153
|
+
const dx = x2 - x1
|
|
154
|
+
const dy = y2 - y1
|
|
155
|
+
const l2 = dx * dx + dy * dy
|
|
156
|
+
if (l2 === 0) continue
|
|
157
|
+
|
|
158
|
+
let t = ((worldX - x1) * dx + (worldY - y1) * dy) / l2
|
|
159
|
+
t = Math.max(0, Math.min(1, t))
|
|
160
|
+
|
|
161
|
+
const nearestX = x1 + t * dx
|
|
162
|
+
const nearestY = y1 + t * dy
|
|
163
|
+
const dist = Math.sqrt((worldX - nearestX) ** 2 + (worldY - nearestY) ** 2)
|
|
164
|
+
|
|
165
|
+
if (dist < threshold) {
|
|
166
|
+
return {
|
|
167
|
+
type: 'edge',
|
|
168
|
+
data: {
|
|
169
|
+
sourceId: source.label,
|
|
170
|
+
targetId: target.label,
|
|
171
|
+
label: edge.label || 'Connection',
|
|
172
|
+
diagramId: group.diagramId,
|
|
173
|
+
sourceObjId: source.elementId,
|
|
174
|
+
targetObjId: target.elementId
|
|
175
|
+
},
|
|
176
|
+
absX: midX,
|
|
177
|
+
absY: midY
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Squiggly lines to portal nodes ──
|
|
183
|
+
for (const node of group.nodes) {
|
|
184
|
+
if (node.isPortal) {
|
|
185
|
+
// Line from diagram bottom center to portal top center
|
|
186
|
+
const x1 = group.worldX + group.diagramX + group.diagramW / 2
|
|
187
|
+
const y1 = group.worldY + group.diagramY + group.diagramH
|
|
188
|
+
const x2 = node.worldX + node.worldW / 2
|
|
189
|
+
const y2 = node.worldY
|
|
190
|
+
|
|
191
|
+
const midX = (x1 + x2) / 2
|
|
192
|
+
const midY = (y1 + y2) / 2
|
|
193
|
+
|
|
194
|
+
const dx = x2 - x1
|
|
195
|
+
const dy = y2 - y1
|
|
196
|
+
const l2 = dx * dx + dy * dy
|
|
197
|
+
if (l2 === 0) continue
|
|
198
|
+
|
|
199
|
+
let t = ((worldX - x1) * dx + (worldY - y1) * dy) / l2
|
|
200
|
+
t = Math.max(0, Math.min(1, t))
|
|
201
|
+
|
|
202
|
+
const nearestX = x1 + t * dx
|
|
203
|
+
const nearestY = y1 + t * dy
|
|
204
|
+
const dist = Math.sqrt((worldX - nearestX) ** 2 + (worldY - nearestY) ** 2)
|
|
205
|
+
|
|
206
|
+
if (dist < threshold) {
|
|
207
|
+
return {
|
|
208
|
+
type: 'edge',
|
|
209
|
+
data: {
|
|
210
|
+
sourceId: group.label,
|
|
211
|
+
targetId: node.label,
|
|
212
|
+
label: '',
|
|
213
|
+
diagramId: group.diagramId,
|
|
214
|
+
targetDiagId: node.linkedDiagramId,
|
|
215
|
+
isPortalConn: true
|
|
216
|
+
},
|
|
217
|
+
absX: midX,
|
|
218
|
+
absY: midY
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return null
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function calculateMaxZoom(groups: DiagramGroupLayout[], canvasW: number): number {
|
|
228
|
+
if (canvasW <= 0) return 40
|
|
229
|
+
const thresholds = getExpandThresholds(canvasW)
|
|
230
|
+
let maxPossibleZoom = 40
|
|
231
|
+
|
|
232
|
+
function visitNodes(nodes: LayoutNode[], cumulativeScale: number) {
|
|
233
|
+
for (const node of nodes) {
|
|
234
|
+
if (!node.children || node.children.length === 0) {
|
|
235
|
+
// This is a leaf node. We want it to be able to fill 'thresholds.end' of the canvas.
|
|
236
|
+
const neededZoom = thresholds.end / (node.worldW * cumulativeScale)
|
|
237
|
+
if (neededZoom > maxPossibleZoom) {
|
|
238
|
+
maxPossibleZoom = neededZoom
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
visitNodes(node.children, cumulativeScale * node.childScale)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const group of groups) {
|
|
247
|
+
visitNodes(group.nodes, 1)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return maxPossibleZoom
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const MIN_ZOOM = 0.4
|
|
254
|
+
|
|
255
|
+
function clampZoom(z: number, prevZ: number, maxZ: number): number {
|
|
256
|
+
if (z > prevZ) {
|
|
257
|
+
// Zooming IN: cap at maxZ (but don't force down if already above)
|
|
258
|
+
return Math.min(z, Math.max(prevZ, maxZ))
|
|
259
|
+
} else {
|
|
260
|
+
// Zooming OUT: ignore maxZ (only cap at global MIN_ZOOM)
|
|
261
|
+
return Math.max(z, MIN_ZOOM)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Zoom toward/away from a screen-space focal point. */
|
|
266
|
+
function zoomAround(
|
|
267
|
+
view: ZUIViewState,
|
|
268
|
+
focalX: number,
|
|
269
|
+
focalY: number,
|
|
270
|
+
factor: number,
|
|
271
|
+
maxZoom: number,
|
|
272
|
+
): ZUIViewState {
|
|
273
|
+
const newZoom = clampZoom(view.zoom * factor, view.zoom, maxZoom)
|
|
274
|
+
const scale = newZoom / view.zoom
|
|
275
|
+
return {
|
|
276
|
+
zoom: newZoom,
|
|
277
|
+
x: focalX - (focalX - view.x) * scale,
|
|
278
|
+
y: focalY - (focalY - view.y) * scale,
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export interface ZUIInteraction {
|
|
283
|
+
viewState: ZUIViewState
|
|
284
|
+
/** Ref that is updated synchronously on every input event use this in RAF loops to avoid waiting for React renders. */
|
|
285
|
+
viewStateRef: React.MutableRefObject<ZUIViewState>
|
|
286
|
+
setViewState: React.Dispatch<React.SetStateAction<ZUIViewState>>
|
|
287
|
+
/** Call with the canvas DOMRect + layout bbox to fit all content. */
|
|
288
|
+
fitView: (
|
|
289
|
+
canvasW: number,
|
|
290
|
+
canvasH: number,
|
|
291
|
+
bbox: { minX: number; minY: number; maxX: number; maxY: number },
|
|
292
|
+
padding?: number,
|
|
293
|
+
) => void
|
|
294
|
+
maxZoom: number
|
|
295
|
+
hoveredItem: HoveredItem | null
|
|
296
|
+
setHoveredItem: (item: HoveredItem | null, force?: boolean) => void
|
|
297
|
+
/** Set to true to prevent clearing hoveredItem (e.g. when mouse is over a popover). */
|
|
298
|
+
setHoverLocked: (locked: boolean) => void
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function useZUIInteraction(
|
|
302
|
+
canvasRef: React.RefObject<HTMLCanvasElement | null>,
|
|
303
|
+
initialView: ZUIViewState = { x: 0, y: 0, zoom: 0.3 },
|
|
304
|
+
groups: DiagramGroupLayout[] = [],
|
|
305
|
+
bbox?: BBox,
|
|
306
|
+
onZoom?: () => void,
|
|
307
|
+
onPan?: () => void,
|
|
308
|
+
isMobile: boolean = false,
|
|
309
|
+
resolveHoveredProxyItem?: (worldX: number, worldY: number, view: ZUIViewState, canvasW: number) => HoveredItem | null,
|
|
310
|
+
): ZUIInteraction {
|
|
311
|
+
const [viewState, setViewStateInternal] = useState<ZUIViewState>(initialView)
|
|
312
|
+
const [hoveredItem, setHoveredItemInternal] = useState<HoveredItem | null>(null)
|
|
313
|
+
const hoverLockedRef = useRef(false)
|
|
314
|
+
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
315
|
+
|
|
316
|
+
const setHoveredItem = useCallback((item: HoveredItem | null, force = false) => {
|
|
317
|
+
if (hoverTimeoutRef.current) {
|
|
318
|
+
clearTimeout(hoverTimeoutRef.current)
|
|
319
|
+
hoverTimeoutRef.current = null
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (item === null) {
|
|
323
|
+
if (force) {
|
|
324
|
+
setHoveredItemInternal(null)
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
// Grace period before clearing hover to allow mouse to reach popover
|
|
328
|
+
hoverTimeoutRef.current = setTimeout(() => {
|
|
329
|
+
if (!hoverLockedRef.current) {
|
|
330
|
+
setHoveredItemInternal(null)
|
|
331
|
+
}
|
|
332
|
+
}, 100)
|
|
333
|
+
} else {
|
|
334
|
+
setHoveredItemInternal(item)
|
|
335
|
+
}
|
|
336
|
+
}, [])
|
|
337
|
+
|
|
338
|
+
const setHoverLocked = useCallback((locked: boolean) => {
|
|
339
|
+
hoverLockedRef.current = locked
|
|
340
|
+
if (locked && hoverTimeoutRef.current) {
|
|
341
|
+
clearTimeout(hoverTimeoutRef.current)
|
|
342
|
+
hoverTimeoutRef.current = null
|
|
343
|
+
}
|
|
344
|
+
if (!locked) {
|
|
345
|
+
// If we unlock and there is no item currently being "detected" by mouse,
|
|
346
|
+
// it should ideally clear soon. The next mouse move will handle it.
|
|
347
|
+
}
|
|
348
|
+
}, [])
|
|
349
|
+
|
|
350
|
+
// ── Refs for stable event handlers ──────────────────────────────
|
|
351
|
+
const viewStateRef = useRef<ZUIViewState>(initialView)
|
|
352
|
+
const groupsRef = useRef<DiagramGroupLayout[]>(groups)
|
|
353
|
+
const bboxRef = useRef<BBox | undefined>(bbox)
|
|
354
|
+
const onZoomRef = useRef(onZoom)
|
|
355
|
+
const onPanRef = useRef(onPan)
|
|
356
|
+
|
|
357
|
+
useEffect(() => {
|
|
358
|
+
groupsRef.current = groups
|
|
359
|
+
bboxRef.current = bbox
|
|
360
|
+
onZoomRef.current = onZoom
|
|
361
|
+
onPanRef.current = onPan
|
|
362
|
+
}, [groups, bbox, onZoom, onPan])
|
|
363
|
+
|
|
364
|
+
const [lastCanvasW, setLastCanvasW] = useState(0)
|
|
365
|
+
|
|
366
|
+
const dynamicMaxZoom = useMemo(() => {
|
|
367
|
+
return calculateMaxZoom(groups, lastCanvasW || 1200) // Fallback width for initial calc
|
|
368
|
+
}, [groups, lastCanvasW])
|
|
369
|
+
|
|
370
|
+
const maxZoomRef = useRef(40)
|
|
371
|
+
useEffect(() => {
|
|
372
|
+
maxZoomRef.current = dynamicMaxZoom
|
|
373
|
+
}, [dynamicMaxZoom])
|
|
374
|
+
|
|
375
|
+
const setViewState = useCallback(
|
|
376
|
+
(update: React.SetStateAction<ZUIViewState>) => {
|
|
377
|
+
setViewStateInternal((prev) => {
|
|
378
|
+
const next = typeof update === 'function' ? (update as (p: ZUIViewState) => ZUIViewState)(prev) : update
|
|
379
|
+
const box = bboxRef.current
|
|
380
|
+
if (!box || !canvasRef.current) {
|
|
381
|
+
viewStateRef.current = next
|
|
382
|
+
return next
|
|
383
|
+
}
|
|
384
|
+
const el = canvasRef.current
|
|
385
|
+
const w = el.clientWidth || el.width / (window.devicePixelRatio || 1)
|
|
386
|
+
const h = el.clientHeight || el.height / (window.devicePixelRatio || 1)
|
|
387
|
+
|
|
388
|
+
if (w !== lastCanvasW && w > 0) {
|
|
389
|
+
setLastCanvasW(w)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (w === 0 || h === 0) {
|
|
393
|
+
viewStateRef.current = next
|
|
394
|
+
return next
|
|
395
|
+
}
|
|
396
|
+
const constrained = constrainViewState(next, w, h, box)
|
|
397
|
+
viewStateRef.current = constrained
|
|
398
|
+
return constrained
|
|
399
|
+
})
|
|
400
|
+
},
|
|
401
|
+
[canvasRef, lastCanvasW],
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
const dragging = useRef(false)
|
|
405
|
+
const lastMouse = useRef({ x: 0, y: 0 })
|
|
406
|
+
const lastPinchDist = useRef<number | null>(null)
|
|
407
|
+
const lastPinchMid = useRef({ x: 0, y: 0 })
|
|
408
|
+
|
|
409
|
+
const fitView = useCallback(
|
|
410
|
+
(
|
|
411
|
+
canvasW: number,
|
|
412
|
+
canvasH: number,
|
|
413
|
+
bbox: { minX: number; minY: number; maxX: number; maxY: number },
|
|
414
|
+
padding = 0.1,
|
|
415
|
+
) => {
|
|
416
|
+
if (canvasW !== lastCanvasW && canvasW > 0) {
|
|
417
|
+
setLastCanvasW(canvasW)
|
|
418
|
+
}
|
|
419
|
+
const bboxW = bbox.maxX - bbox.minX
|
|
420
|
+
const bboxH = bbox.maxY - bbox.minY
|
|
421
|
+
if (bboxW <= 0 || bboxH <= 0) return
|
|
422
|
+
|
|
423
|
+
const currentMaxZ = calculateMaxZoom(groupsRef.current, canvasW)
|
|
424
|
+
const zoom = Math.max(MIN_ZOOM, Math.min(currentMaxZ,
|
|
425
|
+
Math.min(
|
|
426
|
+
(canvasW * (1 - padding * 2)) / bboxW,
|
|
427
|
+
(canvasH * (1 - padding * 2)) / bboxH,
|
|
428
|
+
),
|
|
429
|
+
))
|
|
430
|
+
const x = (canvasW - bboxW * zoom) / 2 - bbox.minX * zoom
|
|
431
|
+
const y = (canvasH - bboxH * zoom) / 2 - bbox.minY * zoom
|
|
432
|
+
setViewState({ x, y, zoom })
|
|
433
|
+
},
|
|
434
|
+
[setViewState, lastCanvasW],
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
const lastPanTimeRef = useRef(0)
|
|
438
|
+
|
|
439
|
+
useEffect(() => {
|
|
440
|
+
const el = canvasRef.current
|
|
441
|
+
if (!el) return
|
|
442
|
+
|
|
443
|
+
function onWheel(e: WheelEvent) {
|
|
444
|
+
// Heuristic to distinguish between trackpad and physical mouse wheel:
|
|
445
|
+
// 1. If ctrlKey is true, it's a pinch (trackpad) or Ctrl+Wheel. We always zoom.
|
|
446
|
+
// 2. If deltaMode !== 0, it's a physical mouse wheel (DOM_DELTA_LINE/PAGE). We zoom.
|
|
447
|
+
// 3. Only zoom on notched mouse wheel, not trackpad pan gestures.
|
|
448
|
+
const isPinch = e.ctrlKey
|
|
449
|
+
|
|
450
|
+
// We don't have isRecentMultiTouch yet, but we can check if it looks like a mouse wheel
|
|
451
|
+
const isMouseWheel = e.deltaMode !== 0 || (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 20)
|
|
452
|
+
|
|
453
|
+
// On mobile, Safari synthesizes wheel events for pinches.
|
|
454
|
+
// If it's not a pinch or a real mouse wheel, we ignore it to allow native gestures or prevent conflicts.
|
|
455
|
+
if (isMobile && !isPinch && !isMouseWheel) return
|
|
456
|
+
|
|
457
|
+
e.preventDefault()
|
|
458
|
+
setHoveredItem(null, true) // Clear popover immediately on zoom/pan
|
|
459
|
+
|
|
460
|
+
// Track multi-touch wheel events (deltaX !== 0 indicates two-finger contact on trackpad)
|
|
461
|
+
if (e.deltaX !== 0) {
|
|
462
|
+
lastPanTimeRef.current = Date.now()
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// If we just finished a multi-touch gesture, suppress zoom for ~1000ms (trackpad momentum can last longer)
|
|
466
|
+
const isRecentMultiTouch = Date.now() - lastPanTimeRef.current < 1000
|
|
467
|
+
|
|
468
|
+
// Re-evaluate isMouseWheel with trackpad suppression for desktop
|
|
469
|
+
const isNotchedWheel = !isRecentMultiTouch && e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 20
|
|
470
|
+
const isRealMouseWheel = e.deltaMode !== 0 || isNotchedWheel
|
|
471
|
+
|
|
472
|
+
if (isPinch || isRealMouseWheel) {
|
|
473
|
+
const rect = el!.getBoundingClientRect()
|
|
474
|
+
const focalX = e.clientX - rect.left
|
|
475
|
+
const focalY = e.clientY - rect.top
|
|
476
|
+
|
|
477
|
+
// Use standard factors for zoom
|
|
478
|
+
let factor = 1 - e.deltaY * (isRealMouseWheel ? 0.002 : 0.01)
|
|
479
|
+
factor = Math.max(0.85, Math.min(1.15, factor))
|
|
480
|
+
|
|
481
|
+
setViewState((prev) => {
|
|
482
|
+
const worldX = (focalX - prev.x) / prev.zoom
|
|
483
|
+
const worldY = (focalY - prev.y) / prev.zoom
|
|
484
|
+
const thresholds = getExpandThresholds(rect.width)
|
|
485
|
+
const deepest = findDeepestAt(worldX, worldY, groupsRef.current, prev, thresholds)
|
|
486
|
+
|
|
487
|
+
let currentMaxZ = maxZoomRef.current
|
|
488
|
+
|
|
489
|
+
if (deepest && (!deepest.node.children || deepest.node.children.length === 0)) {
|
|
490
|
+
currentMaxZ = thresholds.end / (deepest.node.worldW * deepest.cumulativeScale)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return zoomAround(prev, focalX, focalY, factor, currentMaxZ)
|
|
494
|
+
})
|
|
495
|
+
onZoomRef.current?.()
|
|
496
|
+
} else if (!isMobile) {
|
|
497
|
+
// Trackpad panning - disabled on mobile to avoid interference with pinch-to-zoom
|
|
498
|
+
setViewState((prev) => ({ ...prev, x: prev.x - e.deltaX, y: prev.y - e.deltaY }))
|
|
499
|
+
onPanRef.current?.()
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function onMouseDown(e: MouseEvent) {
|
|
504
|
+
if (e.button !== 0) return
|
|
505
|
+
dragging.current = true
|
|
506
|
+
lastMouse.current = { x: e.clientX, y: e.clientY }
|
|
507
|
+
el!.style.cursor = 'grabbing'
|
|
508
|
+
setHoveredItem(null, true) // Hide popover immediately while dragging
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function onMouseMove(e: MouseEvent) {
|
|
512
|
+
if (hoverLockedRef.current) return
|
|
513
|
+
|
|
514
|
+
const rect = el!.getBoundingClientRect()
|
|
515
|
+
const screenX = e.clientX - rect.left
|
|
516
|
+
const screenY = e.clientY - rect.top
|
|
517
|
+
|
|
518
|
+
if (dragging.current) {
|
|
519
|
+
const dx = e.clientX - lastMouse.current.x
|
|
520
|
+
const dy = e.clientY - lastMouse.current.y
|
|
521
|
+
lastMouse.current = { x: e.clientX, y: e.clientY }
|
|
522
|
+
setViewState((prev) => ({ ...prev, x: prev.x + dx, y: prev.y + dy }))
|
|
523
|
+
onPanRef.current?.()
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Hover detection
|
|
528
|
+
const view = viewStateRef.current
|
|
529
|
+
const worldX = (screenX - view.x) / view.zoom
|
|
530
|
+
const worldY = (screenY - view.y) / view.zoom
|
|
531
|
+
const thresholds = getExpandThresholds(rect.width)
|
|
532
|
+
|
|
533
|
+
const deepest = findDeepestAt(worldX, worldY, groupsRef.current, view, thresholds)
|
|
534
|
+
if (deepest) {
|
|
535
|
+
const { node, absX, absY, absW, absH } = deepest
|
|
536
|
+
setHoveredItem({
|
|
537
|
+
type: 'node',
|
|
538
|
+
data: node,
|
|
539
|
+
absX,
|
|
540
|
+
absY,
|
|
541
|
+
absW,
|
|
542
|
+
absH
|
|
543
|
+
})
|
|
544
|
+
} else {
|
|
545
|
+
const proxyEdge = resolveHoveredProxyItem?.(worldX, worldY, view, rect.width) ?? null
|
|
546
|
+
if (proxyEdge) {
|
|
547
|
+
setHoveredItem(proxyEdge)
|
|
548
|
+
return
|
|
549
|
+
}
|
|
550
|
+
const edge = findHoveredEdge(worldX, worldY, groupsRef.current, view)
|
|
551
|
+
if (edge) {
|
|
552
|
+
setHoveredItem(edge)
|
|
553
|
+
} else {
|
|
554
|
+
const group = findHoveredGroup(worldX, worldY, groupsRef.current, view)
|
|
555
|
+
if (group) {
|
|
556
|
+
setHoveredItem({
|
|
557
|
+
type: 'group',
|
|
558
|
+
data: group
|
|
559
|
+
})
|
|
560
|
+
} else {
|
|
561
|
+
setHoveredItem(null)
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function onMouseUp() {
|
|
568
|
+
dragging.current = false
|
|
569
|
+
if (el) el.style.cursor = 'grab'
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function onMouseOut() {
|
|
573
|
+
setHoveredItem(null)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function onDblClick(e: MouseEvent) {
|
|
577
|
+
const rect = el!.getBoundingClientRect()
|
|
578
|
+
const focalX = e.clientX - rect.left
|
|
579
|
+
const focalY = e.clientY - rect.top
|
|
580
|
+
setHoveredItem(null, true) // Clear popover immediately on double-click zoom
|
|
581
|
+
|
|
582
|
+
setViewState((prev) => {
|
|
583
|
+
const worldX = (focalX - prev.x) / prev.zoom
|
|
584
|
+
const worldY = (focalY - prev.y) / prev.zoom
|
|
585
|
+
const thresholds = getExpandThresholds(rect.width)
|
|
586
|
+
const deepest = findDeepestAt(worldX, worldY, groupsRef.current, prev, thresholds)
|
|
587
|
+
|
|
588
|
+
let currentMaxZ = maxZoomRef.current
|
|
589
|
+
|
|
590
|
+
if (deepest && (!deepest.node.children || deepest.node.children.length === 0)) {
|
|
591
|
+
currentMaxZ = thresholds.end / (deepest.node.worldW * deepest.cumulativeScale)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return zoomAround(prev, focalX, focalY, 2, currentMaxZ)
|
|
595
|
+
})
|
|
596
|
+
onZoomRef.current?.()
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ── Touch pan + pinch ──────────────────────────────────────────
|
|
600
|
+
function pinchDist(touches: TouchList): number {
|
|
601
|
+
if (touches.length < 2) return 0
|
|
602
|
+
const dx = touches[0].clientX - touches[1].clientX
|
|
603
|
+
const dy = touches[0].clientY - touches[1].clientY
|
|
604
|
+
return Math.sqrt(dx * dx + dy * dy)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function pinchMid(touches: TouchList): { x: number; y: number } {
|
|
608
|
+
const rect = el!.getBoundingClientRect()
|
|
609
|
+
if (touches.length < 2) {
|
|
610
|
+
return { x: touches[0].clientX - rect.left, y: touches[0].clientY - rect.top }
|
|
611
|
+
}
|
|
612
|
+
return {
|
|
613
|
+
x: (touches[0].clientX + touches[1].clientX) / 2 - rect.left,
|
|
614
|
+
y: (touches[0].clientY + touches[1].clientY) / 2 - rect.top,
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function onTouchStart(e: TouchEvent) {
|
|
619
|
+
e.preventDefault()
|
|
620
|
+
if (e.touches.length === 1) {
|
|
621
|
+
dragging.current = true
|
|
622
|
+
lastMouse.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }
|
|
623
|
+
lastPinchDist.current = null
|
|
624
|
+
} else if (e.touches.length >= 2) {
|
|
625
|
+
dragging.current = false
|
|
626
|
+
const dist = pinchDist(e.touches)
|
|
627
|
+
lastPinchDist.current = dist > 0 ? dist : null
|
|
628
|
+
lastPinchMid.current = pinchMid(e.touches)
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function onTouchMove(e: TouchEvent) {
|
|
633
|
+
e.preventDefault()
|
|
634
|
+
setHoveredItem(null, true) // Clear popover immediately on touch movement
|
|
635
|
+
if (e.touches.length === 1 && dragging.current) {
|
|
636
|
+
const dx = e.touches[0].clientX - lastMouse.current.x
|
|
637
|
+
const dy = e.touches[0].clientY - lastMouse.current.y
|
|
638
|
+
lastMouse.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }
|
|
639
|
+
setViewState((prev) => ({ ...prev, x: prev.x + dx, y: prev.y + dy }))
|
|
640
|
+
onPanRef.current?.()
|
|
641
|
+
} else if (e.touches.length >= 2) {
|
|
642
|
+
const dist = pinchDist(e.touches)
|
|
643
|
+
const mid = pinchMid(e.touches)
|
|
644
|
+
if (lastPinchDist.current !== null && lastPinchDist.current > 0) {
|
|
645
|
+
const factor = dist / lastPinchDist.current
|
|
646
|
+
const dx = mid.x - lastPinchMid.current.x
|
|
647
|
+
const dy = mid.y - lastPinchMid.current.y
|
|
648
|
+
|
|
649
|
+
if (isFinite(factor) && factor > 0) {
|
|
650
|
+
setViewState((prev) => {
|
|
651
|
+
const rect = el!.getBoundingClientRect()
|
|
652
|
+
const worldX = (mid.x - prev.x) / prev.zoom
|
|
653
|
+
const worldY = (mid.y - prev.y) / prev.zoom
|
|
654
|
+
const thresholds = getExpandThresholds(rect.width)
|
|
655
|
+
const deepest = findDeepestAt(worldX, worldY, groupsRef.current, prev, thresholds)
|
|
656
|
+
|
|
657
|
+
let currentMaxZ = maxZoomRef.current
|
|
658
|
+
|
|
659
|
+
if (deepest && (!deepest.node.children || deepest.node.children.length === 0)) {
|
|
660
|
+
currentMaxZ = thresholds.end / (deepest.node.worldW * deepest.cumulativeScale)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const zoomed = zoomAround(prev, mid.x, mid.y, factor, currentMaxZ)
|
|
664
|
+
return { ...zoomed, x: zoomed.x + dx, y: zoomed.y + dy }
|
|
665
|
+
})
|
|
666
|
+
onZoomRef.current?.()
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
lastPinchDist.current = dist > 0 ? dist : lastPinchDist.current
|
|
670
|
+
lastPinchMid.current = mid
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
function onTouchEnd(e: TouchEvent) {
|
|
674
|
+
if (e.touches.length === 0) {
|
|
675
|
+
dragging.current = false
|
|
676
|
+
lastPinchDist.current = null
|
|
677
|
+
} else if (e.touches.length === 1) {
|
|
678
|
+
// Transition back to dragging with the single remaining finger
|
|
679
|
+
dragging.current = true
|
|
680
|
+
lastMouse.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }
|
|
681
|
+
lastPinchDist.current = null
|
|
682
|
+
} else {
|
|
683
|
+
// Still have multiple fingers, reset baseline to avoid jumps
|
|
684
|
+
const dist = pinchDist(e.touches)
|
|
685
|
+
lastPinchDist.current = dist > 0 ? dist : null
|
|
686
|
+
lastPinchMid.current = pinchMid(e.touches)
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
el.style.cursor = 'grab'
|
|
691
|
+
|
|
692
|
+
el.addEventListener('wheel', onWheel, { passive: false })
|
|
693
|
+
el.addEventListener('mousedown', onMouseDown)
|
|
694
|
+
el.addEventListener('mouseleave', onMouseOut)
|
|
695
|
+
el.addEventListener('mouseout', onMouseOut)
|
|
696
|
+
window.addEventListener('mousemove', onMouseMove)
|
|
697
|
+
window.addEventListener('mouseup', onMouseUp)
|
|
698
|
+
el.addEventListener('dblclick', onDblClick)
|
|
699
|
+
el.addEventListener('touchstart', onTouchStart, { passive: false })
|
|
700
|
+
el.addEventListener('touchmove', onTouchMove, { passive: false })
|
|
701
|
+
el.addEventListener('touchend', onTouchEnd)
|
|
702
|
+
el.addEventListener('touchcancel', onTouchEnd)
|
|
703
|
+
|
|
704
|
+
return () => {
|
|
705
|
+
el.removeEventListener('wheel', onWheel)
|
|
706
|
+
el.removeEventListener('mousedown', onMouseDown)
|
|
707
|
+
el.removeEventListener('mouseleave', onMouseOut)
|
|
708
|
+
el.removeEventListener('mouseout', onMouseOut)
|
|
709
|
+
window.removeEventListener('mousemove', onMouseMove)
|
|
710
|
+
window.removeEventListener('mouseup', onMouseUp)
|
|
711
|
+
el.removeEventListener('dblclick', onDblClick)
|
|
712
|
+
el.removeEventListener('touchstart', onTouchStart)
|
|
713
|
+
el.removeEventListener('touchmove', onTouchMove)
|
|
714
|
+
el.removeEventListener('touchend', onTouchEnd)
|
|
715
|
+
el.removeEventListener('touchcancel', onTouchEnd)
|
|
716
|
+
}
|
|
717
|
+
}, [canvasRef, setViewState, setHoveredItem, isMobile, resolveHoveredProxyItem]) // groupsRef handles groups updates without re-binding!
|
|
718
|
+
|
|
719
|
+
return { viewState, viewStateRef, setViewState, fitView, maxZoom: dynamicMaxZoom, hoveredItem, setHoveredItem, setHoverLocked }
|
|
720
|
+
}
|