@carlonicora/nextjs-jsonapi 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/package.json +2 -1
  2. package/src/atoms/index.ts +1 -0
  3. package/src/atoms/recentPagesAtom.ts +10 -0
  4. package/src/client/context/JsonApiContext.ts +61 -0
  5. package/src/client/context/JsonApiProvider.tsx +27 -0
  6. package/src/client/context/index.ts +2 -0
  7. package/src/client/hooks/index.ts +3 -0
  8. package/src/client/hooks/useJsonApiGet.ts +188 -0
  9. package/src/client/hooks/useJsonApiMutation.ts +193 -0
  10. package/src/client/hooks/useRehydration.ts +47 -0
  11. package/src/client/index.ts +11 -0
  12. package/src/client/request.ts +97 -0
  13. package/src/client/token.ts +10 -0
  14. package/src/components/containers/PageContainer.tsx +15 -0
  15. package/src/components/containers/ReactMarkdownContainer.tsx +119 -0
  16. package/src/components/containers/TabsContainer.tsx +93 -0
  17. package/src/components/containers/index.ts +3 -0
  18. package/src/components/contents/AttributeElement.tsx +20 -0
  19. package/src/components/contents/index.ts +1 -0
  20. package/src/components/details/AllowedUsersDetails.tsx +23 -0
  21. package/src/components/details/index.ts +1 -0
  22. package/src/components/editors/BlockNoteDiffInlineContent.tsx +152 -0
  23. package/src/components/editors/BlockNoteEditor.tsx +404 -0
  24. package/src/components/editors/BlockNoteEditorContainer.tsx +13 -0
  25. package/src/components/editors/BlockNoteEditorFormattingToolbar.tsx +38 -0
  26. package/src/components/editors/index.ts +1 -0
  27. package/src/components/errors/ErrorDetails.tsx +41 -0
  28. package/src/components/errors/errorToast.ts +9 -0
  29. package/src/components/errors/index.ts +2 -0
  30. package/src/components/forms/CommonAssociationForm.tsx +162 -0
  31. package/src/components/forms/CommonDeleter.tsx +94 -0
  32. package/src/components/forms/CommonEditorButtons.tsx +30 -0
  33. package/src/components/forms/CommonEditorHeader.tsx +35 -0
  34. package/src/components/forms/CommonEditorTrigger.tsx +26 -0
  35. package/src/components/forms/DatePickerPopover.tsx +219 -0
  36. package/src/components/forms/DateRangeSelector.tsx +110 -0
  37. package/src/components/forms/FileUploader.tsx +324 -0
  38. package/src/components/forms/FormCheckbox.tsx +66 -0
  39. package/src/components/forms/FormContainerGeneric.tsx +39 -0
  40. package/src/components/forms/FormDate.tsx +247 -0
  41. package/src/components/forms/FormDateTime.tsx +231 -0
  42. package/src/components/forms/FormInput.tsx +110 -0
  43. package/src/components/forms/FormPassword.tsx +54 -0
  44. package/src/components/forms/FormPlaceAutocomplete.tsx +286 -0
  45. package/src/components/forms/FormSelect.tsx +72 -0
  46. package/src/components/forms/FormSlider.tsx +51 -0
  47. package/src/components/forms/FormSwitch.tsx +25 -0
  48. package/src/components/forms/FormTextarea.tsx +44 -0
  49. package/src/components/forms/MultiFileUploader.tsx +107 -0
  50. package/src/components/forms/PasswordInput.tsx +47 -0
  51. package/src/components/forms/index.ts +21 -0
  52. package/src/components/index.ts +11 -0
  53. package/src/components/navigations/Breadcrumb.tsx +83 -0
  54. package/src/components/navigations/ContentTitle.tsx +39 -0
  55. package/src/components/navigations/Header.tsx +27 -0
  56. package/src/components/navigations/ModeToggleSwitch.tsx +25 -0
  57. package/src/components/navigations/PageSection.tsx +64 -0
  58. package/src/components/navigations/RecentPagesNavigator.tsx +52 -0
  59. package/src/components/navigations/index.ts +6 -0
  60. package/src/components/pages/PageContainerContentDetails.tsx +76 -0
  61. package/src/components/pages/PageContentContainer.tsx +31 -0
  62. package/src/components/pages/index.ts +2 -0
  63. package/src/components/tables/ContentListTable.tsx +165 -0
  64. package/src/components/tables/ContentTableSearch.tsx +105 -0
  65. package/src/components/tables/cells/cell.component.tsx +18 -0
  66. package/src/components/tables/cells/cell.date.tsx +16 -0
  67. package/src/components/tables/cells/cell.id.tsx +27 -0
  68. package/src/components/tables/cells/cell.link.tsx +18 -0
  69. package/src/components/tables/cells/cell.text.tsx +12 -0
  70. package/src/components/tables/cells/cell.url.tsx +13 -0
  71. package/src/components/tables/cells/index.ts +5 -0
  72. package/src/components/tables/index.ts +3 -0
  73. package/src/contexts/SharedContext.tsx +35 -0
  74. package/src/contexts/index.ts +2 -0
  75. package/src/core/abstracts/AbstractApiData.ts +138 -0
  76. package/src/core/abstracts/AbstractService.ts +263 -0
  77. package/src/core/abstracts/index.ts +2 -0
  78. package/src/core/endpoint/EndpointCreator.ts +97 -0
  79. package/src/core/endpoint/index.ts +1 -0
  80. package/src/core/factories/JsonApiDataFactory.ts +12 -0
  81. package/src/core/factories/RehydrationFactory.ts +30 -0
  82. package/src/core/factories/index.ts +2 -0
  83. package/src/core/fields/FieldSelector.ts +15 -0
  84. package/src/core/fields/index.ts +1 -0
  85. package/src/core/index.ts +20 -0
  86. package/src/core/interfaces/ApiData.ts +8 -0
  87. package/src/core/interfaces/ApiDataInterface.ts +15 -0
  88. package/src/core/interfaces/ApiRequestDataTypeInterface.ts +14 -0
  89. package/src/core/interfaces/ApiResponseInterface.ts +17 -0
  90. package/src/core/interfaces/JsonApiHydratedDataInterface.ts +5 -0
  91. package/src/core/interfaces/index.ts +5 -0
  92. package/src/core/registry/DataClassRegistry.ts +51 -0
  93. package/src/core/registry/ModuleRegistrar.ts +43 -0
  94. package/src/core/registry/ModuleRegistry.ts +64 -0
  95. package/src/core/registry/index.ts +3 -0
  96. package/src/core/utils/index.ts +2 -0
  97. package/src/core/utils/rehydrate.ts +24 -0
  98. package/src/core/utils/translateResponse.ts +125 -0
  99. package/src/features/auth/auth.module.ts +9 -0
  100. package/src/features/auth/config.ts +57 -0
  101. package/src/features/auth/data/auth.interface.ts +31 -0
  102. package/src/features/auth/data/auth.service.ts +159 -0
  103. package/src/features/auth/data/auth.ts +54 -0
  104. package/src/features/auth/data/index.ts +3 -0
  105. package/src/features/auth/index.ts +3 -0
  106. package/src/features/company/company.module.ts +10 -0
  107. package/src/features/company/data/company.fields.ts +6 -0
  108. package/src/features/company/data/company.interface.ts +28 -0
  109. package/src/features/company/data/company.service.ts +73 -0
  110. package/src/features/company/data/company.ts +104 -0
  111. package/src/features/company/data/index.ts +4 -0
  112. package/src/features/company/index.ts +2 -0
  113. package/src/features/content/content.module.ts +20 -0
  114. package/src/features/content/data/content.fields.ts +13 -0
  115. package/src/features/content/data/content.interface.ts +23 -0
  116. package/src/features/content/data/content.service.ts +75 -0
  117. package/src/features/content/data/content.ts +85 -0
  118. package/src/features/content/data/index.ts +4 -0
  119. package/src/features/content/index.ts +2 -0
  120. package/src/features/feature/components/forms/FormFeatures.tsx +149 -0
  121. package/src/features/feature/components/index.ts +1 -0
  122. package/src/features/feature/data/feature.interface.ts +9 -0
  123. package/src/features/feature/data/feature.service.ts +19 -0
  124. package/src/features/feature/data/feature.ts +33 -0
  125. package/src/features/feature/data/index.ts +3 -0
  126. package/src/features/feature/feature.module.ts +10 -0
  127. package/src/features/feature/index.ts +3 -0
  128. package/src/features/index.ts +12 -0
  129. package/src/features/module/data/index.ts +2 -0
  130. package/src/features/module/data/module.interface.ts +12 -0
  131. package/src/features/module/data/module.ts +42 -0
  132. package/src/features/module/index.ts +2 -0
  133. package/src/features/module/module.module.ts +10 -0
  134. package/src/features/notification/data/index.ts +4 -0
  135. package/src/features/notification/data/notification.fields.ts +8 -0
  136. package/src/features/notification/data/notification.interface.ts +14 -0
  137. package/src/features/notification/data/notification.service.ts +34 -0
  138. package/src/features/notification/data/notification.ts +51 -0
  139. package/src/features/notification/index.ts +2 -0
  140. package/src/features/notification/notification.module.ts +10 -0
  141. package/src/features/push/data/index.ts +3 -0
  142. package/src/features/push/data/push.interface.ts +8 -0
  143. package/src/features/push/data/push.service.ts +17 -0
  144. package/src/features/push/data/push.ts +18 -0
  145. package/src/features/push/index.ts +2 -0
  146. package/src/features/push/push.module.ts +10 -0
  147. package/src/features/role/data/index.ts +4 -0
  148. package/src/features/role/data/role.fields.ts +8 -0
  149. package/src/features/role/data/role.interface.ts +16 -0
  150. package/src/features/role/data/role.service.ts +117 -0
  151. package/src/features/role/data/role.ts +62 -0
  152. package/src/features/role/index.ts +2 -0
  153. package/src/features/role/role.module.ts +10 -0
  154. package/src/features/s3/data/index.ts +3 -0
  155. package/src/features/s3/data/s3.interface.ts +11 -0
  156. package/src/features/s3/data/s3.service.ts +30 -0
  157. package/src/features/s3/data/s3.ts +60 -0
  158. package/src/features/s3/index.ts +2 -0
  159. package/src/features/s3/s3.module.ts +10 -0
  160. package/src/features/search/index.ts +1 -0
  161. package/src/features/search/interfaces/index.ts +1 -0
  162. package/src/features/search/interfaces/search.result.interface.ts +3 -0
  163. package/src/features/user/author.module.ts +10 -0
  164. package/src/features/user/components/index.ts +2 -0
  165. package/src/features/user/components/lists/ContributorsList.tsx +41 -0
  166. package/src/features/user/components/lists/index.ts +1 -0
  167. package/src/features/user/components/widgets/UserAvatar.tsx +86 -0
  168. package/src/features/user/components/widgets/index.ts +1 -0
  169. package/src/features/user/contexts/CurrentUserContext.tsx +156 -0
  170. package/src/features/user/contexts/index.ts +1 -0
  171. package/src/features/user/data/index.ts +4 -0
  172. package/src/features/user/data/user.fields.ts +8 -0
  173. package/src/features/user/data/user.interface.ts +41 -0
  174. package/src/features/user/data/user.service.ts +246 -0
  175. package/src/features/user/data/user.ts +162 -0
  176. package/src/features/user/index.ts +4 -0
  177. package/src/features/user/user.module.ts +21 -0
  178. package/src/hooks/TableGeneratorRegistry.ts +53 -0
  179. package/src/hooks/index.ts +33 -0
  180. package/src/hooks/types.ts +35 -0
  181. package/src/hooks/url.rewriter.ts +22 -0
  182. package/src/hooks/useCustomD3Graph.tsx +705 -0
  183. package/src/hooks/useDataListRetriever.ts +349 -0
  184. package/src/hooks/useDebounce.ts +33 -0
  185. package/src/hooks/usePageUrlGenerator.ts +50 -0
  186. package/src/hooks/useTableGenerator.ts +16 -0
  187. package/src/i18n/config.ts +73 -0
  188. package/src/i18n/index.ts +18 -0
  189. package/src/index.ts +16 -0
  190. package/src/interfaces/breadcrumb.item.data.interface.ts +4 -0
  191. package/src/interfaces/d3.link.interface.ts +7 -0
  192. package/src/interfaces/d3.node.interface.ts +12 -0
  193. package/src/interfaces/index.ts +3 -0
  194. package/src/permissions/check.ts +127 -0
  195. package/src/permissions/index.ts +2 -0
  196. package/src/permissions/types.ts +109 -0
  197. package/src/roles/config.ts +46 -0
  198. package/src/roles/index.ts +1 -0
  199. package/src/server/cache.ts +28 -0
  200. package/src/server/index.ts +3 -0
  201. package/src/server/request.ts +113 -0
  202. package/src/server/token.ts +10 -0
  203. package/src/shadcnui/custom/kanban.tsx +1001 -0
  204. package/src/shadcnui/custom/link.tsx +18 -0
  205. package/src/shadcnui/custom/multi-select.tsx +382 -0
  206. package/src/shadcnui/index.ts +49 -0
  207. package/src/shadcnui/ui/accordion.tsx +52 -0
  208. package/src/shadcnui/ui/alert-dialog.tsx +141 -0
  209. package/src/shadcnui/ui/alert.tsx +43 -0
  210. package/src/shadcnui/ui/avatar.tsx +50 -0
  211. package/src/shadcnui/ui/badge.tsx +40 -0
  212. package/src/shadcnui/ui/breadcrumb.tsx +115 -0
  213. package/src/shadcnui/ui/button.tsx +51 -0
  214. package/src/shadcnui/ui/calendar.tsx +73 -0
  215. package/src/shadcnui/ui/card.tsx +43 -0
  216. package/src/shadcnui/ui/carousel.tsx +225 -0
  217. package/src/shadcnui/ui/chart.tsx +320 -0
  218. package/src/shadcnui/ui/checkbox.tsx +29 -0
  219. package/src/shadcnui/ui/collapsible.tsx +11 -0
  220. package/src/shadcnui/ui/command.tsx +155 -0
  221. package/src/shadcnui/ui/context-menu.tsx +179 -0
  222. package/src/shadcnui/ui/dialog.tsx +96 -0
  223. package/src/shadcnui/ui/drawer.tsx +89 -0
  224. package/src/shadcnui/ui/dropdown-menu.tsx +205 -0
  225. package/src/shadcnui/ui/form.tsx +138 -0
  226. package/src/shadcnui/ui/hover-card.tsx +29 -0
  227. package/src/shadcnui/ui/input.tsx +21 -0
  228. package/src/shadcnui/ui/label.tsx +26 -0
  229. package/src/shadcnui/ui/navigation-menu.tsx +168 -0
  230. package/src/shadcnui/ui/popover.tsx +33 -0
  231. package/src/shadcnui/ui/progress.tsx +25 -0
  232. package/src/shadcnui/ui/radio-group.tsx +37 -0
  233. package/src/shadcnui/ui/resizable.tsx +47 -0
  234. package/src/shadcnui/ui/scroll-area.tsx +40 -0
  235. package/src/shadcnui/ui/select.tsx +164 -0
  236. package/src/shadcnui/ui/separator.tsx +28 -0
  237. package/src/shadcnui/ui/sheet.tsx +139 -0
  238. package/src/shadcnui/ui/sidebar.tsx +677 -0
  239. package/src/shadcnui/ui/skeleton.tsx +13 -0
  240. package/src/shadcnui/ui/slider.tsx +25 -0
  241. package/src/shadcnui/ui/sonner.tsx +25 -0
  242. package/src/shadcnui/ui/switch.tsx +31 -0
  243. package/src/shadcnui/ui/table.tsx +120 -0
  244. package/src/shadcnui/ui/tabs.tsx +55 -0
  245. package/src/shadcnui/ui/textarea.tsx +24 -0
  246. package/src/shadcnui/ui/toggle.tsx +39 -0
  247. package/src/shadcnui/ui/tooltip.tsx +61 -0
  248. package/src/unified/JsonApiRequest.ts +325 -0
  249. package/src/unified/index.ts +1 -0
  250. package/src/utils/blocknote-diff.util.ts +815 -0
  251. package/src/utils/blocknote-word-diff-renderer.util.ts +413 -0
  252. package/src/utils/cn.ts +6 -0
  253. package/src/utils/compose-refs.ts +61 -0
  254. package/src/utils/date-formatter.ts +53 -0
  255. package/src/utils/exists.ts +7 -0
  256. package/src/utils/index.ts +15 -0
  257. package/src/utils/schemas/entity.object.schema.ts +8 -0
  258. package/src/utils/schemas/index.ts +2 -0
  259. package/src/utils/schemas/user.object.schema.ts +9 -0
  260. package/src/utils/table-options.ts +67 -0
  261. package/src/utils/use-mobile.tsx +21 -0
