@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,1189 @@
|
|
|
1
|
+
// src/components/ZUI/renderer.ts
|
|
2
|
+
|
|
3
|
+
import type { DiagramGroupLayout, LayoutNode, ZUIViewState } from './types'
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_SOURCE_HANDLE_SIDE,
|
|
6
|
+
DEFAULT_TARGET_HANDLE_SIDE,
|
|
7
|
+
getHandleFlowPosition,
|
|
8
|
+
getLogicalHandleId,
|
|
9
|
+
getVisualHandleIdForGroup,
|
|
10
|
+
} from '../../utils/edgeDistribution'
|
|
11
|
+
|
|
12
|
+
// ── Thresholds (screen pixels) ─────────────────────────────────────
|
|
13
|
+
// Responsive thresholds: smaller screens expand earlier.
|
|
14
|
+
export function getExpandThresholds(canvasW: number) {
|
|
15
|
+
return {
|
|
16
|
+
start: clamp(canvasW * 0.25, 80, 450),
|
|
17
|
+
end: clamp(canvasW * 0.4, 200, 640),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MIN_LABEL_PX = 12 // below this screen width, skip label text
|
|
22
|
+
const MIN_DRAW_PX = 2 // below this screen width, skip node entirely
|
|
23
|
+
const BADGE_THRESHOLD = 100 // node width in screen pixels below which we hide type badge and zoom icon
|
|
24
|
+
|
|
25
|
+
// ── Screen-space font limits (px) ──────────────────────────────────
|
|
26
|
+
const MIN_FONT_NAME = 10
|
|
27
|
+
const MAX_FONT_NAME = 50
|
|
28
|
+
const MIN_FONT_BADGE = 12
|
|
29
|
+
const MAX_FONT_BADGE = 30
|
|
30
|
+
const MIN_FONT_HINT = 12
|
|
31
|
+
const MAX_FONT_HINT = 24
|
|
32
|
+
|
|
33
|
+
export interface ScreenRect {
|
|
34
|
+
left: number
|
|
35
|
+
top: number
|
|
36
|
+
right: number
|
|
37
|
+
bottom: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Returns a world-space font size that, when multiplied by zoom,
|
|
43
|
+
* stays within [minScreenSize, maxScreenSize] screen pixels,
|
|
44
|
+
* while preferring baseWorldSize if possible.
|
|
45
|
+
*/
|
|
46
|
+
function getClampedFontSize(baseWorldSize: number, minScreenSize: number, maxScreenSize: number, zoom: number): number {
|
|
47
|
+
return clamp(baseWorldSize, minScreenSize / zoom, maxScreenSize / zoom)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Chakra v2 type palette - mirrors TYPE_COLORS in src/types/index.ts ─
|
|
51
|
+
// .400 variants: used for type badge text and border tint
|
|
52
|
+
const TYPE_COLOR_400: Record<string, string> = {
|
|
53
|
+
person: '#38b2ac', // teal.400
|
|
54
|
+
system: '#63b3ed', // blue.400
|
|
55
|
+
container: '#9f7aea', // purple.400
|
|
56
|
+
component: '#f6ad55', // orange.400
|
|
57
|
+
database: '#4fd1c5', // cyan.400
|
|
58
|
+
queue: '#f6e05e', // yellow.400
|
|
59
|
+
api: '#68d391', // green.400
|
|
60
|
+
service: '#f687b3', // pink.400
|
|
61
|
+
external: '#a0aec0', // gray.400
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Border color: type .400 at 50% alpha - bold branded tint */
|
|
65
|
+
function typeBorderColor(type: string, alpha = 0.5): string {
|
|
66
|
+
const color = TYPE_COLOR_400[type]
|
|
67
|
+
const hex = typeof color === 'string' ? color : '#a0aec0'
|
|
68
|
+
const r = parseInt(hex.slice(1, 3), 16)
|
|
69
|
+
const g = parseInt(hex.slice(3, 5), 16)
|
|
70
|
+
const b = parseInt(hex.slice(5, 7), 16)
|
|
71
|
+
return `rgba(${r},${g},${b},${alpha})`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Read a CSS custom property value from :root (resolves color-mix, etc.). */
|
|
75
|
+
function readCSSVar(name: string, fallback: string): string {
|
|
76
|
+
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
|
77
|
+
return v || fallback
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Geometry helpers ───────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
const imageCache = new Map<string, HTMLImageElement>()
|
|
83
|
+
|
|
84
|
+
let onImageLoadCallback: (() => void) | null = null
|
|
85
|
+
export function setOnImageLoadCallback(cb: (() => void) | null) {
|
|
86
|
+
onImageLoadCallback = cb
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let currentHighlightedTags: Set<string> = new Set()
|
|
90
|
+
export function setHighlightedTags(tags: Set<string>): void {
|
|
91
|
+
currentHighlightedTags = tags
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let currentHighlightColor = ''
|
|
95
|
+
export function setHighlightColor(color: string): void {
|
|
96
|
+
currentHighlightColor = color
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let currentHiddenTags: Set<string> = new Set()
|
|
100
|
+
export function setHiddenTags(tags: Set<string>): void {
|
|
101
|
+
currentHiddenTags = tags
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get image from cache or start loading it.
|
|
106
|
+
* Returns the image if already loaded, null otherwise.
|
|
107
|
+
*/
|
|
108
|
+
function getOrLoadImage(url: string | null): HTMLImageElement | null {
|
|
109
|
+
if (!url) return null
|
|
110
|
+
const cached = imageCache.get(url)
|
|
111
|
+
if (cached) {
|
|
112
|
+
return cached.complete && cached.naturalWidth > 0 ? cached : null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const img = new Image()
|
|
116
|
+
img.src = url
|
|
117
|
+
img.onload = () => {
|
|
118
|
+
if (onImageLoadCallback) onImageLoadCallback()
|
|
119
|
+
}
|
|
120
|
+
imageCache.set(url, img)
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function clamp(v: number, min: number, max: number): number {
|
|
125
|
+
return v < min ? min : v > max ? max : v
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function transitionT(screenW: number, start: number, end: number): number {
|
|
129
|
+
return clamp((screenW - start) / (end - start), 0, 1)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function rectsOverlap(a: ScreenRect, b: ScreenRect): boolean {
|
|
133
|
+
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function worldToScreen(matrix: DOMMatrix, x: number, y: number) {
|
|
137
|
+
return new DOMPoint(x, y).matrixTransform(matrix)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function screenToWorld(matrix: DOMMatrix, x: number, y: number) {
|
|
141
|
+
return new DOMPoint(x, y).matrixTransform(matrix.inverse())
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function pickEdgeLabelPosition(
|
|
145
|
+
matrix: DOMMatrix,
|
|
146
|
+
midX: number,
|
|
147
|
+
midY: number,
|
|
148
|
+
textW: number,
|
|
149
|
+
textH: number,
|
|
150
|
+
dx: number,
|
|
151
|
+
dy: number,
|
|
152
|
+
occupiedLabelRects: ScreenRect[],
|
|
153
|
+
) {
|
|
154
|
+
const screenMid = worldToScreen(matrix, midX, midY)
|
|
155
|
+
const screenTextW = Math.max(1, textW * matrix.a)
|
|
156
|
+
const screenTextH = Math.max(1, textH * matrix.d)
|
|
157
|
+
const gap = 6
|
|
158
|
+
const step = screenTextH + gap
|
|
159
|
+
const length = Math.hypot(dx, dy) || 1
|
|
160
|
+
const normalX = -dy / length
|
|
161
|
+
const normalY = dx / length
|
|
162
|
+
const tangentX = dx / length
|
|
163
|
+
const tangentY = dy / length
|
|
164
|
+
const candidateOffsets = [
|
|
165
|
+
{ x: 0, y: 0 },
|
|
166
|
+
{ x: normalX * step, y: normalY * step },
|
|
167
|
+
{ x: -normalX * step, y: -normalY * step },
|
|
168
|
+
{ x: normalX * step * 2, y: normalY * step * 2 },
|
|
169
|
+
{ x: -normalX * step * 2, y: -normalY * step * 2 },
|
|
170
|
+
{ x: tangentX * step, y: tangentY * step },
|
|
171
|
+
{ x: -tangentX * step, y: -tangentY * step },
|
|
172
|
+
{ x: tangentX * step + normalX * step, y: tangentY * step + normalY * step },
|
|
173
|
+
{ x: -tangentX * step - normalX * step, y: -tangentY * step - normalY * step },
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
for (const offset of candidateOffsets) {
|
|
177
|
+
const centerX = screenMid.x + offset.x
|
|
178
|
+
const centerY = screenMid.y + offset.y
|
|
179
|
+
const rect: ScreenRect = {
|
|
180
|
+
left: centerX - screenTextW / 2 - gap,
|
|
181
|
+
top: centerY - screenTextH / 2 - gap / 2,
|
|
182
|
+
right: centerX + screenTextW / 2 + gap,
|
|
183
|
+
bottom: centerY + screenTextH / 2 + gap / 2,
|
|
184
|
+
}
|
|
185
|
+
if (occupiedLabelRects.some((existing) => rectsOverlap(rect, existing))) continue
|
|
186
|
+
occupiedLabelRects.push(rect)
|
|
187
|
+
const worldPoint = screenToWorld(matrix, centerX, centerY)
|
|
188
|
+
return { x: worldPoint.x, y: worldPoint.y }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const fallbackRect: ScreenRect = {
|
|
192
|
+
left: screenMid.x - screenTextW / 2 - gap,
|
|
193
|
+
top: screenMid.y - screenTextH / 2 - gap / 2,
|
|
194
|
+
right: screenMid.x + screenTextW / 2 + gap,
|
|
195
|
+
bottom: screenMid.y + screenTextH / 2 + gap / 2,
|
|
196
|
+
}
|
|
197
|
+
occupiedLabelRects.push(fallbackRect)
|
|
198
|
+
return { x: midX, y: midY }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Is the rect (in world space) visible on screen? */
|
|
202
|
+
export function isVisible(
|
|
203
|
+
worldX: number, worldY: number, worldW: number, worldH: number,
|
|
204
|
+
view: ZUIViewState, canvasW: number, canvasH: number,
|
|
205
|
+
): boolean {
|
|
206
|
+
const sx = worldX * view.zoom + view.x
|
|
207
|
+
const sy = worldY * view.zoom + view.y
|
|
208
|
+
const sw = worldW * view.zoom
|
|
209
|
+
const sh = worldH * view.zoom
|
|
210
|
+
return sx + sw > 0 && sy + sh > 0 && sx < canvasW && sy < canvasH
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Is the rect (in world space) FULLY visible on screen? */
|
|
214
|
+
export function isFullyVisible(
|
|
215
|
+
worldX: number, worldY: number, worldW: number, worldH: number,
|
|
216
|
+
view: ZUIViewState, canvasW: number, canvasH: number,
|
|
217
|
+
): boolean {
|
|
218
|
+
const sx = worldX * view.zoom + view.x
|
|
219
|
+
const sy = worldY * view.zoom + view.y
|
|
220
|
+
const sw = worldW * view.zoom
|
|
221
|
+
const sh = worldH * view.zoom
|
|
222
|
+
return sx >= 0 && sy >= 0 && sx + sw <= canvasW && sy + sh <= canvasH
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Draw the ZoomIn magnifying glass icon. */
|
|
226
|
+
function drawZoomInIcon(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, strokeWidth: number): void {
|
|
227
|
+
ctx.save()
|
|
228
|
+
ctx.translate(x, y)
|
|
229
|
+
const s = size / 24
|
|
230
|
+
ctx.scale(s, s)
|
|
231
|
+
ctx.beginPath()
|
|
232
|
+
// Magnifying glass circle: cx="11" cy="11" r="8"
|
|
233
|
+
ctx.arc(11, 11, 8, 0, Math.PI * 2)
|
|
234
|
+
// Handle: x1="21" y1="21" x2="16.65" y2="16.65"
|
|
235
|
+
ctx.moveTo(21, 21)
|
|
236
|
+
ctx.lineTo(16.65, 16.65)
|
|
237
|
+
// Plus vertical: x1="11" y1="8" x2="11" y2="14"
|
|
238
|
+
ctx.moveTo(11, 8)
|
|
239
|
+
ctx.lineTo(11, 14)
|
|
240
|
+
// Plus horizontal: x1="8" y1="11" x2="14" y2="11"
|
|
241
|
+
ctx.moveTo(8, 11)
|
|
242
|
+
ctx.lineTo(14, 11)
|
|
243
|
+
ctx.lineWidth = strokeWidth
|
|
244
|
+
ctx.lineCap = 'round'
|
|
245
|
+
ctx.lineJoin = 'round'
|
|
246
|
+
ctx.stroke()
|
|
247
|
+
ctx.restore()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Draw a portal arrow icon (↗) for portal nodes. */
|
|
251
|
+
function drawPortalIcon(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, strokeWidth: number, color: string): void {
|
|
252
|
+
ctx.save()
|
|
253
|
+
ctx.strokeStyle = color
|
|
254
|
+
ctx.lineWidth = strokeWidth
|
|
255
|
+
ctx.lineCap = 'round'
|
|
256
|
+
ctx.lineJoin = 'round'
|
|
257
|
+
ctx.translate(x, y)
|
|
258
|
+
const s = size / 16
|
|
259
|
+
ctx.scale(s, s)
|
|
260
|
+
ctx.beginPath()
|
|
261
|
+
// Arrow shaft: (2,14) → (13,3)
|
|
262
|
+
ctx.moveTo(2, 14)
|
|
263
|
+
ctx.lineTo(13, 3)
|
|
264
|
+
// Arrow head
|
|
265
|
+
ctx.moveTo(5, 3)
|
|
266
|
+
ctx.lineTo(13, 3)
|
|
267
|
+
ctx.lineTo(13, 11)
|
|
268
|
+
ctx.stroke()
|
|
269
|
+
ctx.restore()
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Draw a cycle icon (↺) for circular nodes. */
|
|
273
|
+
function drawCycleIcon(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, strokeWidth: number, color: string): void {
|
|
274
|
+
ctx.save()
|
|
275
|
+
ctx.strokeStyle = color
|
|
276
|
+
ctx.lineWidth = strokeWidth
|
|
277
|
+
ctx.lineCap = 'round'
|
|
278
|
+
ctx.lineJoin = 'round'
|
|
279
|
+
ctx.translate(x, y)
|
|
280
|
+
const s = size / 24
|
|
281
|
+
ctx.scale(s, s)
|
|
282
|
+
ctx.beginPath()
|
|
283
|
+
// Circular arrow
|
|
284
|
+
ctx.arc(12, 12, 8, 0, Math.PI * 1.5)
|
|
285
|
+
ctx.moveTo(12, 4)
|
|
286
|
+
ctx.lineTo(16, 4)
|
|
287
|
+
ctx.lineTo(16, 0)
|
|
288
|
+
ctx.stroke()
|
|
289
|
+
ctx.restore()
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Parse a hex CSS color (#rrggbb or #rgb) into { r, g, b } 0-255. */
|
|
293
|
+
function parseHex(hex: string): { r: number; g: number; b: number } {
|
|
294
|
+
hex = hex.trim().replace(/^#/, '')
|
|
295
|
+
if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
|
|
296
|
+
return {
|
|
297
|
+
r: parseInt(hex.slice(0, 2), 16),
|
|
298
|
+
g: parseInt(hex.slice(2, 4), 16),
|
|
299
|
+
b: parseInt(hex.slice(4, 6), 16),
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Derive a portal tint color from the accent: same hue, very low alpha. */
|
|
304
|
+
function portalTintColor(accent: string, alpha: number): string {
|
|
305
|
+
const { r, g, b } = parseHex(accent)
|
|
306
|
+
return `rgba(${r},${g},${b},${alpha})`
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Draw a squiggly line from (x1, y1) to (x2, y2). */
|
|
310
|
+
function drawSquigglyLine(ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number, zoom: number): void {
|
|
311
|
+
ctx.save()
|
|
312
|
+
ctx.beginPath()
|
|
313
|
+
ctx.moveTo(x1, y1)
|
|
314
|
+
ctx.lineTo(x2, y2)
|
|
315
|
+
const dashLen = 6 / zoom
|
|
316
|
+
ctx.setLineDash([dashLen, dashLen * 1.5])
|
|
317
|
+
ctx.stroke()
|
|
318
|
+
ctx.restore()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Calculate coordinate for a named handle on a node. */
|
|
322
|
+
function getHandlePos(nodeX: number, nodeY: number, nodeW: number, nodeH: number, handleId: string | null, isSource: boolean): { x: number, y: number, pos: 'top' | 'bottom' | 'left' | 'right' } {
|
|
323
|
+
const fallback = isSource ? DEFAULT_SOURCE_HANDLE_SIDE : DEFAULT_TARGET_HANDLE_SIDE
|
|
324
|
+
const { x, y, side } = getHandleFlowPosition(nodeX, nodeY, nodeW, nodeH, handleId, fallback)
|
|
325
|
+
return { x, y, pos: side }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Draw a closed arrow head matching React Flow MarkerType.ArrowClosed. */
|
|
329
|
+
function drawArrowHead(ctx: CanvasRenderingContext2D, x: number, y: number, angle: number, size: number, color: string): void {
|
|
330
|
+
ctx.save()
|
|
331
|
+
ctx.translate(x, y)
|
|
332
|
+
ctx.rotate(angle)
|
|
333
|
+
ctx.beginPath()
|
|
334
|
+
// React Flow ArrowClosed is roughly a triangle
|
|
335
|
+
// size 14x14
|
|
336
|
+
ctx.moveTo(0, 0)
|
|
337
|
+
ctx.lineTo(-size, -size * 0.45)
|
|
338
|
+
ctx.lineTo(-size, size * 0.45)
|
|
339
|
+
ctx.closePath()
|
|
340
|
+
ctx.fillStyle = color
|
|
341
|
+
ctx.fill()
|
|
342
|
+
ctx.restore()
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── Node drawing ───────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Draw a single node.
|
|
349
|
+
*
|
|
350
|
+
* @param ctx Canvas 2D context (already in world-space transform)
|
|
351
|
+
* @param node The node to draw
|
|
352
|
+
* @param screenW Width of this node in screen pixels (worldW * zoom)
|
|
353
|
+
* @param alpha Outer opacity multiplier (from parent's childrenOpacity)
|
|
354
|
+
* @param zoom Current zoom (needed for font sizes)
|
|
355
|
+
* @param accent Resolved --accent CSS color (passed from renderFrame to avoid re-reading per node)
|
|
356
|
+
* @param labelBg Resolved label background color (passed through to avoid per-edge CSS reads)
|
|
357
|
+
* @param absX Absolute world-space X of this node (for child visibility culling)
|
|
358
|
+
* @param absY Absolute world-space Y of this node (for child visibility culling)
|
|
359
|
+
* @param absScale Accumulated product of ancestor childScale values (world-space scale factor).
|
|
360
|
+
* 1 for top-level nodes; multiplied by each parent's childScale going deeper.
|
|
361
|
+
* Required to correctly map child-local displacements to world-space for culling.
|
|
362
|
+
*/
|
|
363
|
+
function drawNode(
|
|
364
|
+
ctx: CanvasRenderingContext2D,
|
|
365
|
+
node: LayoutNode,
|
|
366
|
+
screenW: number,
|
|
367
|
+
thresholds: { start: number; end: number },
|
|
368
|
+
alpha: number,
|
|
369
|
+
zoom: number,
|
|
370
|
+
nodeBg: string,
|
|
371
|
+
canvasBg: string,
|
|
372
|
+
view: ZUIViewState,
|
|
373
|
+
canvasW: number,
|
|
374
|
+
canvasH: number,
|
|
375
|
+
accent: string,
|
|
376
|
+
labelBg: string,
|
|
377
|
+
absX: number,
|
|
378
|
+
absY: number,
|
|
379
|
+
absScale: number,
|
|
380
|
+
occupiedLabelRects: ScreenRect[],
|
|
381
|
+
): void {
|
|
382
|
+
if (screenW < MIN_DRAW_PX || alpha < 0.01) return
|
|
383
|
+
|
|
384
|
+
// Skip nodes whose tags are all hidden
|
|
385
|
+
if (currentHiddenTags.size > 0 && node.tags.length > 0 && node.tags.some(t => currentHiddenTags.has(t))) return
|
|
386
|
+
|
|
387
|
+
const x = node.worldX
|
|
388
|
+
const y = node.worldY
|
|
389
|
+
const w = node.worldW
|
|
390
|
+
const h = node.worldH
|
|
391
|
+
|
|
392
|
+
let drawZoom = zoom
|
|
393
|
+
let drawScreenW = screenW
|
|
394
|
+
|
|
395
|
+
const hasChildren = node.children && node.children.length > 0
|
|
396
|
+
const t = hasChildren ? transitionT(screenW, thresholds.start, thresholds.end) : 0
|
|
397
|
+
|
|
398
|
+
// ── Cap leaf nodes visually ──
|
|
399
|
+
if (!hasChildren && screenW > thresholds.end) {
|
|
400
|
+
const s = thresholds.end / screenW
|
|
401
|
+
drawZoom = zoom * s
|
|
402
|
+
drawScreenW = thresholds.end
|
|
403
|
+
ctx.save()
|
|
404
|
+
const cx = x + w / 2
|
|
405
|
+
const cy = y + h / 2
|
|
406
|
+
ctx.translate(cx, cy)
|
|
407
|
+
ctx.scale(s, s)
|
|
408
|
+
ctx.translate(-cx, -cy)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const parentAlpha = alpha * (1 - t)
|
|
412
|
+
const childAlpha = alpha * t
|
|
413
|
+
const r = 8 / drawZoom // matches Chakra rounded="lg" (8px)
|
|
414
|
+
|
|
415
|
+
const borderColor = typeBorderColor(node.type)
|
|
416
|
+
|
|
417
|
+
const traceShape = (ox = 0, oy = 0) => {
|
|
418
|
+
ctx.beginPath()
|
|
419
|
+
ctx.roundRect(x + ox, y + oy, w, h, r)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ── Circular Link Overlay - subtle indicator ──────────────────────
|
|
423
|
+
if (node.isCircular && parentAlpha > 0.1) {
|
|
424
|
+
ctx.save()
|
|
425
|
+
ctx.globalAlpha = parentAlpha * 0.15
|
|
426
|
+
ctx.fillStyle = accent
|
|
427
|
+
traceShape()
|
|
428
|
+
ctx.fill()
|
|
429
|
+
ctx.restore()
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ── Zoomable Stack Signal - subtle card stack behind ───────────────
|
|
433
|
+
if (hasChildren && parentAlpha > 0.1 && t < 0.5) {
|
|
434
|
+
const stackT = 1 - (t / 0.5) // Fades out completely by t=0.5
|
|
435
|
+
ctx.save()
|
|
436
|
+
ctx.globalAlpha = parentAlpha * stackT * 0.4
|
|
437
|
+
ctx.fillStyle = nodeBg
|
|
438
|
+
ctx.strokeStyle = borderColor
|
|
439
|
+
ctx.lineWidth = 1 / drawZoom
|
|
440
|
+
|
|
441
|
+
const offset1 = 4 / drawZoom
|
|
442
|
+
const offset2 = 8 / drawZoom
|
|
443
|
+
|
|
444
|
+
// Draw two offset rectangles behind the node
|
|
445
|
+
// Rect 2 (deepest)
|
|
446
|
+
traceShape(offset2, offset2)
|
|
447
|
+
ctx.fill()
|
|
448
|
+
ctx.stroke()
|
|
449
|
+
|
|
450
|
+
// Rect 1
|
|
451
|
+
traceShape(offset1, offset1)
|
|
452
|
+
ctx.fill()
|
|
453
|
+
ctx.stroke()
|
|
454
|
+
ctx.restore()
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ── Background ───────────────────────────────────────────────────
|
|
458
|
+
// We draw two backgrounds:
|
|
459
|
+
// 1. A base background (canvasBg) that remains opaque (total 'alpha').
|
|
460
|
+
// This hides connectors from parent levels.
|
|
461
|
+
// 2. The node's branded background (nodeBg) that fades out as we zoom in ('parentAlpha').
|
|
462
|
+
// This makes the nested diagram appear on a clean canvas background.
|
|
463
|
+
if (alpha > 0.01) {
|
|
464
|
+
ctx.save()
|
|
465
|
+
traceShape()
|
|
466
|
+
|
|
467
|
+
// Base background (20% transparent to allow slight ghosting of connectors)
|
|
468
|
+
ctx.globalAlpha = alpha * 0.8
|
|
469
|
+
ctx.fillStyle = canvasBg
|
|
470
|
+
ctx.fill()
|
|
471
|
+
|
|
472
|
+
// Fading node background
|
|
473
|
+
if (parentAlpha > 0.01) {
|
|
474
|
+
ctx.globalAlpha = parentAlpha * 0.8
|
|
475
|
+
ctx.fillStyle = nodeBg
|
|
476
|
+
ctx.fill()
|
|
477
|
+
|
|
478
|
+
// Portal overlay: accent-tinted fill derived from --accent CSS var
|
|
479
|
+
if (node.isPortal) {
|
|
480
|
+
ctx.fillStyle = portalTintColor(accent, 0.10)
|
|
481
|
+
ctx.fill()
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
ctx.restore()
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── Technology Icon - Top Center like ElementNode.tsx (no fade) ──────
|
|
489
|
+
// Hide when node is too small (drawScreenW < 60)
|
|
490
|
+
if (node.logoUrl && parentAlpha > 0.05 && drawScreenW > 60) {
|
|
491
|
+
const img = getOrLoadImage(node.logoUrl)
|
|
492
|
+
if (img) {
|
|
493
|
+
ctx.save()
|
|
494
|
+
ctx.globalAlpha = parentAlpha * 1
|
|
495
|
+
|
|
496
|
+
// Scale logoMaxDim and topOffset relative to node world height 'h'
|
|
497
|
+
// instead of fixed screen pixels.
|
|
498
|
+
const logoMaxDim = h * 0.35
|
|
499
|
+
const topOffset = h * 0.06
|
|
500
|
+
|
|
501
|
+
const aspect = img.width / img.height
|
|
502
|
+
let drawW = logoMaxDim
|
|
503
|
+
let drawH = drawW / aspect
|
|
504
|
+
|
|
505
|
+
if (drawH > logoMaxDim) {
|
|
506
|
+
drawH = logoMaxDim
|
|
507
|
+
drawW = drawH * aspect
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Center icon at top
|
|
511
|
+
const iconX = x + (w - drawW) / 2
|
|
512
|
+
const iconY = y + topOffset + (logoMaxDim - drawH) / 2
|
|
513
|
+
|
|
514
|
+
ctx.drawImage(img, iconX, iconY, drawW, drawH)
|
|
515
|
+
ctx.restore()
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
// ── Border - portal uses accent long-dash; others use type-tinted border ─
|
|
519
|
+
ctx.save()
|
|
520
|
+
ctx.globalAlpha = alpha
|
|
521
|
+
traceShape()
|
|
522
|
+
if (node.isPortal) {
|
|
523
|
+
// Solid accent border per latest request
|
|
524
|
+
ctx.strokeStyle = accent
|
|
525
|
+
ctx.lineWidth = 1 / drawZoom
|
|
526
|
+
ctx.setLineDash([])
|
|
527
|
+
} else {
|
|
528
|
+
ctx.strokeStyle = borderColor
|
|
529
|
+
ctx.lineWidth = 1.5 / drawZoom
|
|
530
|
+
if (t > 0.15) {
|
|
531
|
+
const dashLen = 6
|
|
532
|
+
ctx.setLineDash([dashLen, dashLen * 0.7])
|
|
533
|
+
} else {
|
|
534
|
+
ctx.setLineDash([])
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
ctx.stroke()
|
|
538
|
+
ctx.setLineDash([])
|
|
539
|
+
ctx.restore()
|
|
540
|
+
|
|
541
|
+
// ── Label - portal shows "PORTAL" badge in accent; otherwise type badge ─
|
|
542
|
+
if (screenW >= MIN_LABEL_PX && parentAlpha > 0.1) {
|
|
543
|
+
// Dynamic minimum: don't let font be larger than a fraction of node height on screen
|
|
544
|
+
const minName = Math.min(MIN_FONT_NAME, screenW * 0.35)
|
|
545
|
+
// w=200, so 0.10w = 20px (Chakra 'xl')
|
|
546
|
+
const nameFontSize = getClampedFontSize(w * 0.10, minName, MAX_FONT_NAME, drawZoom)
|
|
547
|
+
const screenFontSize = nameFontSize * drawZoom
|
|
548
|
+
|
|
549
|
+
if (screenFontSize >= 6) {
|
|
550
|
+
ctx.save()
|
|
551
|
+
ctx.globalAlpha = parentAlpha
|
|
552
|
+
ctx.font = `600 ${nameFontSize}px Inter, system-ui, sans-serif`
|
|
553
|
+
ctx.fillStyle = '#f7fafc' // gray.100
|
|
554
|
+
ctx.textAlign = 'center'
|
|
555
|
+
ctx.textBaseline = 'middle'
|
|
556
|
+
|
|
557
|
+
const worldPadding = w * 0.08
|
|
558
|
+
const maxW = w - worldPadding
|
|
559
|
+
let label = node.label
|
|
560
|
+
const totalW = ctx.measureText(label).width
|
|
561
|
+
if (totalW > maxW) {
|
|
562
|
+
const ratio = maxW / totalW
|
|
563
|
+
label = label.slice(0, Math.max(3, Math.floor(label.length * ratio)))
|
|
564
|
+
if (label.length < node.label.length) label += '…'
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// If logo exists and is shown, push text down similar to ElementNode.tsx (pt=9/36px)
|
|
568
|
+
const showLogo = !!node.logoUrl && drawScreenW > 60
|
|
569
|
+
const baseOffset = showLogo ? 0.15 : 0
|
|
570
|
+
const nameY = drawScreenW > BADGE_THRESHOLD ? y + h * (0.42 + baseOffset) : y + h * (0.5 + baseOffset)
|
|
571
|
+
ctx.fillText(label, x + w / 2, nameY)
|
|
572
|
+
|
|
573
|
+
// Type badge - using regular element type display
|
|
574
|
+
if (drawScreenW > BADGE_THRESHOLD) {
|
|
575
|
+
const minBadge = Math.min(MIN_FONT_BADGE, screenW * 0.20)
|
|
576
|
+
// 0.05w = 10px (Chakra '2xs')
|
|
577
|
+
const badgeFontSize = getClampedFontSize(w * 0.05, minBadge, MAX_FONT_BADGE, drawZoom)
|
|
578
|
+
if (badgeFontSize * drawZoom >= 5) {
|
|
579
|
+
ctx.font = `${badgeFontSize}px Inter, system-ui, sans-serif`
|
|
580
|
+
const badgeColor = TYPE_COLOR_400[node.type]
|
|
581
|
+
ctx.fillStyle = typeof badgeColor === 'string' ? badgeColor : '#a0aec0'
|
|
582
|
+
const displayType = typeof node.type === 'string' ? node.type.toUpperCase() : 'UNKNOWN'
|
|
583
|
+
ctx.fillText(displayType, x + w / 2, y + h * (0.62 + baseOffset))
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
ctx.restore()
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ── Linked-diagram hint below node during transition ─────────────
|
|
591
|
+
if (node.linkedDiagramLabel && t > 0.05 && alpha > 0.05) {
|
|
592
|
+
const hintFontSize = getClampedFontSize(14, MIN_FONT_HINT, MAX_FONT_HINT, drawZoom)
|
|
593
|
+
const screenFontSize = hintFontSize * drawZoom
|
|
594
|
+
|
|
595
|
+
if (screenFontSize >= 6) {
|
|
596
|
+
let hintX = x + w / 2
|
|
597
|
+
let hintY = y + h + 10 // Fixed distance in world units
|
|
598
|
+
|
|
599
|
+
if (t > 0.8) {
|
|
600
|
+
// Sticky hint Y: stick to viewport bottom
|
|
601
|
+
const viewportBottomWorld = (canvasH - screenFontSize - view.y) / view.zoom
|
|
602
|
+
hintY = Math.min(hintY, viewportBottomWorld)
|
|
603
|
+
hintY = Math.max(hintY, y + h / 2) // avoid overlapping center
|
|
604
|
+
|
|
605
|
+
// Sticky hint X: stick to viewport sides
|
|
606
|
+
const vwL = -view.x / view.zoom
|
|
607
|
+
const vwR = (canvasW - view.x) / view.zoom
|
|
608
|
+
|
|
609
|
+
ctx.save()
|
|
610
|
+
ctx.font = `${hintFontSize}px Inter, system-ui, sans-serif`
|
|
611
|
+
const tw = ctx.measureText('⊞ ' + node.linkedDiagramLabel).width
|
|
612
|
+
ctx.restore()
|
|
613
|
+
|
|
614
|
+
const pad = 30 / view.zoom
|
|
615
|
+
hintX = Math.max(hintX, vwL + tw / 2 + pad)
|
|
616
|
+
hintX = Math.min(hintX, vwR - tw / 2 - pad)
|
|
617
|
+
// Ensure it stays within node boundaries (with some padding)
|
|
618
|
+
hintX = clamp(hintX, x + tw / 2 + 10, x + w - tw / 2 - 10)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
ctx.save()
|
|
622
|
+
ctx.globalAlpha = alpha * 0.7
|
|
623
|
+
ctx.font = `${hintFontSize}px Inter, system-ui, sans-serif`
|
|
624
|
+
ctx.fillStyle = node.isCircular ? accent : '#718096' // accent for circular to draw attention
|
|
625
|
+
ctx.textAlign = 'center'
|
|
626
|
+
ctx.textBaseline = 'top'
|
|
627
|
+
const hintPrefix = node.isCircular ? '↺ ' : '⊞ '
|
|
628
|
+
const hintSuffix = node.isCircular ? ' (Circular)' : ''
|
|
629
|
+
ctx.fillText(hintPrefix + node.linkedDiagramLabel + hintSuffix, hintX, hintY)
|
|
630
|
+
ctx.restore()
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ── Children ─────────────────────────────────────────────────────
|
|
635
|
+
if (childAlpha > 0.01 && node.children.length > 0) {
|
|
636
|
+
ctx.save()
|
|
637
|
+
// Clip to the node's rect so children don't bleed out
|
|
638
|
+
traceShape()
|
|
639
|
+
ctx.clip()
|
|
640
|
+
|
|
641
|
+
// Transform into child-local space
|
|
642
|
+
ctx.translate(x, y)
|
|
643
|
+
ctx.scale(node.childScale, node.childScale)
|
|
644
|
+
ctx.translate(-node.childOffsetX, -node.childOffsetY)
|
|
645
|
+
|
|
646
|
+
const childZoom = zoom * node.childScale
|
|
647
|
+
const edgeZoom = drawZoom * node.childScale
|
|
648
|
+
|
|
649
|
+
// Recursive children's edges DRAWN FIRST (below nodes)
|
|
650
|
+
if (childAlpha > 0.2) {
|
|
651
|
+
drawEdges(ctx, node.children, childAlpha * 0.5, edgeZoom, thresholds, accent, labelBg, occupiedLabelRects)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const nextAbsScale = absScale * node.childScale
|
|
655
|
+
for (const child of node.children) {
|
|
656
|
+
const childAbsX = absX + (child.worldX - node.childOffsetX) * node.childScale * absScale
|
|
657
|
+
const childAbsY = absY + (child.worldY - node.childOffsetY) * node.childScale * absScale
|
|
658
|
+
const childAbsW = child.worldW * node.childScale * absScale
|
|
659
|
+
const childAbsH = child.worldH * node.childScale * absScale
|
|
660
|
+
if (!isVisible(childAbsX, childAbsY, childAbsW, childAbsH, view, canvasW, canvasH)) continue
|
|
661
|
+
|
|
662
|
+
const childScreenW = child.worldW * childZoom
|
|
663
|
+
drawNode(ctx, child, childScreenW, thresholds, childAlpha, childZoom, nodeBg, canvasBg, view, canvasW, canvasH, accent, labelBg, childAbsX, childAbsY, nextAbsScale, occupiedLabelRects)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
ctx.restore()
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ── Zoomable indicator (top-right) ──────────────────────────────
|
|
670
|
+
if ((hasChildren || node.isCircular) && t < 0.9 && alpha > 0.2 && drawScreenW > BADGE_THRESHOLD) {
|
|
671
|
+
const iconSize = getClampedFontSize(12, 10, 16, drawZoom)
|
|
672
|
+
const padding = 8 / drawZoom
|
|
673
|
+
|
|
674
|
+
ctx.save()
|
|
675
|
+
// Noticeable but subtle: opacity fades as we zoom in (t increases)
|
|
676
|
+
ctx.globalAlpha = alpha * (1 - t) * 0.8
|
|
677
|
+
ctx.strokeStyle = accent
|
|
678
|
+
if (node.isCircular) {
|
|
679
|
+
drawCycleIcon(ctx, x + w - iconSize - padding, y + padding, iconSize, 3.5, accent)
|
|
680
|
+
} else if (node.isPortal) {
|
|
681
|
+
// Portal: use arrow icon instead of magnifying glass
|
|
682
|
+
drawPortalIcon(ctx, x + w - iconSize - padding, y + padding, iconSize, 3.5, accent)
|
|
683
|
+
} else {
|
|
684
|
+
drawZoomInIcon(ctx, x + w - iconSize - padding, y + padding, iconSize, 3.5)
|
|
685
|
+
}
|
|
686
|
+
ctx.restore()
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ── Tag highlighting dim / glow ──────────────────────────────────
|
|
690
|
+
if (currentHighlightedTags.size > 0 && parentAlpha > 0.05) {
|
|
691
|
+
const isHighlighted = node.tags.length > 0 && node.tags.some(t => currentHighlightedTags.has(t))
|
|
692
|
+
if (!isHighlighted) {
|
|
693
|
+
ctx.save()
|
|
694
|
+
ctx.globalAlpha = parentAlpha * 0.82
|
|
695
|
+
ctx.fillStyle = canvasBg
|
|
696
|
+
traceShape()
|
|
697
|
+
ctx.fill()
|
|
698
|
+
ctx.restore()
|
|
699
|
+
} else {
|
|
700
|
+
const glowColor = currentHighlightColor || accent
|
|
701
|
+
ctx.save()
|
|
702
|
+
ctx.globalAlpha = parentAlpha
|
|
703
|
+
ctx.shadowColor = glowColor
|
|
704
|
+
ctx.shadowBlur = 8 / drawZoom
|
|
705
|
+
ctx.strokeStyle = glowColor
|
|
706
|
+
ctx.lineWidth = 2.5 / drawZoom
|
|
707
|
+
ctx.setLineDash([])
|
|
708
|
+
traceShape()
|
|
709
|
+
ctx.stroke()
|
|
710
|
+
ctx.shadowBlur = 0
|
|
711
|
+
ctx.restore()
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (!hasChildren && screenW > thresholds.end) {
|
|
716
|
+
ctx.restore()
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ── Edge drawing ───────────────────────────────────────────────────
|
|
721
|
+
|
|
722
|
+
function drawEdges(
|
|
723
|
+
ctx: CanvasRenderingContext2D,
|
|
724
|
+
nodes: LayoutNode[],
|
|
725
|
+
alpha: number,
|
|
726
|
+
zoom: number,
|
|
727
|
+
thresholds: { start: number; end: number },
|
|
728
|
+
accent: string,
|
|
729
|
+
labelBg: string,
|
|
730
|
+
occupiedLabelRects: ScreenRect[],
|
|
731
|
+
): void {
|
|
732
|
+
if (alpha < 0.05) return
|
|
733
|
+
const nodeMap = new Map(nodes.map((n) => [n.id, n]))
|
|
734
|
+
const handleUsage: Record<string, { edgeKey: string; type: 'source' | 'target'; otherNodeCoord: number }[]> = {}
|
|
735
|
+
|
|
736
|
+
nodes.forEach((node) => {
|
|
737
|
+
node.edgesOut.forEach((edge, edgeIndex) => {
|
|
738
|
+
const target = nodeMap.get(edge.targetId)
|
|
739
|
+
if (!target) return
|
|
740
|
+
|
|
741
|
+
const edgeKey = `${node.id}:${edgeIndex}`
|
|
742
|
+
const sourceSide = getLogicalHandleId(edge.sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE) ?? DEFAULT_SOURCE_HANDLE_SIDE
|
|
743
|
+
const targetSide = getLogicalHandleId(edge.targetHandle, DEFAULT_TARGET_HANDLE_SIDE) ?? DEFAULT_TARGET_HANDLE_SIDE
|
|
744
|
+
|
|
745
|
+
const srcKey = `${node.id}-${sourceSide}`
|
|
746
|
+
handleUsage[srcKey] ??= []
|
|
747
|
+
handleUsage[srcKey].push({
|
|
748
|
+
edgeKey,
|
|
749
|
+
type: 'source',
|
|
750
|
+
otherNodeCoord: sourceSide === 'left' || sourceSide === 'right'
|
|
751
|
+
? target.worldY + target.worldH / 2
|
|
752
|
+
: target.worldX + target.worldW / 2,
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
const tgtKey = `${target.id}-${targetSide}`
|
|
756
|
+
handleUsage[tgtKey] ??= []
|
|
757
|
+
handleUsage[tgtKey].push({
|
|
758
|
+
edgeKey,
|
|
759
|
+
type: 'target',
|
|
760
|
+
otherNodeCoord: targetSide === 'left' || targetSide === 'right'
|
|
761
|
+
? node.worldY + node.worldH / 2
|
|
762
|
+
: node.worldX + node.worldW / 2,
|
|
763
|
+
})
|
|
764
|
+
})
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
Object.values(handleUsage).forEach((usages) => {
|
|
768
|
+
usages.sort((a, b) => a.otherNodeCoord - b.otherNodeCoord)
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
for (const node of nodes) {
|
|
772
|
+
for (const [edgeIndex, edge] of node.edgesOut.entries()) {
|
|
773
|
+
const target = nodeMap.get(edge.targetId)
|
|
774
|
+
if (!target) continue
|
|
775
|
+
|
|
776
|
+
// Skip edge if either endpoint is hidden by tag filter
|
|
777
|
+
if (currentHiddenTags.size > 0) {
|
|
778
|
+
const srcHidden = node.tags.length > 0 && node.tags.some(t => currentHiddenTags.has(t))
|
|
779
|
+
const tgtHidden = target.tags.length > 0 && target.tags.some(t => currentHiddenTags.has(t))
|
|
780
|
+
if (srcHidden || tgtHidden) continue
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const dir = edge.direction ?? 'forward'
|
|
784
|
+
const type = edge.type || 'bezier'
|
|
785
|
+
|
|
786
|
+
// ── Effective visual dimensions (handles capping) ─────────────
|
|
787
|
+
const hasSourceChildren = node.children && node.children.length > 0
|
|
788
|
+
const sourceScreenW = node.worldW * zoom
|
|
789
|
+
const sSource = (!hasSourceChildren && sourceScreenW > thresholds.end) ? thresholds.end / sourceScreenW : 1
|
|
790
|
+
const effWSource = node.worldW * sSource
|
|
791
|
+
const effHSource = node.worldH * sSource
|
|
792
|
+
const cxSource = node.worldX + node.worldW / 2
|
|
793
|
+
const cySource = node.worldY + node.worldH / 2
|
|
794
|
+
const effXSource = cxSource - effWSource / 2
|
|
795
|
+
const effYSource = cySource - effHSource / 2
|
|
796
|
+
|
|
797
|
+
const hasTargetChildren = target.children && target.children.length > 0
|
|
798
|
+
const targetScreenW = target.worldW * zoom
|
|
799
|
+
const sTarget = (!hasTargetChildren && targetScreenW > thresholds.end) ? thresholds.end / targetScreenW : 1
|
|
800
|
+
const effWTarget = target.worldW * sTarget
|
|
801
|
+
const effHTarget = target.worldH * sTarget
|
|
802
|
+
const cxTarget = target.worldX + target.worldW / 2
|
|
803
|
+
const cyTarget = target.worldY + target.worldH / 2
|
|
804
|
+
const effXTarget = cxTarget - effWTarget / 2
|
|
805
|
+
const effYTarget = cyTarget - effHTarget / 2
|
|
806
|
+
|
|
807
|
+
const edgeKey = `${node.id}:${edgeIndex}`
|
|
808
|
+
const sourceSide = getLogicalHandleId(edge.sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE) ?? DEFAULT_SOURCE_HANDLE_SIDE
|
|
809
|
+
const targetSide = getLogicalHandleId(edge.targetHandle, DEFAULT_TARGET_HANDLE_SIDE) ?? DEFAULT_TARGET_HANDLE_SIDE
|
|
810
|
+
const srcGroup = handleUsage[`${node.id}-${sourceSide}`] ?? []
|
|
811
|
+
const tgtGroup = handleUsage[`${target.id}-${targetSide}`] ?? []
|
|
812
|
+
const sourceGroupIndex = srcGroup.findIndex((usage) => usage.edgeKey === edgeKey && usage.type === 'source')
|
|
813
|
+
const targetGroupIndex = tgtGroup.findIndex((usage) => usage.edgeKey === edgeKey && usage.type === 'target')
|
|
814
|
+
|
|
815
|
+
const sH = getHandlePos(
|
|
816
|
+
effXSource,
|
|
817
|
+
effYSource,
|
|
818
|
+
effWSource,
|
|
819
|
+
effHSource,
|
|
820
|
+
getVisualHandleIdForGroup(sourceSide, sourceGroupIndex, Math.max(srcGroup.length, 1)),
|
|
821
|
+
true,
|
|
822
|
+
)
|
|
823
|
+
const tH = getHandlePos(
|
|
824
|
+
effXTarget,
|
|
825
|
+
effYTarget,
|
|
826
|
+
effWTarget,
|
|
827
|
+
effHTarget,
|
|
828
|
+
getVisualHandleIdForGroup(targetSide, targetGroupIndex, Math.max(tgtGroup.length, 1)),
|
|
829
|
+
false,
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
ctx.save()
|
|
833
|
+
ctx.globalAlpha = alpha * 0.8
|
|
834
|
+
ctx.strokeStyle = accent
|
|
835
|
+
ctx.lineWidth = 2 / zoom
|
|
836
|
+
|
|
837
|
+
let midX = (sH.x + tH.x) / 2
|
|
838
|
+
let midY = (sH.y + tH.y) / 2
|
|
839
|
+
let finalAngleS = 0
|
|
840
|
+
let finalAngleT = 0
|
|
841
|
+
|
|
842
|
+
if (type === 'bezier') {
|
|
843
|
+
const curvature = 0.5
|
|
844
|
+
let cp1x = sH.x, cp1y = sH.y, cp2x = tH.x, cp2y = tH.y
|
|
845
|
+
const dx = Math.abs(tH.x - sH.x)
|
|
846
|
+
const dy = Math.abs(tH.y - sH.y)
|
|
847
|
+
|
|
848
|
+
// Minimum stem: control point must extend at least half the node's
|
|
849
|
+
// dimension along the handle's exit axis. This prevents the curve
|
|
850
|
+
// from taking a sharp turn when dx or dy is small relative to the node.
|
|
851
|
+
const minStemSH = (sH.pos === 'left' || sH.pos === 'right') ? effWSource * 0.5 : effHSource * 0.5
|
|
852
|
+
const minStemTH = (tH.pos === 'left' || tH.pos === 'right') ? effWTarget * 0.5 : effHTarget * 0.5
|
|
853
|
+
|
|
854
|
+
if (sH.pos === 'left' || sH.pos === 'right') {
|
|
855
|
+
const stem = Math.max(dx * curvature, minStemSH)
|
|
856
|
+
cp1x += sH.pos === 'left' ? -stem : stem
|
|
857
|
+
} else {
|
|
858
|
+
const stem = Math.max(dy * curvature, minStemSH)
|
|
859
|
+
cp1y += sH.pos === 'top' ? -stem : stem
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (tH.pos === 'left' || tH.pos === 'right') {
|
|
863
|
+
const stem = Math.max(dx * curvature, minStemTH)
|
|
864
|
+
cp2x += tH.pos === 'left' ? -stem : stem
|
|
865
|
+
} else {
|
|
866
|
+
const stem = Math.max(dy * curvature, minStemTH)
|
|
867
|
+
cp2y += tH.pos === 'top' ? -stem : stem
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
ctx.beginPath()
|
|
871
|
+
ctx.moveTo(sH.x, sH.y)
|
|
872
|
+
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, tH.x, tH.y)
|
|
873
|
+
ctx.stroke()
|
|
874
|
+
|
|
875
|
+
midX = 0.125 * sH.x + 0.375 * cp1x + 0.375 * cp2x + 0.125 * tH.x
|
|
876
|
+
midY = 0.125 * sH.y + 0.375 * cp1y + 0.375 * cp2y + 0.125 * tH.y
|
|
877
|
+
finalAngleT = Math.atan2(tH.y - cp2y, tH.x - cp2x)
|
|
878
|
+
finalAngleS = Math.atan2(sH.y - cp1y, sH.x - cp1x)
|
|
879
|
+
|
|
880
|
+
} else if (type === 'straight') {
|
|
881
|
+
ctx.beginPath()
|
|
882
|
+
ctx.moveTo(sH.x, sH.y)
|
|
883
|
+
ctx.lineTo(tH.x, tH.y)
|
|
884
|
+
ctx.stroke()
|
|
885
|
+
finalAngleT = Math.atan2(tH.y - sH.y, tH.x - sH.x)
|
|
886
|
+
finalAngleS = Math.atan2(sH.y - tH.y, sH.x - tH.x)
|
|
887
|
+
|
|
888
|
+
} else if (type === 'step' || type === 'smoothstep') {
|
|
889
|
+
const borderRadius = type === 'smoothstep' ? 6 / zoom : 0
|
|
890
|
+
|
|
891
|
+
const points: Array<{ x: number, y: number }> = [{ x: sH.x, y: sH.y }]
|
|
892
|
+
const sOrth = sH.pos === 'left' || sH.pos === 'right' ? 'h' : 'v'
|
|
893
|
+
const tOrth = tH.pos === 'left' || tH.pos === 'right' ? 'h' : 'v'
|
|
894
|
+
|
|
895
|
+
if (sOrth === 'h' && tOrth === 'h') {
|
|
896
|
+
// Both horizontal: exit H, then V turn, then enter H
|
|
897
|
+
points.push({ x: midX, y: sH.y })
|
|
898
|
+
points.push({ x: midX, y: tH.y })
|
|
899
|
+
} else if (sOrth === 'v' && tOrth === 'v') {
|
|
900
|
+
// Both vertical: exit V, then H turn, then enter V
|
|
901
|
+
points.push({ x: sH.x, y: midY })
|
|
902
|
+
points.push({ x: tH.x, y: midY })
|
|
903
|
+
} else if (sOrth === 'h' && tOrth === 'v') {
|
|
904
|
+
// Mixed: exit H, turn V, enter V
|
|
905
|
+
points.push({ x: tH.x, y: sH.y })
|
|
906
|
+
} else if (sOrth === 'v' && tOrth === 'h') {
|
|
907
|
+
// Mixed: exit V, turn H, enter H
|
|
908
|
+
points.push({ x: sH.x, y: tH.y })
|
|
909
|
+
}
|
|
910
|
+
points.push({ x: tH.x, y: tH.y })
|
|
911
|
+
|
|
912
|
+
// Calculate label midpoint along the orthogonal segments
|
|
913
|
+
if (points.length === 4) {
|
|
914
|
+
// H-H or V-V: put label in the middle of the middle segment
|
|
915
|
+
midX = (points[1].x + points[2].x) / 2
|
|
916
|
+
midY = (points[1].y + points[2].y) / 2
|
|
917
|
+
} else if (points.length === 3) {
|
|
918
|
+
// Mixed H-V or V-H: put label in the middle of the longer segment
|
|
919
|
+
const d1 = Math.abs(points[1].x - points[0].x) + Math.abs(points[1].y - points[0].y)
|
|
920
|
+
const d2 = Math.abs(points[2].x - points[1].x) + Math.abs(points[2].y - points[1].y)
|
|
921
|
+
if (d1 > d2) {
|
|
922
|
+
midX = (points[0].x + points[1].x) / 2
|
|
923
|
+
midY = (points[0].y + points[1].y) / 2
|
|
924
|
+
} else {
|
|
925
|
+
midX = (points[1].x + points[2].x) / 2
|
|
926
|
+
midY = (points[1].y + points[2].y) / 2
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
ctx.beginPath()
|
|
931
|
+
ctx.moveTo(points[0].x, points[0].y)
|
|
932
|
+
|
|
933
|
+
for (let i = 1; i < points.length; i++) {
|
|
934
|
+
const curr = points[i]
|
|
935
|
+
const prev = points[i - 1]
|
|
936
|
+
const next = points[i + 1]
|
|
937
|
+
|
|
938
|
+
if (borderRadius > 0 && next) {
|
|
939
|
+
// Draw line to start of corner
|
|
940
|
+
const dPrevX = curr.x - prev.x
|
|
941
|
+
const dPrevY = curr.y - prev.y
|
|
942
|
+
const dPrevLen = Math.sqrt(dPrevX * dPrevX + dPrevY * dPrevY)
|
|
943
|
+
const r = Math.min(borderRadius, dPrevLen / 2)
|
|
944
|
+
|
|
945
|
+
ctx.lineTo(curr.x - (dPrevX / dPrevLen) * r, curr.y - (dPrevY / dPrevLen) * r)
|
|
946
|
+
|
|
947
|
+
// Draw arc
|
|
948
|
+
const dNextX = next.x - curr.x
|
|
949
|
+
const dNextY = next.y - curr.y
|
|
950
|
+
const dNextLen = Math.sqrt(dNextX * dNextX + dNextY * dNextY)
|
|
951
|
+
const rNext = Math.min(borderRadius, dNextLen / 2)
|
|
952
|
+
|
|
953
|
+
ctx.arcTo(curr.x, curr.y, curr.x + (dNextX / dNextLen) * rNext, curr.y + (dNextY / dNextLen) * rNext, r)
|
|
954
|
+
} else {
|
|
955
|
+
ctx.lineTo(curr.x, curr.y)
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
ctx.stroke()
|
|
959
|
+
|
|
960
|
+
// Arrows for step/smoothstep should align with final segment
|
|
961
|
+
const last = points[points.length - 1]
|
|
962
|
+
const prev = points[points.length - 2]
|
|
963
|
+
finalAngleT = Math.atan2(last.y - prev.y, last.x - prev.x)
|
|
964
|
+
|
|
965
|
+
const first = points[0]
|
|
966
|
+
const firstNext = points[1]
|
|
967
|
+
finalAngleS = Math.atan2(first.y - firstNext.y, first.x - firstNext.x)
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// ── Arrow heads ───────────────────────────────────────────────
|
|
971
|
+
const visualTargetScreenW = effWTarget * zoom
|
|
972
|
+
const visualSourceScreenW = effWSource * zoom
|
|
973
|
+
|
|
974
|
+
// Scale arrow with node size, but cap it at 14px
|
|
975
|
+
// And hide if node is too small
|
|
976
|
+
const ARROW_SIZE_BASE = 10
|
|
977
|
+
const MIN_NODE_W_FOR_ARROW = 120
|
|
978
|
+
|
|
979
|
+
if (dir === 'forward' || dir === 'both' || dir === 'bidirectional') {
|
|
980
|
+
if (visualTargetScreenW > MIN_NODE_W_FOR_ARROW) {
|
|
981
|
+
const arrowScreenSize = Math.min(ARROW_SIZE_BASE, visualTargetScreenW * 0.2)
|
|
982
|
+
drawArrowHead(ctx, tH.x, tH.y, finalAngleT, arrowScreenSize / zoom, accent)
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
if (dir === 'backward' || dir === 'both' || dir === 'bidirectional') {
|
|
986
|
+
if (visualSourceScreenW > MIN_NODE_W_FOR_ARROW) {
|
|
987
|
+
const arrowScreenSize = Math.min(ARROW_SIZE_BASE, visualSourceScreenW * 0.2)
|
|
988
|
+
drawArrowHead(ctx, sH.x, sH.y, finalAngleS, arrowScreenSize / zoom, accent)
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// ── Edge Label ───────────────────────────────────────────
|
|
993
|
+
if (edge.label && zoom * 11 > 4) {
|
|
994
|
+
const fontSize = 11 / zoom
|
|
995
|
+
ctx.font = `${fontSize}px Inter, system-ui, sans-serif`
|
|
996
|
+
const textMetrics = ctx.measureText(edge.label)
|
|
997
|
+
const textW = textMetrics.width
|
|
998
|
+
const textH = fontSize
|
|
999
|
+
const labelPos = pickEdgeLabelPosition(
|
|
1000
|
+
ctx.getTransform(),
|
|
1001
|
+
midX,
|
|
1002
|
+
midY,
|
|
1003
|
+
textW,
|
|
1004
|
+
textH,
|
|
1005
|
+
tH.x - sH.x,
|
|
1006
|
+
tH.y - sH.y,
|
|
1007
|
+
occupiedLabelRects,
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
ctx.save()
|
|
1011
|
+
ctx.globalAlpha = alpha * 0.95
|
|
1012
|
+
ctx.fillStyle = labelBg
|
|
1013
|
+
const px = 4 / zoom, py = 2 / zoom
|
|
1014
|
+
ctx.beginPath()
|
|
1015
|
+
ctx.roundRect(labelPos.x - textW / 2 - px, labelPos.y - textH / 2 - py, textW + px * 2, textH + py * 2, 4 / zoom)
|
|
1016
|
+
ctx.fill()
|
|
1017
|
+
ctx.restore()
|
|
1018
|
+
|
|
1019
|
+
ctx.fillStyle = accent
|
|
1020
|
+
ctx.textAlign = 'center'
|
|
1021
|
+
ctx.textBaseline = 'middle'
|
|
1022
|
+
ctx.fillText(edge.label, labelPos.x, labelPos.y)
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
ctx.restore()
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// ── Diagram group label ────────────────────────────────────────────
|
|
1031
|
+
|
|
1032
|
+
function drawGroupLabel(
|
|
1033
|
+
ctx: CanvasRenderingContext2D,
|
|
1034
|
+
group: DiagramGroupLayout,
|
|
1035
|
+
view: ZUIViewState,
|
|
1036
|
+
canvasW: number,
|
|
1037
|
+
canvasH: number,
|
|
1038
|
+
accent: string,
|
|
1039
|
+
): void {
|
|
1040
|
+
const screenW = group.worldW * view.zoom
|
|
1041
|
+
if (screenW < 30) return
|
|
1042
|
+
|
|
1043
|
+
const fontSize = clamp(13 / view.zoom, 3 / view.zoom, 24 / view.zoom)
|
|
1044
|
+
const labelX = group.worldX + group.diagramX + group.diagramW / 2
|
|
1045
|
+
const labelY = group.worldY + group.diagramY - 22 / view.zoom
|
|
1046
|
+
|
|
1047
|
+
// Ensure label is within viewport
|
|
1048
|
+
const screenY = labelY * view.zoom + view.y
|
|
1049
|
+
if (screenY < -20 || screenY > canvasH + 20) return
|
|
1050
|
+
|
|
1051
|
+
ctx.save()
|
|
1052
|
+
const levelText = group.levelLabel || `Level ${group.level}`
|
|
1053
|
+
|
|
1054
|
+
// ── Level indicator (e.g. "Level 1" or "System Context")
|
|
1055
|
+
const levelFontSize = fontSize * 0.8
|
|
1056
|
+
ctx.font = `600 ${levelFontSize}px Inter, system-ui, sans-serif`
|
|
1057
|
+
ctx.fillStyle = accent
|
|
1058
|
+
ctx.globalAlpha = 0.8
|
|
1059
|
+
ctx.textAlign = 'center'
|
|
1060
|
+
ctx.textBaseline = 'bottom'
|
|
1061
|
+
ctx.fillText(levelText.toUpperCase(), labelX, group.worldY + group.diagramY - 30 / view.zoom)
|
|
1062
|
+
|
|
1063
|
+
// ── Diagram Name
|
|
1064
|
+
ctx.font = `600 ${fontSize}px Inter, system-ui, sans-serif`
|
|
1065
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.95)'
|
|
1066
|
+
const nameText = group.nodes.length === 0 ? `${group.label} (Empty)` : group.label
|
|
1067
|
+
ctx.fillText(nameText, labelX, group.worldY + group.diagramY - 10 / view.zoom)
|
|
1068
|
+
|
|
1069
|
+
// ── Empty State Indicator inside the box
|
|
1070
|
+
if (group.nodes.length === 0 && view.zoom * group.diagramW > 100) {
|
|
1071
|
+
ctx.save()
|
|
1072
|
+
ctx.globalAlpha = 0.3
|
|
1073
|
+
ctx.font = `${fontSize * 0.7}px Inter, system-ui, sans-serif`
|
|
1074
|
+
ctx.textAlign = 'center'
|
|
1075
|
+
ctx.textBaseline = 'middle'
|
|
1076
|
+
ctx.fillText('This diagram has no elements.', labelX, group.worldY + group.diagramY + group.diagramH / 2)
|
|
1077
|
+
ctx.restore()
|
|
1078
|
+
}
|
|
1079
|
+
ctx.restore()
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
// ── Public: render one frame ───────────────────────────────────────
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Render a complete frame onto `ctx`.
|
|
1087
|
+
* Call this from a `requestAnimationFrame` loop.
|
|
1088
|
+
* The caller must set `ctx.setTransform(dpr,0,0,dpr,0,0)` before calling;
|
|
1089
|
+
* `canvasW/canvasH` are CSS-pixel dimensions (the transform handles HiDPI).
|
|
1090
|
+
*/
|
|
1091
|
+
export function renderFrame(
|
|
1092
|
+
ctx: CanvasRenderingContext2D,
|
|
1093
|
+
groups: DiagramGroupLayout[],
|
|
1094
|
+
view: ZUIViewState,
|
|
1095
|
+
canvasW: number,
|
|
1096
|
+
canvasH: number,
|
|
1097
|
+
): ScreenRect[] {
|
|
1098
|
+
// Read user-customisable CSS vars once per frame
|
|
1099
|
+
const canvasBg = readCSSVar('--bg-main', '#0d121e')
|
|
1100
|
+
const nodeBg = readCSSVar('--bg-element', '#2d3748')
|
|
1101
|
+
const accent = readCSSVar('--accent', '#63b3ed')
|
|
1102
|
+
const labelBg = readCSSVar('--chakra-colors-gray-900', '#171923')
|
|
1103
|
+
|
|
1104
|
+
ctx.clearRect(0, 0, canvasW, canvasH)
|
|
1105
|
+
|
|
1106
|
+
// Background matches the app's --bg-main
|
|
1107
|
+
ctx.fillStyle = canvasBg
|
|
1108
|
+
ctx.fillRect(0, 0, canvasW, canvasH)
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
// Apply world transform
|
|
1112
|
+
ctx.save()
|
|
1113
|
+
ctx.translate(view.x, view.y)
|
|
1114
|
+
ctx.scale(view.zoom, view.zoom)
|
|
1115
|
+
|
|
1116
|
+
const thresholds = getExpandThresholds(canvasW)
|
|
1117
|
+
const occupiedLabelRects: ScreenRect[] = []
|
|
1118
|
+
|
|
1119
|
+
for (const group of groups) {
|
|
1120
|
+
if (!isVisible(group.worldX, group.worldY, group.worldW, group.worldH, view, canvasW, canvasH)) {
|
|
1121
|
+
continue
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
drawGroupLabel(ctx, group, view, canvasW, canvasH, accent)
|
|
1125
|
+
|
|
1126
|
+
// ── Group box (diagram elements container) ──────────────────────────
|
|
1127
|
+
const borderAlpha = clamp(0.5 - view.zoom * 0.05, 0.15, 0.5)
|
|
1128
|
+
|
|
1129
|
+
ctx.save()
|
|
1130
|
+
ctx.globalAlpha = borderAlpha
|
|
1131
|
+
ctx.strokeStyle = accent
|
|
1132
|
+
ctx.lineWidth = 2 / view.zoom
|
|
1133
|
+
ctx.setLineDash([2, 2])
|
|
1134
|
+
// Only draw the border around the diagram part (not portals)
|
|
1135
|
+
ctx.strokeRect(group.worldX + group.diagramX, group.worldY + group.diagramY, group.diagramW, group.diagramH)
|
|
1136
|
+
ctx.setLineDash([])
|
|
1137
|
+
ctx.restore()
|
|
1138
|
+
|
|
1139
|
+
// ── Squiggly edges to portal nodes ────────────────────────────────
|
|
1140
|
+
ctx.save()
|
|
1141
|
+
ctx.strokeStyle = accent
|
|
1142
|
+
ctx.setLineDash([])
|
|
1143
|
+
ctx.lineWidth = 2 / view.zoom
|
|
1144
|
+
ctx.globalAlpha = 0.6
|
|
1145
|
+
for (const node of group.nodes) {
|
|
1146
|
+
if (node.isPortal) {
|
|
1147
|
+
// Draw squiggle/dash from diagram box boundary to portal box boundary
|
|
1148
|
+
const cx = group.worldX + group.diagramX + group.diagramW / 2
|
|
1149
|
+
const cy = group.worldY + group.diagramY + group.diagramH / 2
|
|
1150
|
+
const px = node.worldX + node.worldW / 2
|
|
1151
|
+
const py = node.worldY + node.worldH / 2
|
|
1152
|
+
|
|
1153
|
+
const dx = px - cx
|
|
1154
|
+
const dy = py - cy
|
|
1155
|
+
|
|
1156
|
+
const getBBoxIntersection = (boxW: number, boxH: number, targetDX: number, targetDY: number) => {
|
|
1157
|
+
const hw = boxW / 2 + 10 // pad
|
|
1158
|
+
const hh = boxH / 2 + 10 // pad
|
|
1159
|
+
if (Math.abs(targetDX * hh) > Math.abs(targetDY * hw)) {
|
|
1160
|
+
return { x: Math.sign(targetDX) * hw, y: targetDY * (hw / Math.abs(targetDX)) }
|
|
1161
|
+
} else {
|
|
1162
|
+
return { x: targetDX * (hh / Math.abs(targetDY)), y: Math.sign(targetDY) * hh }
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const start = getBBoxIntersection(group.diagramW, group.diagramH, dx, dy)
|
|
1167
|
+
const end = getBBoxIntersection(node.worldW, node.worldH, -dx, -dy)
|
|
1168
|
+
|
|
1169
|
+
drawSquigglyLine(ctx, cx + start.x, cy + start.y, px + end.x, py + end.y, view.zoom)
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
ctx.restore()
|
|
1173
|
+
|
|
1174
|
+
// Edges in this group
|
|
1175
|
+
drawEdges(ctx, group.nodes, 0.7, view.zoom, thresholds, accent, labelBg, occupiedLabelRects)
|
|
1176
|
+
|
|
1177
|
+
// Nodes in this group
|
|
1178
|
+
for (const node of group.nodes) {
|
|
1179
|
+
if (!isVisible(node.worldX, node.worldY, node.worldW, node.worldH, view, canvasW, canvasH)) {
|
|
1180
|
+
continue
|
|
1181
|
+
}
|
|
1182
|
+
const screenW = node.worldW * view.zoom
|
|
1183
|
+
drawNode(ctx, node, screenW, thresholds, 1, view.zoom, nodeBg, canvasBg, view, canvasW, canvasH, accent, labelBg, node.worldX, node.worldY, 1, occupiedLabelRects)
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
ctx.restore()
|
|
1188
|
+
return occupiedLabelRects
|
|
1189
|
+
}
|