@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.
- package/package.json +2 -1
- package/src/atoms/index.ts +1 -0
- package/src/atoms/recentPagesAtom.ts +10 -0
- package/src/client/context/JsonApiContext.ts +61 -0
- package/src/client/context/JsonApiProvider.tsx +27 -0
- package/src/client/context/index.ts +2 -0
- package/src/client/hooks/index.ts +3 -0
- package/src/client/hooks/useJsonApiGet.ts +188 -0
- package/src/client/hooks/useJsonApiMutation.ts +193 -0
- package/src/client/hooks/useRehydration.ts +47 -0
- package/src/client/index.ts +11 -0
- package/src/client/request.ts +97 -0
- package/src/client/token.ts +10 -0
- package/src/components/containers/PageContainer.tsx +15 -0
- package/src/components/containers/ReactMarkdownContainer.tsx +119 -0
- package/src/components/containers/TabsContainer.tsx +93 -0
- package/src/components/containers/index.ts +3 -0
- package/src/components/contents/AttributeElement.tsx +20 -0
- package/src/components/contents/index.ts +1 -0
- package/src/components/details/AllowedUsersDetails.tsx +23 -0
- package/src/components/details/index.ts +1 -0
- package/src/components/editors/BlockNoteDiffInlineContent.tsx +152 -0
- package/src/components/editors/BlockNoteEditor.tsx +404 -0
- package/src/components/editors/BlockNoteEditorContainer.tsx +13 -0
- package/src/components/editors/BlockNoteEditorFormattingToolbar.tsx +38 -0
- package/src/components/editors/index.ts +1 -0
- package/src/components/errors/ErrorDetails.tsx +41 -0
- package/src/components/errors/errorToast.ts +9 -0
- package/src/components/errors/index.ts +2 -0
- package/src/components/forms/CommonAssociationForm.tsx +162 -0
- package/src/components/forms/CommonDeleter.tsx +94 -0
- package/src/components/forms/CommonEditorButtons.tsx +30 -0
- package/src/components/forms/CommonEditorHeader.tsx +35 -0
- package/src/components/forms/CommonEditorTrigger.tsx +26 -0
- package/src/components/forms/DatePickerPopover.tsx +219 -0
- package/src/components/forms/DateRangeSelector.tsx +110 -0
- package/src/components/forms/FileUploader.tsx +324 -0
- package/src/components/forms/FormCheckbox.tsx +66 -0
- package/src/components/forms/FormContainerGeneric.tsx +39 -0
- package/src/components/forms/FormDate.tsx +247 -0
- package/src/components/forms/FormDateTime.tsx +231 -0
- package/src/components/forms/FormInput.tsx +110 -0
- package/src/components/forms/FormPassword.tsx +54 -0
- package/src/components/forms/FormPlaceAutocomplete.tsx +286 -0
- package/src/components/forms/FormSelect.tsx +72 -0
- package/src/components/forms/FormSlider.tsx +51 -0
- package/src/components/forms/FormSwitch.tsx +25 -0
- package/src/components/forms/FormTextarea.tsx +44 -0
- package/src/components/forms/MultiFileUploader.tsx +107 -0
- package/src/components/forms/PasswordInput.tsx +47 -0
- package/src/components/forms/index.ts +21 -0
- package/src/components/index.ts +11 -0
- package/src/components/navigations/Breadcrumb.tsx +83 -0
- package/src/components/navigations/ContentTitle.tsx +39 -0
- package/src/components/navigations/Header.tsx +27 -0
- package/src/components/navigations/ModeToggleSwitch.tsx +25 -0
- package/src/components/navigations/PageSection.tsx +64 -0
- package/src/components/navigations/RecentPagesNavigator.tsx +52 -0
- package/src/components/navigations/index.ts +6 -0
- package/src/components/pages/PageContainerContentDetails.tsx +76 -0
- package/src/components/pages/PageContentContainer.tsx +31 -0
- package/src/components/pages/index.ts +2 -0
- package/src/components/tables/ContentListTable.tsx +165 -0
- package/src/components/tables/ContentTableSearch.tsx +105 -0
- package/src/components/tables/cells/cell.component.tsx +18 -0
- package/src/components/tables/cells/cell.date.tsx +16 -0
- package/src/components/tables/cells/cell.id.tsx +27 -0
- package/src/components/tables/cells/cell.link.tsx +18 -0
- package/src/components/tables/cells/cell.text.tsx +12 -0
- package/src/components/tables/cells/cell.url.tsx +13 -0
- package/src/components/tables/cells/index.ts +5 -0
- package/src/components/tables/index.ts +3 -0
- package/src/contexts/SharedContext.tsx +35 -0
- package/src/contexts/index.ts +2 -0
- package/src/core/abstracts/AbstractApiData.ts +138 -0
- package/src/core/abstracts/AbstractService.ts +263 -0
- package/src/core/abstracts/index.ts +2 -0
- package/src/core/endpoint/EndpointCreator.ts +97 -0
- package/src/core/endpoint/index.ts +1 -0
- package/src/core/factories/JsonApiDataFactory.ts +12 -0
- package/src/core/factories/RehydrationFactory.ts +30 -0
- package/src/core/factories/index.ts +2 -0
- package/src/core/fields/FieldSelector.ts +15 -0
- package/src/core/fields/index.ts +1 -0
- package/src/core/index.ts +20 -0
- package/src/core/interfaces/ApiData.ts +8 -0
- package/src/core/interfaces/ApiDataInterface.ts +15 -0
- package/src/core/interfaces/ApiRequestDataTypeInterface.ts +14 -0
- package/src/core/interfaces/ApiResponseInterface.ts +17 -0
- package/src/core/interfaces/JsonApiHydratedDataInterface.ts +5 -0
- package/src/core/interfaces/index.ts +5 -0
- package/src/core/registry/DataClassRegistry.ts +51 -0
- package/src/core/registry/ModuleRegistrar.ts +43 -0
- package/src/core/registry/ModuleRegistry.ts +64 -0
- package/src/core/registry/index.ts +3 -0
- package/src/core/utils/index.ts +2 -0
- package/src/core/utils/rehydrate.ts +24 -0
- package/src/core/utils/translateResponse.ts +125 -0
- package/src/features/auth/auth.module.ts +9 -0
- package/src/features/auth/config.ts +57 -0
- package/src/features/auth/data/auth.interface.ts +31 -0
- package/src/features/auth/data/auth.service.ts +159 -0
- package/src/features/auth/data/auth.ts +54 -0
- package/src/features/auth/data/index.ts +3 -0
- package/src/features/auth/index.ts +3 -0
- package/src/features/company/company.module.ts +10 -0
- package/src/features/company/data/company.fields.ts +6 -0
- package/src/features/company/data/company.interface.ts +28 -0
- package/src/features/company/data/company.service.ts +73 -0
- package/src/features/company/data/company.ts +104 -0
- package/src/features/company/data/index.ts +4 -0
- package/src/features/company/index.ts +2 -0
- package/src/features/content/content.module.ts +20 -0
- package/src/features/content/data/content.fields.ts +13 -0
- package/src/features/content/data/content.interface.ts +23 -0
- package/src/features/content/data/content.service.ts +75 -0
- package/src/features/content/data/content.ts +85 -0
- package/src/features/content/data/index.ts +4 -0
- package/src/features/content/index.ts +2 -0
- package/src/features/feature/components/forms/FormFeatures.tsx +149 -0
- package/src/features/feature/components/index.ts +1 -0
- package/src/features/feature/data/feature.interface.ts +9 -0
- package/src/features/feature/data/feature.service.ts +19 -0
- package/src/features/feature/data/feature.ts +33 -0
- package/src/features/feature/data/index.ts +3 -0
- package/src/features/feature/feature.module.ts +10 -0
- package/src/features/feature/index.ts +3 -0
- package/src/features/index.ts +12 -0
- package/src/features/module/data/index.ts +2 -0
- package/src/features/module/data/module.interface.ts +12 -0
- package/src/features/module/data/module.ts +42 -0
- package/src/features/module/index.ts +2 -0
- package/src/features/module/module.module.ts +10 -0
- package/src/features/notification/data/index.ts +4 -0
- package/src/features/notification/data/notification.fields.ts +8 -0
- package/src/features/notification/data/notification.interface.ts +14 -0
- package/src/features/notification/data/notification.service.ts +34 -0
- package/src/features/notification/data/notification.ts +51 -0
- package/src/features/notification/index.ts +2 -0
- package/src/features/notification/notification.module.ts +10 -0
- package/src/features/push/data/index.ts +3 -0
- package/src/features/push/data/push.interface.ts +8 -0
- package/src/features/push/data/push.service.ts +17 -0
- package/src/features/push/data/push.ts +18 -0
- package/src/features/push/index.ts +2 -0
- package/src/features/push/push.module.ts +10 -0
- package/src/features/role/data/index.ts +4 -0
- package/src/features/role/data/role.fields.ts +8 -0
- package/src/features/role/data/role.interface.ts +16 -0
- package/src/features/role/data/role.service.ts +117 -0
- package/src/features/role/data/role.ts +62 -0
- package/src/features/role/index.ts +2 -0
- package/src/features/role/role.module.ts +10 -0
- package/src/features/s3/data/index.ts +3 -0
- package/src/features/s3/data/s3.interface.ts +11 -0
- package/src/features/s3/data/s3.service.ts +30 -0
- package/src/features/s3/data/s3.ts +60 -0
- package/src/features/s3/index.ts +2 -0
- package/src/features/s3/s3.module.ts +10 -0
- package/src/features/search/index.ts +1 -0
- package/src/features/search/interfaces/index.ts +1 -0
- package/src/features/search/interfaces/search.result.interface.ts +3 -0
- package/src/features/user/author.module.ts +10 -0
- package/src/features/user/components/index.ts +2 -0
- package/src/features/user/components/lists/ContributorsList.tsx +41 -0
- package/src/features/user/components/lists/index.ts +1 -0
- package/src/features/user/components/widgets/UserAvatar.tsx +86 -0
- package/src/features/user/components/widgets/index.ts +1 -0
- package/src/features/user/contexts/CurrentUserContext.tsx +156 -0
- package/src/features/user/contexts/index.ts +1 -0
- package/src/features/user/data/index.ts +4 -0
- package/src/features/user/data/user.fields.ts +8 -0
- package/src/features/user/data/user.interface.ts +41 -0
- package/src/features/user/data/user.service.ts +246 -0
- package/src/features/user/data/user.ts +162 -0
- package/src/features/user/index.ts +4 -0
- package/src/features/user/user.module.ts +21 -0
- package/src/hooks/TableGeneratorRegistry.ts +53 -0
- package/src/hooks/index.ts +33 -0
- package/src/hooks/types.ts +35 -0
- package/src/hooks/url.rewriter.ts +22 -0
- package/src/hooks/useCustomD3Graph.tsx +705 -0
- package/src/hooks/useDataListRetriever.ts +349 -0
- package/src/hooks/useDebounce.ts +33 -0
- package/src/hooks/usePageUrlGenerator.ts +50 -0
- package/src/hooks/useTableGenerator.ts +16 -0
- package/src/i18n/config.ts +73 -0
- package/src/i18n/index.ts +18 -0
- package/src/index.ts +16 -0
- package/src/interfaces/breadcrumb.item.data.interface.ts +4 -0
- package/src/interfaces/d3.link.interface.ts +7 -0
- package/src/interfaces/d3.node.interface.ts +12 -0
- package/src/interfaces/index.ts +3 -0
- package/src/permissions/check.ts +127 -0
- package/src/permissions/index.ts +2 -0
- package/src/permissions/types.ts +109 -0
- package/src/roles/config.ts +46 -0
- package/src/roles/index.ts +1 -0
- package/src/server/cache.ts +28 -0
- package/src/server/index.ts +3 -0
- package/src/server/request.ts +113 -0
- package/src/server/token.ts +10 -0
- package/src/shadcnui/custom/kanban.tsx +1001 -0
- package/src/shadcnui/custom/link.tsx +18 -0
- package/src/shadcnui/custom/multi-select.tsx +382 -0
- package/src/shadcnui/index.ts +49 -0
- package/src/shadcnui/ui/accordion.tsx +52 -0
- package/src/shadcnui/ui/alert-dialog.tsx +141 -0
- package/src/shadcnui/ui/alert.tsx +43 -0
- package/src/shadcnui/ui/avatar.tsx +50 -0
- package/src/shadcnui/ui/badge.tsx +40 -0
- package/src/shadcnui/ui/breadcrumb.tsx +115 -0
- package/src/shadcnui/ui/button.tsx +51 -0
- package/src/shadcnui/ui/calendar.tsx +73 -0
- package/src/shadcnui/ui/card.tsx +43 -0
- package/src/shadcnui/ui/carousel.tsx +225 -0
- package/src/shadcnui/ui/chart.tsx +320 -0
- package/src/shadcnui/ui/checkbox.tsx +29 -0
- package/src/shadcnui/ui/collapsible.tsx +11 -0
- package/src/shadcnui/ui/command.tsx +155 -0
- package/src/shadcnui/ui/context-menu.tsx +179 -0
- package/src/shadcnui/ui/dialog.tsx +96 -0
- package/src/shadcnui/ui/drawer.tsx +89 -0
- package/src/shadcnui/ui/dropdown-menu.tsx +205 -0
- package/src/shadcnui/ui/form.tsx +138 -0
- package/src/shadcnui/ui/hover-card.tsx +29 -0
- package/src/shadcnui/ui/input.tsx +21 -0
- package/src/shadcnui/ui/label.tsx +26 -0
- package/src/shadcnui/ui/navigation-menu.tsx +168 -0
- package/src/shadcnui/ui/popover.tsx +33 -0
- package/src/shadcnui/ui/progress.tsx +25 -0
- package/src/shadcnui/ui/radio-group.tsx +37 -0
- package/src/shadcnui/ui/resizable.tsx +47 -0
- package/src/shadcnui/ui/scroll-area.tsx +40 -0
- package/src/shadcnui/ui/select.tsx +164 -0
- package/src/shadcnui/ui/separator.tsx +28 -0
- package/src/shadcnui/ui/sheet.tsx +139 -0
- package/src/shadcnui/ui/sidebar.tsx +677 -0
- package/src/shadcnui/ui/skeleton.tsx +13 -0
- package/src/shadcnui/ui/slider.tsx +25 -0
- package/src/shadcnui/ui/sonner.tsx +25 -0
- package/src/shadcnui/ui/switch.tsx +31 -0
- package/src/shadcnui/ui/table.tsx +120 -0
- package/src/shadcnui/ui/tabs.tsx +55 -0
- package/src/shadcnui/ui/textarea.tsx +24 -0
- package/src/shadcnui/ui/toggle.tsx +39 -0
- package/src/shadcnui/ui/tooltip.tsx +61 -0
- package/src/unified/JsonApiRequest.ts +325 -0
- package/src/unified/index.ts +1 -0
- package/src/utils/blocknote-diff.util.ts +815 -0
- package/src/utils/blocknote-word-diff-renderer.util.ts +413 -0
- package/src/utils/cn.ts +6 -0
- package/src/utils/compose-refs.ts +61 -0
- package/src/utils/date-formatter.ts +53 -0
- package/src/utils/exists.ts +7 -0
- package/src/utils/index.ts +15 -0
- package/src/utils/schemas/entity.object.schema.ts +8 -0
- package/src/utils/schemas/index.ts +2 -0
- package/src/utils/schemas/user.object.schema.ts +9 -0
- package/src/utils/table-options.ts +67 -0
- 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
|
+
}
|