@@ -0,0 +1,705 @@
1
+ import * as d3 from "d3";
2
+ import { Loader2 } from "lucide-react";
3
+ import { useCallback, useEffect, useMemo, useRef } from "react";
4
+ import { renderToStaticMarkup } from "react-dom/server";
5
+ import { D3Link, D3Node } from "../interfaces";
6
+
7
+ /**
8
+ * Custom hook for D3 graph visualization with larger circles and more interactive features
9
+ */
10
+ export function useCustomD3Graph(
11
+ nodes: D3Node[],
12
+ links: D3Link[],
13
+ onNodeClick: (nodeId: string) => void,
14
+ visibleNodeIds?: Set<string>,
15
+ loadingNodeIds?: Set<string>,
16
+ containerKey?: string | number,
17
+ ) {
18
+ const svgRef = useRef<SVGSVGElement | null>(null);
19
+ const zoomRef = useRef<d3.ZoomTransform | null>(null);
20
+ const zoomBehaviorRef = useRef<d3.ZoomBehavior<SVGSVGElement, unknown> | null>(null);
21
+ const nodePositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map());
22
+ const prevContainerKeyRef = useRef<string | number | undefined>(containerKey);
23
+
24
+ const zoomToNode = useCallback(
25
+ (nodeId: string, childIds: string[] = []) => {
26
+ if (!svgRef.current || !zoomBehaviorRef.current) return;
27
+
28
+ const svg = d3.select(svgRef.current);
29
+ const zoom = zoomBehaviorRef.current;
30
+
31
+ const targetNode = nodes.find((n) => n.id === nodeId);
32
+ const childNodes = nodes.filter((n) => childIds.includes(n.id));
33
+
34
+ if (!targetNode) return;
35
+
36
+ const positions: { x: number; y: number }[] = [];
37
+
38
+ if (
39
+ targetNode.fx !== undefined &&
40
+ targetNode.fy !== undefined &&
41
+ targetNode.fx !== null &&
42
+ targetNode.fy !== null
43
+ ) {
44
+ positions.push({ x: targetNode.fx, y: targetNode.fy });
45
+ } else if (
46
+ targetNode.x !== undefined &&
47
+ targetNode.y !== undefined &&
48
+ targetNode.x !== null &&
49
+ targetNode.y !== null
50
+ ) {
51
+ positions.push({ x: targetNode.x, y: targetNode.y });
52
+ }
53
+
54
+ childNodes.forEach((child) => {
55
+ if (child.fx !== undefined && child.fy !== undefined && child.fx !== null && child.fy !== null) {
56
+ positions.push({ x: child.fx, y: child.fy });
57
+ } else if (child.x !== undefined && child.y !== undefined && child.x !== null && child.y !== null) {
58
+ positions.push({ x: child.x, y: child.y });
59
+ }
60
+ });
61
+
62
+ if (positions.length === 0) return;
63
+
64
+ const bounds = {
65
+ xMin: Math.min(...positions.map((p) => p.x)),
66
+ xMax: Math.max(...positions.map((p) => p.x)),
67
+ yMin: Math.min(...positions.map((p) => p.y)),
68
+ yMax: Math.max(...positions.map((p) => p.y)),
69
+ };
70
+
71
+ const padding = 150;
72
+ bounds.xMin -= padding;
73
+ bounds.xMax += padding;
74
+ bounds.yMin -= padding;
75
+ bounds.yMax += padding;
76
+
77
+ const width = svgRef.current.clientWidth;
78
+ const height = svgRef.current.clientHeight;
79
+
80
+ const contentWidth = bounds.xMax - bounds.xMin;
81
+ const contentHeight = bounds.yMax - bounds.yMin;
82
+
83
+ const scaleX = width / contentWidth;
84
+ const scaleY = height / contentHeight;
85
+
86
+ let scale = Math.min(scaleX, scaleY);
87
+
88
+ scale = Math.min(Math.max(scale, 0.2), 1.5);
89
+
90
+ const centerX = (bounds.xMin + bounds.xMax) / 2;
91
+ const centerY = (bounds.yMin + bounds.yMax) / 2;
92
+
93
+ const translateX = width / 2 - centerX * scale;
94
+ const translateY = height / 2 - centerY * scale;
95
+
96
+ svg
97
+ .transition()
98
+ .duration(750)
99
+ .call(zoom.transform, d3.zoomIdentity.translate(translateX, translateY).scale(scale));
100
+ },
101
+ [nodes],
102
+ );
103
+
104
+ const colorScale = useMemo(() => {
105
+ const accentColor = "var(--accent)";
106
+
107
+ // Define unified color for all content types
108
+ const contentColor = "hsl(30, 80%, 55%)"; // Orange for all content
109
+
110
+ const groupTypes = new Set<string>();
111
+ nodes.forEach((node) => {
112
+ groupTypes.add(node.instanceType);
113
+ });
114
+
115
+ const typeColorMap = new Map<string, string>();
116
+
117
+ Array.from(groupTypes).forEach((type, index) => {
118
+ if (type === nodes[0]?.instanceType) {
119
+ // Root node
120
+ typeColorMap.set(type, accentColor);
121
+ } else if (type === "documents" || type === "articles" || type === "hyperlinks") {
122
+ // All content types get the same orange color
123
+ typeColorMap.set(type, contentColor);
124
+ } else {
125
+ // Topics, Expertises, etc. - use golden angle
126
+ const hueShift = (index * 137.508) % 360;
127
+ typeColorMap.set(type, `hsl(${hueShift}, 32%, 52%)`);
128
+ }
129
+ });
130
+
131
+ return typeColorMap;
132
+ }, [nodes]);
133
+
134
+ const washOutColor = useCallback((color: string): string => {
135
+ // Parse HSL color and make it lighter and more desaturated
136
+ const hslMatch = color.match(/hsl\((\d+\.?\d*),\s*(\d+\.?\d*)%,\s*(\d+\.?\d*)%\)/);
137
+ if (hslMatch) {
138
+ const hue = parseFloat(hslMatch[1]);
139
+
140
+ // Reduce saturation to 15% and increase lightness to 80%
141
+ return `hsl(${hue}, 15%, 80%)`;
142
+ }
143
+
144
+ // For var(--accent), return a lighter version
145
+ if (color.includes("var(--accent)")) {
146
+ return "hsl(0, 0%, 80%)"; // Light gray for washed out accent
147
+ }
148
+
149
+ // Fallback
150
+ return "hsl(0, 0%, 80%)";
151
+ }, []);
152
+
153
+ const getNodeColor = useCallback(
154
+ (node: D3Node) => {
155
+ const baseColor = colorScale.get(node.instanceType) || "gray";
156
+ if (node.washedOut) {
157
+ return washOutColor(baseColor);
158
+ }
159
+ return baseColor;
160
+ },
161
+ [colorScale, washOutColor],
162
+ );
163
+
164
+ useEffect(() => {
165
+ if (!nodes.length || !svgRef.current) return;
166
+
167
+ const visibleNodes = visibleNodeIds
168
+ ? nodes.filter((node) => visibleNodeIds.has(node.id))
169
+ : nodes.filter((node) => node.visible !== false);
170
+
171
+ const visibleNodeIdSet = new Set(visibleNodes.map((node) => node.id));
172
+ const visibleLinks = links.filter((link) => {
173
+ const sourceId = typeof link.source === "string" ? link.source : link.source.id;
174
+ const targetId = typeof link.target === "string" ? link.target : link.target.id;
175
+ return visibleNodeIdSet.has(sourceId) && visibleNodeIdSet.has(targetId);
176
+ });
177
+
178
+ const svg = d3.select<SVGSVGElement, unknown>(svgRef.current);
179
+ svg.selectAll("*").remove();
180
+
181
+ const container = svgRef.current?.parentElement;
182
+ if (!container) return;
183
+
184
+ const width = container.clientWidth;
185
+ const height = container.clientHeight;
186
+
187
+ svg.attr("width", width).attr("height", height).attr("viewBox", `0 0 ${width} ${height}`);
188
+
189
+ const graphGroup = svg.append("g").attr("class", "graph-content");
190
+
191
+ const zoom = d3
192
+ .zoom<SVGSVGElement, unknown>()
193
+ .scaleExtent([0.1, 4])
194
+ .on("zoom", (event) => {
195
+ const transform = event.transform;
196
+ graphGroup.attr("transform", transform.toString());
197
+ zoomRef.current = transform;
198
+ });
199
+
200
+ zoomBehaviorRef.current = zoom;
201
+
202
+ svg
203
+ .call(zoom as any)
204
+ .on("wheel.zoom", null)
205
+ .on("dblclick.zoom", null);
206
+
207
+ const nodeRadius = 40;
208
+
209
+ const childDistanceFromRoot = Math.min(width, height) * 0.4;
210
+ const grandchildDistanceFromChild = nodeRadius * 10;
211
+
212
+ const centralNodeId = nodes[0].id;
213
+
214
+ const nodeHierarchy = new Map<
215
+ string,
216
+ {
217
+ depth: number;
218
+ parent: string | null;
219
+ children: string[];
220
+ angle?: number;
221
+ x?: number;
222
+ y?: number;
223
+ }
224
+ >();
225
+
226
+ nodeHierarchy.set(centralNodeId, {
227
+ depth: 0,
228
+ parent: null,
229
+ children: [],
230
+ });
231
+
232
+ visibleLinks.forEach((link) => {
233
+ const sourceId = typeof link.source === "string" ? link.source : link.source.id;
234
+ const targetId = typeof link.target === "string" ? link.target : link.target.id;
235
+
236
+ if (sourceId === centralNodeId) {
237
+ nodeHierarchy.set(targetId, { depth: 1, parent: centralNodeId, children: [] });
238
+ const rootNode = nodeHierarchy.get(centralNodeId);
239
+ if (rootNode) {
240
+ rootNode.children.push(targetId);
241
+ }
242
+ }
243
+ });
244
+
245
+ visibleLinks.forEach((link) => {
246
+ const sourceId = typeof link.source === "string" ? link.source : link.source.id;
247
+ const targetId = typeof link.target === "string" ? link.target : link.target.id;
248
+
249
+ const sourceNode = nodeHierarchy.get(sourceId);
250
+ if (sourceNode && sourceNode.depth === 1 && !nodeHierarchy.has(targetId)) {
251
+ nodeHierarchy.set(targetId, { depth: 2, parent: sourceId, children: [] });
252
+ sourceNode.children.push(targetId);
253
+ }
254
+ });
255
+
256
+ const rootChildren = nodeHierarchy.get(centralNodeId)?.children || [];
257
+
258
+ const childAngleStep = (2 * Math.PI) / Math.max(rootChildren.length, 1);
259
+
260
+ rootChildren.forEach((childId, index) => {
261
+ const childNode = nodeHierarchy.get(childId);
262
+ if (childNode) {
263
+ const angle = index * childAngleStep;
264
+ childNode.angle = angle;
265
+ childNode.x = width / 2 + childDistanceFromRoot * Math.cos(angle);
266
+ childNode.y = height / 2 + childDistanceFromRoot * Math.sin(angle);
267
+ }
268
+ });
269
+
270
+ for (const [nodeId, node] of nodeHierarchy.entries()) {
271
+ if (node.depth === 1 && node.angle !== undefined && node.x !== undefined && node.y !== undefined) {
272
+ const childAngle = node.angle;
273
+ const childX = node.x;
274
+ const childY = node.y;
275
+ const grandchildren = node.children;
276
+
277
+ if (grandchildren.length === 0) continue;
278
+
279
+ const dirX = childX - width / 2;
280
+ const dirY = childY - height / 2;
281
+ const dirLength = Math.sqrt(dirX * dirX + dirY * dirY);
282
+
283
+ const normDirX = dirX / dirLength;
284
+ const normDirY = dirY / dirLength;
285
+
286
+ if (grandchildren.length === 1) {
287
+ const grandchildId = grandchildren[0];
288
+ const grandchildNode = nodeHierarchy.get(grandchildId);
289
+ if (grandchildNode) {
290
+ grandchildNode.x = childX + normDirX * grandchildDistanceFromChild;
291
+ grandchildNode.y = childY + normDirY * grandchildDistanceFromChild;
292
+ grandchildNode.angle = childAngle;
293
+ }
294
+ } else {
295
+ // Multiple grandchildren - arrange in semicircular arc
296
+ const numChildren = grandchildren.length;
297
+
298
+ // Dynamic arc span: scale from 60° (2 children) to 180° (7+ children)
299
+ const minArc = Math.PI / 3; // 60 degrees
300
+ const maxArc = Math.PI; // 180 degrees
301
+ const arcProgress = Math.min(1, (numChildren - 2) / 5);
302
+ const arcSpan = minArc + arcProgress * (maxArc - minArc);
303
+
304
+ // Calculate starting angle (center the arc around the radial direction)
305
+ const startAngle = childAngle - arcSpan / 2;
306
+
307
+ grandchildren.forEach((grandchildId, index) => {
308
+ const grandchildNode = nodeHierarchy.get(grandchildId);
309
+ if (!grandchildNode) return;
310
+
311
+ // Calculate angle for this child
312
+ const angleOffset = numChildren > 1 ? (index / (numChildren - 1)) * arcSpan : 0;
313
+ const angle = startAngle + angleOffset;
314
+
315
+ // Position at constant radius from parent
316
+ grandchildNode.x = childX + grandchildDistanceFromChild * Math.cos(angle);
317
+ grandchildNode.y = childY + grandchildDistanceFromChild * Math.sin(angle);
318
+ grandchildNode.angle = angle;
319
+ });
320
+ }
321
+ }
322
+ }
323
+
324
+ visibleNodes.forEach((node) => {
325
+ const savedPosition = nodePositionsRef.current.get(node.id);
326
+
327
+ if (savedPosition) {
328
+ node.fx = savedPosition.x;
329
+ node.fy = savedPosition.y;
330
+ } else {
331
+ const hierarchyNode = nodeHierarchy.get(node.id);
332
+ if (hierarchyNode && hierarchyNode.x !== undefined && hierarchyNode.y !== undefined) {
333
+ node.fx = hierarchyNode.x;
334
+ node.fy = hierarchyNode.y;
335
+ // Save the calculated position so it persists across re-renders
336
+ nodePositionsRef.current.set(node.id, { x: hierarchyNode.x, y: hierarchyNode.y });
337
+ } else if (node.id === centralNodeId) {
338
+ node.fx = width / 2;
339
+ node.fy = height / 2;
340
+ // Save the center position
341
+ nodePositionsRef.current.set(node.id, { x: width / 2, y: height / 2 });
342
+ }
343
+ }
344
+ });
345
+
346
+ const simulation = d3
347
+ .forceSimulation<D3Node>(visibleNodes)
348
+ .force(
349
+ "link",
350
+ d3
351
+ .forceLink<D3Node, D3Link>(visibleLinks)
352
+ .id((d) => d.id)
353
+ .distance(nodeRadius * 3)
354
+ .strength(0.1),
355
+ )
356
+ .force("charge", d3.forceManyBody().strength(-500).distanceMax(300))
357
+ .force("collision", d3.forceCollide().radius(nodeRadius * 1.2))
358
+ .force("center", d3.forceCenter(width / 2, height / 2).strength(0.1));
359
+
360
+ simulation.stop();
361
+ for (let i = 0; i < 100; i++) {
362
+ simulation.tick();
363
+ }
364
+
365
+ visibleNodes.forEach((node) => {
366
+ if (node.fx === undefined) {
367
+ node.fx = node.x;
368
+ node.fy = node.y;
369
+ }
370
+ });
371
+
372
+ const link = graphGroup
373
+ .append("g")
374
+ .attr("stroke", "#999")
375
+ .attr("stroke-opacity", 0.6)
376
+ .selectAll("line")
377
+ .data(visibleLinks)
378
+ .join("line")
379
+ .attr("x1", (d) => (d.source as D3Node).x || 0)
380
+ .attr("y1", (d) => (d.source as D3Node).y || 0)
381
+ .attr("x2", (d) => (d.target as D3Node).x || 0)
382
+ .attr("y2", (d) => (d.target as D3Node).y || 0)
383
+ .attr("stroke-width", 1.5);
384
+
385
+ const node = graphGroup
386
+ .append("g")
387
+ .selectAll("g")
388
+ .data(visibleNodes)
389
+ .join("g")
390
+ .attr("class", "node-group")
391
+ .attr("cursor", "pointer")
392
+ .attr("transform", (d) => `translate(${d.x || 0}, ${d.y || 0})`)
393
+ .call(
394
+ d3
395
+ .drag<SVGGElement, D3Node>()
396
+ .subject(function (d) {
397
+ return d;
398
+ })
399
+ .on("start", function (event, d) {
400
+ d.fx = d.x;
401
+ d.fy = d.y;
402
+ })
403
+ .on("drag", function (event, d) {
404
+ d.fx = event.x;
405
+ d.fy = event.y;
406
+
407
+ d3.select(this).attr("transform", `translate(${event.x}, ${event.y})`);
408
+
409
+ d3.select(this).attr("transform", `translate(${event.x}, ${event.y})`);
410
+
411
+ nodePositionsRef.current.set(d.id, { x: event.x, y: event.y });
412
+
413
+ link
414
+ .attr("x1", (l) => {
415
+ const source = l.source as D3Node;
416
+ return source.fx || source.x || 0;
417
+ })
418
+ .attr("y1", (l) => {
419
+ const source = l.source as D3Node;
420
+ return source.fy || source.y || 0;
421
+ })
422
+ .attr("x2", (l) => {
423
+ const target = l.target as D3Node;
424
+ return target.fx || target.x || 0;
425
+ })
426
+ .attr("y2", (l) => {
427
+ const target = l.target as D3Node;
428
+ return target.fy || target.y || 0;
429
+ });
430
+ })
431
+ .on("end", function (event, d) {
432
+ d.fx = event.x;
433
+ d.fy = event.y;
434
+ }) as any,
435
+ )
436
+ .on("mouseenter", function (_event, d) {
437
+ // Skip hover effect for root node
438
+ if (d.instanceType === "root") return;
439
+
440
+ const currentNode = d3.select(this);
441
+
442
+ // Bring node to front
443
+ currentNode.raise();
444
+
445
+ // Get current zoom scale for counter-scaling text
446
+ const currentZoom = zoomRef.current?.k || 1;
447
+ const targetScreenFontSize = 20; // Target font size in screen pixels
448
+ const baseFontSize = 12; // Base font size in graph coordinates
449
+
450
+ // Calculate smooth scale factor for transform
451
+ const textScale = targetScreenFontSize / (baseFontSize * currentZoom);
452
+
453
+ // Calculate text position offset - circle stays in graph coords, only gap is counter-scaled
454
+ const hoverTextOffset = nodeRadius * 1.4 + 5 / currentZoom;
455
+
456
+ // Scale up the circle with smooth transition (unchanged)
457
+ currentNode
458
+ .select("circle")
459
+ .transition()
460
+ .duration(250)
461
+ .ease(d3.easeExpOut)
462
+ .attr("r", nodeRadius * 1.4)
463
+ .attr("filter", "drop-shadow(0px 4px 12px rgba(0, 0, 0, 0.3))");
464
+
465
+ // Scale up the text with smooth transform scaling around its anchor point
466
+ currentNode
467
+ .select("text")
468
+ .transition()
469
+ .duration(250)
470
+ .ease(d3.easeExpOut)
471
+ .attr("dy", -hoverTextOffset)
472
+ .attr("transform", `translate(0, ${-hoverTextOffset}) scale(${textScale}) translate(0, ${hoverTextOffset})`);
473
+ })
474
+ .on("mouseleave", function (_event, d) {
475
+ // Skip hover effect for root node
476
+ if (d.instanceType === "root") return;
477
+
478
+ const currentNode = d3.select(this);
479
+
480
+ // Return circle to normal size
481
+ currentNode
482
+ .select("circle")
483
+ .transition()
484
+ .duration(250)
485
+ .ease(d3.easeExpOut)
486
+ .attr("r", nodeRadius)
487
+ .attr("filter", null);
488
+
489
+ // Return text to normal size with smooth transform
490
+ const normalOffset = nodeRadius + 5;
491
+ currentNode
492
+ .select("text")
493
+ .transition()
494
+ .duration(250)
495
+ .ease(d3.easeExpOut)
496
+ .attr("dy", -normalOffset)
497
+ .attr("transform", `translate(0, ${-normalOffset}) scale(1) translate(0, ${normalOffset})`);
498
+ });
499
+
500
+ node
501
+ .append("circle")
502
+ .attr("r", nodeRadius)
503
+ .attr("fill", (d) => getNodeColor(d))
504
+ .attr("stroke", "#fff")
505
+ .attr("stroke-width", 1.5)
506
+ .on("click", (event, d) => {
507
+ event.preventDefault();
508
+ event.stopPropagation();
509
+ if (zoomBehaviorRef.current) {
510
+ svg.on(".zoom", null);
511
+ setTimeout(() => {
512
+ if (zoomBehaviorRef.current) {
513
+ svg
514
+ .call(zoomBehaviorRef.current as any)
515
+ .on("wheel.zoom", null)
516
+ .on("dblclick.zoom", null);
517
+ }
518
+ }, 100);
519
+ }
520
+ onNodeClick(d.id);
521
+ });
522
+
523
+ node.each(function (d: D3Node) {
524
+ if (d.icon) {
525
+ const Icon = d.icon as React.FC<{ size: number; color: string }>;
526
+ const iconSvg = renderToStaticMarkup(<Icon size={nodeRadius / 2} color="white" />);
527
+
528
+ const iconGroup = d3
529
+ .select(this)
530
+ .append("g")
531
+ .html(iconSvg)
532
+ .attr("transform", `translate(${-nodeRadius / 4}, ${-nodeRadius / 4})`)
533
+ .style("pointer-events", "all")
534
+ .on("click", (event) => {
535
+ event.stopPropagation();
536
+ onNodeClick(d.id);
537
+ });
538
+ }
539
+ });
540
+
541
+ // Add loading spinner for nodes that are fetching children
542
+ node.each(function (d: D3Node) {
543
+ if (loadingNodeIds && loadingNodeIds.has(d.id)) {
544
+ // Remove existing icon
545
+ d3.select(this).selectAll("g").remove();
546
+
547
+ // Add spinner
548
+ const spinnerSvg = renderToStaticMarkup(<Loader2 size={nodeRadius / 2} color="white" />);
549
+
550
+ d3.select(this)
551
+ .append("g")
552
+ .html(spinnerSvg)
553
+ .attr("class", "animate-spin")
554
+ .attr("transform", `translate(${-nodeRadius / 4}, ${-nodeRadius / 4})`)
555
+ .style("pointer-events", "none");
556
+ }
557
+ });
558
+
559
+ node.each(function (d: D3Node) {
560
+ const textElement = d3
561
+ .select(this)
562
+ .append("text")
563
+ .attr("text-anchor", "middle")
564
+ .attr("font-size", 12)
565
+ .attr("pointer-events", "none");
566
+
567
+ if (d.instanceType === "root") {
568
+ // Split text by spaces for multi-line display
569
+ const words = d.name.split(" ");
570
+ const lineHeight = 1.2; // em units
571
+ const numLines = words.length;
572
+ // Calculate starting position to center the text block vertically
573
+ // Account for the fact that we want the middle of the entire text block at y=0
574
+ const startY = -((numLines - 1) * lineHeight) / 2;
575
+
576
+ textElement.attr("fill", "var(--accent-foreground)").attr("dominant-baseline", "middle");
577
+
578
+ words.forEach((word, index) => {
579
+ textElement
580
+ .append("tspan")
581
+ .attr("x", 0)
582
+ .attr("dy", index === 0 ? `${startY}em` : `${lineHeight}em`)
583
+ .text(word);
584
+ });
585
+ } else {
586
+ // Non-root nodes: single line text above the circle
587
+ textElement
588
+ .attr("dy", -nodeRadius - 5)
589
+ .attr("fill", "currentColor")
590
+ .text(d.name);
591
+ }
592
+ });
593
+
594
+ return () => {
595
+ simulation.stop();
596
+ };
597
+ }, [nodes, links, colorScale, visibleNodeIds, loadingNodeIds, onNodeClick]);
598
+
599
+ const zoomIn = useCallback(() => {
600
+ if (!svgRef.current || !zoomBehaviorRef.current) return;
601
+
602
+ const svg = d3.select(svgRef.current);
603
+ const zoom = zoomBehaviorRef.current;
604
+
605
+ const currentTransform = zoomRef.current || d3.zoomIdentity;
606
+ const newScale = Math.min(currentTransform.k * 1.3, 4);
607
+
608
+ svg
609
+ .transition()
610
+ .duration(300)
611
+ .call(zoom.transform, d3.zoomIdentity.translate(currentTransform.x, currentTransform.y).scale(newScale));
612
+ }, []);
613
+
614
+ const zoomOut = useCallback(() => {
615
+ if (!svgRef.current || !zoomBehaviorRef.current) return;
616
+
617
+ const svg = d3.select(svgRef.current);
618
+ const zoom = zoomBehaviorRef.current;
619
+
620
+ const currentTransform = zoomRef.current || d3.zoomIdentity;
621
+ const newScale = Math.max(currentTransform.k * 0.7, 0.1);
622
+
623
+ svg
624
+ .transition()
625
+ .duration(300)
626
+ .call(zoom.transform, d3.zoomIdentity.translate(currentTransform.x, currentTransform.y).scale(newScale));
627
+ }, []);
628
+
629
+ const zoomToFitAll = useCallback(() => {
630
+ if (!svgRef.current || !zoomBehaviorRef.current) return;
631
+
632
+ const svg = d3.select(svgRef.current);
633
+ const zoom = zoomBehaviorRef.current;
634
+
635
+ // Get all visible nodes
636
+ const visibleNodes = visibleNodeIds
637
+ ? nodes.filter((node) => visibleNodeIds.has(node.id))
638
+ : nodes.filter((node) => node.visible !== false);
639
+
640
+ if (visibleNodes.length === 0) return;
641
+
642
+ // Calculate bounds of all visible nodes
643
+ const positions: { x: number; y: number }[] = [];
644
+ visibleNodes.forEach((node) => {
645
+ if (node.fx !== undefined && node.fy !== undefined && node.fx !== null && node.fy !== null) {
646
+ positions.push({ x: node.fx, y: node.fy });
647
+ } else if (node.x !== undefined && node.y !== undefined && node.x !== null && node.y !== null) {
648
+ positions.push({ x: node.x, y: node.y });
649
+ }
650
+ });
651
+
652
+ if (positions.length === 0) return;
653
+
654
+ const bounds = {
655
+ xMin: Math.min(...positions.map((p) => p.x)),
656
+ xMax: Math.max(...positions.map((p) => p.x)),
657
+ yMin: Math.min(...positions.map((p) => p.y)),
658
+ yMax: Math.max(...positions.map((p) => p.y)),
659
+ };
660
+
661
+ // Add padding
662
+ const padding = 150;
663
+ bounds.xMin -= padding;
664
+ bounds.xMax += padding;
665
+ bounds.yMin -= padding;
666
+ bounds.yMax += padding;
667
+
668
+ const width = svgRef.current.clientWidth;
669
+ const height = svgRef.current.clientHeight;
670
+
671
+ const contentWidth = bounds.xMax - bounds.xMin;
672
+ const contentHeight = bounds.yMax - bounds.yMin;
673
+
674
+ const scaleX = width / contentWidth;
675
+ const scaleY = height / contentHeight;
676
+
677
+ let scale = Math.min(scaleX, scaleY);
678
+ scale = Math.min(Math.max(scale, 0.1), 2); // Clamp between 0.1 and 2
679
+
680
+ const centerX = (bounds.xMin + bounds.xMax) / 2;
681
+ const centerY = (bounds.yMin + bounds.yMax) / 2;
682
+
683
+ const translateX = width / 2 - centerX * scale;
684
+ const translateY = height / 2 - centerY * scale;
685
+
686
+ svg.transition().duration(750).call(zoom.transform, d3.zoomIdentity.translate(translateX, translateY).scale(scale));
687
+ }, [nodes, visibleNodeIds]);
688
+
689
+ // When container size changes (full-screen toggle), zoom to fit all nodes
690
+ // This scales the view instead of recalculating positions, maintaining relative layout
691
+ useEffect(() => {
692
+ if (containerKey !== undefined && containerKey !== prevContainerKeyRef.current) {
693
+ // Small delay to allow the container to finish resizing
694
+ const timeoutId = setTimeout(() => {
695
+ zoomToFitAll();
696
+ }, 100);
697
+
698
+ prevContainerKeyRef.current = containerKey;
699
+
700
+ return () => clearTimeout(timeoutId);
701
+ }
702
+ }, [containerKey, zoomToFitAll]);
703
+
704
+ return { svgRef, zoomIn, zoomOut, zoomToNode, zoomToFitAll };
705
+ }