@omiron33/omi-neuron-web 0.2.1 → 0.2.13

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/README.md CHANGED
@@ -656,6 +656,7 @@ export interface NeuronWebProps {
656
656
  error?: string | null;
657
657
  selectedNode?: NeuronNode | null;
658
658
  focusNodeSlug?: string | null;
659
+ onFocusConsumed?: () => void;
659
660
  visibleNodeSlugs?: string[] | null;
660
661
  onNodeClick?: (node: NeuronNode) => void;
661
662
  onNodeDoubleClick?: (node: NeuronNode) => void;
@@ -668,6 +669,9 @@ export interface NeuronWebProps {
668
669
  onStudyPathComplete?: () => void;
669
670
  layout?: NeuronLayoutOptions;
670
671
  cameraFit?: CameraFitOptions;
672
+ cardsMode?: CardsMode;
673
+ clickCard?: ClickCardOptions;
674
+ clickZoom?: ClickZoomOptions;
671
675
  theme?: NeuronWebThemeOverride;
672
676
  domainColors?: Record<string, string>;
673
677
  renderNodeHover?: (node: NeuronVisualNode) => React.ReactNode;
@@ -682,9 +686,9 @@ export interface NeuronWebProps {
682
686
 
683
687
  Props currently used inside `NeuronWeb` (others are reserved for future use):
684
688
 
685
- - Used: `graphData`, `className`, `style`, `fullHeight`, `isFullScreen`, `isLoading`, `error`, `renderEmptyState`, `renderLoadingState`, `ariaLabel`, `theme`, `layout`, `renderNodeHover`, `hoverCard`, `onNodeHover`, `onNodeClick`, `onNodeDoubleClick`, `onNodeFocused`, `onBackgroundClick`, `performanceMode`.
689
+ - Used: `graphData`, `className`, `style`, `fullHeight`, `isFullScreen`, `isLoading`, `error`, `renderEmptyState`, `renderLoadingState`, `ariaLabel`, `theme`, `layout`, `renderNodeHover`, `renderNodeDetail`, `hoverCard`, `clickCard`, `clickZoom`, `cardsMode`, `onNodeHover`, `onNodeClick`, `onNodeDoubleClick`, `onNodeFocused`, `onBackgroundClick`, `performanceMode`, `focusNodeSlug`, `onFocusConsumed`, `visibleNodeSlugs`.
686
690
  - Used: `cameraFit` (auto-fit bounds to a viewport fraction).
687
- - Reserved (declared but not used in the component yet): `selectedNode`, `focusNodeSlug`, `visibleNodeSlugs`, `onEdgeClick`, `onCameraChange`, `studyPathRequest`, `onStudyPathComplete`, `domainColors`, `renderNodeDetail`, `graphData.storyBeats`.
691
+ - Reserved (declared but not used in the component yet): `selectedNode`, `onEdgeClick`, `onCameraChange`, `studyPathRequest`, `onStudyPathComplete`, `domainColors`, `graphData.storyBeats`.
688
692
 
689
693
  ### Layout modes
690
694
 
@@ -730,6 +734,85 @@ When `isFullScreen` is true and `cameraFit.enabled` is not specified, auto-fit i
730
734
  />
731
735
  ```
732
736
 
737
+ ### Programmatic focus (focusNodeSlug)
738
+
739
+ Use `focusNodeSlug` to drive selection + camera focus from outside the component
740
+ (mirrors Technochristian’s “focus a node when something else happens” behavior).
741
+
742
+ Behavior:
743
+ - Looks up by **slug**, with **id fallback**.
744
+ - Sets selection, pulses the node, and emphasizes connected edges.
745
+ - If `clickZoom.enabled` is true (default), the camera tween runs.
746
+ - Fires `onNodeFocused(node)` after the focus tween.
747
+ - Calls `onFocusConsumed()` so the parent can clear the request.
748
+
749
+ ```tsx
750
+ <NeuronWeb
751
+ graphData={graphData}
752
+ focusNodeSlug={focusSlug}
753
+ onFocusConsumed={() => setFocusSlug(null)}
754
+ onNodeFocused={(node) => console.log('focused', node.slug)}
755
+ clickZoom={{ enabled: true }}
756
+ />
757
+ ```
758
+
759
+ ### Filtered views (visibleNodeSlugs)
760
+
761
+ `visibleNodeSlugs` limits the graph to a subset of nodes (and their edges).
762
+ This is the mechanism Technochristian uses for filtered views and decluttering.
763
+
764
+ Semantics:
765
+ - `null` or `undefined` → show **all** nodes/edges.
766
+ - `[]` (empty array) → show **none**.
767
+ - Filters nodes by **slug or id**; edges are kept only if **both endpoints** are visible.
768
+ - `storyBeats` (if present) are filtered to beats with ≥ 2 visible nodeIds.
769
+ - A subtle fade/scale/blur transition is applied on each filter change.
770
+ - If the currently selected node disappears, selection is cleared.
771
+
772
+ ```tsx
773
+ // Show only a curated slice
774
+ <NeuronWeb
775
+ graphData={graphData}
776
+ visibleNodeSlugs={['uap', 'neph', 'jude6']}
777
+ />;
778
+
779
+ // Show everything (default)
780
+ <NeuronWeb graphData={graphData} visibleNodeSlugs={null} />;
781
+
782
+ // Hide everything
783
+ <NeuronWeb graphData={graphData} visibleNodeSlugs={[]} />;
784
+ ```
785
+
786
+ ### Click cards + click zoom
787
+
788
+ Enable a persistent card on click and optional zoom-to-node behavior:
789
+
790
+ ```tsx
791
+ <NeuronWeb
792
+ graphData={graphData}
793
+ clickCard={{ enabled: true, width: 320, offset: [24, 24] }}
794
+ clickZoom={{ enabled: true }}
795
+ />
796
+ ```
797
+
798
+ ### Card mode (global override)
799
+
800
+ `cardsMode` lets you force card behavior irrespective of `hoverCard.enabled` or `clickCard.enabled`
801
+ (when `cardsMode` is set, it wins).
802
+
803
+ ```tsx
804
+ <NeuronWeb graphData={graphData} cardsMode="none" />
805
+ <NeuronWeb graphData={graphData} cardsMode="hover" />
806
+ <NeuronWeb graphData={graphData} cardsMode="click" />
807
+ <NeuronWeb graphData={graphData} cardsMode="both" />
808
+ ```
809
+
810
+ Disable click cards or zoom:
811
+
812
+ ```tsx
813
+ <NeuronWeb graphData={graphData} clickCard={{ enabled: false }} clickZoom={{ enabled: false }} />
814
+ ```
815
+
733
816
  To disable in fullscreen:
734
817
 
735
818
  ```tsx
@@ -814,6 +897,24 @@ pnpm lint
814
897
  pnpm test
815
898
  ```
816
899
 
900
+ ## Release via tags (automation)
901
+
902
+ This repo publishes on **tag push**. The tag version is authoritative and must be a **patch bump**.
903
+
904
+ Workflow:
905
+ - Create and push `vX.Y.Z` (same major/minor as `package.json`, patch +1).
906
+ - GitHub Actions updates `package.json` + `src/index.ts`, commits to `main`, builds, and publishes.
907
+
908
+ Helper skill (repo-local):
909
+
910
+ ```bash
911
+ skills/tagged-npm-release/scripts/create_patch_tag.sh
912
+ ```
913
+
914
+ Authentication:
915
+ - **Preferred**: npm **Trusted Publishing** (OIDC). Configure the GitHub repo/workflow in npm package settings. No secret required.
916
+ - **Legacy fallback**: set `NPM_TOKEN` in GitHub repo secrets and remove `--provenance` from the workflow.
917
+
817
918
  ## License
818
919
 
819
920
  MIT
@@ -74,6 +74,15 @@ interface HoverCardOptions {
74
74
  width?: number;
75
75
  offset?: [number, number];
76
76
  }
77
+ interface ClickCardOptions {
78
+ enabled?: boolean;
79
+ width?: number;
80
+ offset?: [number, number];
81
+ }
82
+ interface ClickZoomOptions {
83
+ enabled?: boolean;
84
+ }
85
+ type CardsMode = 'none' | 'hover' | 'click' | 'both';
77
86
  interface CameraFitOptions {
78
87
  /** Enable auto-fitting camera to node bounds */
79
88
  enabled?: boolean;
@@ -97,7 +106,11 @@ interface NeuronWebProps {
97
106
  isLoading?: boolean;
98
107
  error?: string | null;
99
108
  selectedNode?: NeuronNode | null;
109
+ /** Programmatically focus/select a node by slug (or id fallback). */
100
110
  focusNodeSlug?: string | null;
111
+ /** Called after a focusNodeSlug request is processed. */
112
+ onFocusConsumed?: () => void;
113
+ /** Limit the rendered graph to these node slugs/ids; null shows all, empty array shows none. */
101
114
  visibleNodeSlugs?: string[] | null;
102
115
  onNodeClick?: (node: NeuronNode) => void;
103
116
  onNodeDoubleClick?: (node: NeuronNode) => void;
@@ -110,6 +123,9 @@ interface NeuronWebProps {
110
123
  onStudyPathComplete?: () => void;
111
124
  layout?: NeuronLayoutOptions;
112
125
  cameraFit?: CameraFitOptions;
126
+ cardsMode?: CardsMode;
127
+ clickCard?: ClickCardOptions;
128
+ clickZoom?: ClickZoomOptions;
113
129
  theme?: NeuronWebThemeOverride;
114
130
  domainColors?: Record<string, string>;
115
131
  renderNodeHover?: (node: NeuronVisualNode) => React__default.ReactNode;
@@ -121,6 +137,6 @@ interface NeuronWebProps {
121
137
  ariaLabel?: string;
122
138
  }
123
139
 
124
- declare function NeuronWeb({ graphData, className, style, fullHeight, isFullScreen, isLoading, error, renderEmptyState, renderLoadingState, ariaLabel, theme, layout, cameraFit, renderNodeHover, hoverCard, onNodeHover, onNodeClick, onNodeDoubleClick, onNodeFocused, onBackgroundClick, performanceMode, }: NeuronWebProps): React__default.ReactElement;
140
+ declare function NeuronWeb({ graphData, className, style, fullHeight, isFullScreen, isLoading, error, focusNodeSlug, onFocusConsumed, visibleNodeSlugs, renderEmptyState, renderLoadingState, ariaLabel, theme, layout, cameraFit, cardsMode, clickCard, clickZoom, renderNodeHover, renderNodeDetail, hoverCard, onNodeHover, onNodeClick, onNodeDoubleClick, onNodeFocused, onBackgroundClick, performanceMode, }: NeuronWebProps): React__default.ReactElement;
125
141
 
126
- export { type CameraFitOptions as C, type HoverCardOptions as H, type NeuronWebTheme as N, type NeuronWebThemeOverride as a, type NeuronLayoutOptions as b, NeuronWeb as c, type NeuronWebProps as d, type NeuronLayoutMode as e };
142
+ export { type CameraFitOptions as C, type HoverCardOptions as H, type NeuronWebTheme as N, type NeuronWebThemeOverride as a, type NeuronLayoutOptions as b, NeuronWeb as c, type NeuronWebProps as d, type NeuronLayoutMode as e, type ClickCardOptions as f, type ClickZoomOptions as g, type CardsMode as h };
@@ -74,6 +74,15 @@ interface HoverCardOptions {
74
74
  width?: number;
75
75
  offset?: [number, number];
76
76
  }
77
+ interface ClickCardOptions {
78
+ enabled?: boolean;
79
+ width?: number;
80
+ offset?: [number, number];
81
+ }
82
+ interface ClickZoomOptions {
83
+ enabled?: boolean;
84
+ }
85
+ type CardsMode = 'none' | 'hover' | 'click' | 'both';
77
86
  interface CameraFitOptions {
78
87
  /** Enable auto-fitting camera to node bounds */
79
88
  enabled?: boolean;
@@ -97,7 +106,11 @@ interface NeuronWebProps {
97
106
  isLoading?: boolean;
98
107
  error?: string | null;
99
108
  selectedNode?: NeuronNode | null;
109
+ /** Programmatically focus/select a node by slug (or id fallback). */
100
110
  focusNodeSlug?: string | null;
111
+ /** Called after a focusNodeSlug request is processed. */
112
+ onFocusConsumed?: () => void;
113
+ /** Limit the rendered graph to these node slugs/ids; null shows all, empty array shows none. */
101
114
  visibleNodeSlugs?: string[] | null;
102
115
  onNodeClick?: (node: NeuronNode) => void;
103
116
  onNodeDoubleClick?: (node: NeuronNode) => void;
@@ -110,6 +123,9 @@ interface NeuronWebProps {
110
123
  onStudyPathComplete?: () => void;
111
124
  layout?: NeuronLayoutOptions;
112
125
  cameraFit?: CameraFitOptions;
126
+ cardsMode?: CardsMode;
127
+ clickCard?: ClickCardOptions;
128
+ clickZoom?: ClickZoomOptions;
113
129
  theme?: NeuronWebThemeOverride;
114
130
  domainColors?: Record<string, string>;
115
131
  renderNodeHover?: (node: NeuronVisualNode) => React__default.ReactNode;
@@ -121,6 +137,6 @@ interface NeuronWebProps {
121
137
  ariaLabel?: string;
122
138
  }
123
139
 
124
- declare function NeuronWeb({ graphData, className, style, fullHeight, isFullScreen, isLoading, error, renderEmptyState, renderLoadingState, ariaLabel, theme, layout, cameraFit, renderNodeHover, hoverCard, onNodeHover, onNodeClick, onNodeDoubleClick, onNodeFocused, onBackgroundClick, performanceMode, }: NeuronWebProps): React__default.ReactElement;
140
+ declare function NeuronWeb({ graphData, className, style, fullHeight, isFullScreen, isLoading, error, focusNodeSlug, onFocusConsumed, visibleNodeSlugs, renderEmptyState, renderLoadingState, ariaLabel, theme, layout, cameraFit, cardsMode, clickCard, clickZoom, renderNodeHover, renderNodeDetail, hoverCard, onNodeHover, onNodeClick, onNodeDoubleClick, onNodeFocused, onBackgroundClick, performanceMode, }: NeuronWebProps): React__default.ReactElement;
125
141
 
126
- export { type CameraFitOptions as C, type HoverCardOptions as H, type NeuronWebTheme as N, type NeuronWebThemeOverride as a, type NeuronLayoutOptions as b, NeuronWeb as c, type NeuronWebProps as d, type NeuronLayoutMode as e };
142
+ export { type CameraFitOptions as C, type HoverCardOptions as H, type NeuronWebTheme as N, type NeuronWebThemeOverride as a, type NeuronLayoutOptions as b, NeuronWeb as c, type NeuronWebProps as d, type NeuronLayoutMode as e, type ClickCardOptions as f, type ClickZoomOptions as g, type CardsMode as h };
@@ -1049,13 +1049,20 @@ function NeuronWeb({
1049
1049
  isFullScreen,
1050
1050
  isLoading,
1051
1051
  error,
1052
+ focusNodeSlug,
1053
+ onFocusConsumed,
1054
+ visibleNodeSlugs,
1052
1055
  renderEmptyState,
1053
1056
  renderLoadingState,
1054
1057
  ariaLabel,
1055
1058
  theme,
1056
1059
  layout,
1057
1060
  cameraFit,
1061
+ cardsMode,
1062
+ clickCard,
1063
+ clickZoom,
1058
1064
  renderNodeHover,
1065
+ renderNodeDetail,
1059
1066
  hoverCard,
1060
1067
  onNodeHover,
1061
1068
  onNodeClick,
@@ -1066,9 +1073,34 @@ function NeuronWeb({
1066
1073
  }) {
1067
1074
  const containerRef = react.useRef(null);
1068
1075
  const hoverCardRef = react.useRef(null);
1076
+ const clickCardRef = react.useRef(null);
1069
1077
  const [hoveredNodeId, setHoveredNodeId] = react.useState(null);
1070
1078
  const [selectedNodeId, setSelectedNodeId] = react.useState(null);
1071
1079
  const fitStateRef = react.useRef({ hasFit: false, signature: "" });
1080
+ const firstFilterChangeRef = react.useRef(true);
1081
+ const [filterTransitioning, setFilterTransitioning] = react.useState(false);
1082
+ const filteredGraphData = react.useMemo(() => {
1083
+ if (visibleNodeSlugs === null || visibleNodeSlugs === void 0) {
1084
+ return graphData;
1085
+ }
1086
+ const allowed = new Set(visibleNodeSlugs);
1087
+ const filteredNodes = graphData.nodes.filter(
1088
+ (node) => allowed.has(node.slug) || allowed.has(node.id)
1089
+ );
1090
+ const filteredEdges = graphData.edges.filter(
1091
+ (edge) => allowed.has(edge.from) && allowed.has(edge.to)
1092
+ );
1093
+ const filteredStoryBeats = graphData.storyBeats?.map((beat) => ({
1094
+ ...beat,
1095
+ nodeIds: beat.nodeIds.filter((id) => allowed.has(id))
1096
+ })).filter((beat) => beat.nodeIds.length >= 2);
1097
+ return {
1098
+ ...graphData,
1099
+ nodes: filteredNodes,
1100
+ edges: filteredEdges,
1101
+ storyBeats: filteredStoryBeats
1102
+ };
1103
+ }, [graphData, visibleNodeSlugs]);
1072
1104
  const resolvedTheme = react.useMemo(
1073
1105
  () => ({
1074
1106
  ...DEFAULT_THEME,
@@ -1079,13 +1111,27 @@ function NeuronWeb({
1079
1111
  }),
1080
1112
  [theme]
1081
1113
  );
1114
+ const filterSignature = react.useMemo(() => {
1115
+ if (visibleNodeSlugs === null || visibleNodeSlugs === void 0) return "all";
1116
+ return visibleNodeSlugs.length ? visibleNodeSlugs.join("|") : "none";
1117
+ }, [visibleNodeSlugs]);
1118
+ react.useEffect(() => {
1119
+ if (firstFilterChangeRef.current) {
1120
+ firstFilterChangeRef.current = false;
1121
+ return;
1122
+ }
1123
+ setFilterTransitioning(true);
1124
+ const timer = setTimeout(() => setFilterTransitioning(false), resolvedTheme.animation.transitionDuration);
1125
+ return () => clearTimeout(timer);
1126
+ }, [filterSignature, resolvedTheme.animation.transitionDuration]);
1127
+ const workingGraph = filteredGraphData;
1082
1128
  const resolvedPerformanceMode = react.useMemo(() => {
1083
1129
  if (performanceMode && performanceMode !== "auto") return performanceMode;
1084
- const count = graphData.nodes.length;
1130
+ const count = workingGraph.nodes.length;
1085
1131
  if (count > 360) return "fallback";
1086
1132
  if (count > 180) return "degraded";
1087
1133
  return "normal";
1088
- }, [performanceMode, graphData.nodes.length]);
1134
+ }, [performanceMode, workingGraph.nodes.length]);
1089
1135
  const sceneManager = useSceneManager(containerRef, {
1090
1136
  backgroundColor: resolvedTheme.colors.background,
1091
1137
  cameraFov: 52,
@@ -1166,8 +1212,8 @@ function NeuronWeb({
1166
1212
  });
1167
1213
  }, [sceneManager, resolvedTheme]);
1168
1214
  const resolvedNodes = react.useMemo(
1169
- () => applyFuzzyLayout(graphData.nodes, layout),
1170
- [graphData.nodes, layout]
1215
+ () => applyFuzzyLayout(workingGraph.nodes, layout),
1216
+ [workingGraph.nodes, layout]
1171
1217
  );
1172
1218
  const resolvedCameraFit = react.useMemo(() => {
1173
1219
  const enabled = cameraFit?.enabled ?? Boolean(isFullScreen);
@@ -1193,7 +1239,7 @@ function NeuronWeb({
1193
1239
  }, [resolvedNodes]);
1194
1240
  const edgesBySlug = react.useMemo(() => {
1195
1241
  const map = /* @__PURE__ */ new Map();
1196
- graphData.edges.forEach((edge) => {
1242
+ workingGraph.edges.forEach((edge) => {
1197
1243
  const add = (slug) => {
1198
1244
  const list = map.get(slug);
1199
1245
  if (list) list.push(edge.id);
@@ -1203,11 +1249,23 @@ function NeuronWeb({
1203
1249
  add(edge.to);
1204
1250
  });
1205
1251
  return map;
1206
- }, [graphData.edges]);
1252
+ }, [workingGraph.edges]);
1253
+ const nodeByIdentifier = react.useMemo(() => {
1254
+ const map = /* @__PURE__ */ new Map();
1255
+ resolvedNodes.forEach((node) => {
1256
+ map.set(node.slug, node);
1257
+ map.set(node.id, node);
1258
+ });
1259
+ return map;
1260
+ }, [resolvedNodes]);
1207
1261
  const hoveredNode = hoveredNodeId ? nodeMap.get(hoveredNodeId) ?? null : null;
1208
- const hoverCardEnabled = (hoverCard?.enabled ?? true) && resolvedPerformanceMode !== "fallback";
1262
+ const hoverCardEnabled = (cardsMode ? cardsMode === "hover" || cardsMode === "both" : hoverCard?.enabled ?? true) && resolvedPerformanceMode !== "fallback";
1209
1263
  const hoverCardOffset = hoverCard?.offset ?? [18, 18];
1210
1264
  const hoverCardWidth = hoverCard?.width ?? 240;
1265
+ const clickCardEnabled = (cardsMode ? cardsMode === "click" || cardsMode === "both" : clickCard?.enabled ?? false) && resolvedPerformanceMode !== "fallback";
1266
+ const clickCardOffset = clickCard?.offset ?? [24, 24];
1267
+ const clickCardWidth = clickCard?.width ?? 320;
1268
+ const clickZoomEnabled = clickZoom?.enabled ?? true;
1211
1269
  react.useEffect(() => {
1212
1270
  if (!sceneManager || !nodeRenderer || !edgeRenderer) return;
1213
1271
  nodeRenderer.renderNodes(resolvedNodes);
@@ -1216,8 +1274,8 @@ function NeuronWeb({
1216
1274
  if (!node.position) return;
1217
1275
  positions.set(node.slug, new THREE__namespace.Vector3(...node.position));
1218
1276
  });
1219
- edgeRenderer.renderEdges(graphData.edges, positions);
1220
- }, [resolvedNodes, graphData.edges, sceneManager, nodeRenderer, edgeRenderer]);
1277
+ edgeRenderer.renderEdges(workingGraph.edges, positions);
1278
+ }, [resolvedNodes, workingGraph.edges, sceneManager, nodeRenderer, edgeRenderer]);
1221
1279
  react.useEffect(() => {
1222
1280
  if (!sceneManager) return;
1223
1281
  sceneManager.updateBackground(resolvedTheme.colors.background);
@@ -1283,6 +1341,22 @@ function NeuronWeb({
1283
1341
  hoverCardRef.current.style.transform = `translate(${x}px, ${y}px)`;
1284
1342
  }
1285
1343
  }
1344
+ if (clickCardRef.current && selectedNodeId) {
1345
+ const position = nodeRenderer.getNodePosition(selectedNodeId);
1346
+ const rect = containerRef.current?.getBoundingClientRect();
1347
+ if (position && rect) {
1348
+ const screen = sceneManager.worldToScreen(position);
1349
+ const cardWidth = clickCardRef.current.offsetWidth;
1350
+ const cardHeight = clickCardRef.current.offsetHeight;
1351
+ const rawX = screen.x - rect.left + clickCardOffset[0];
1352
+ const rawY = screen.y - rect.top + clickCardOffset[1];
1353
+ const maxX = Math.max(8, rect.width - cardWidth - 8);
1354
+ const maxY = Math.max(8, rect.height - cardHeight - 8);
1355
+ const x = Math.min(Math.max(rawX, 8), maxX);
1356
+ const y = Math.min(Math.max(rawY, 8), maxY);
1357
+ clickCardRef.current.style.transform = `translate(${x}px, ${y}px)`;
1358
+ }
1359
+ }
1286
1360
  });
1287
1361
  }, [
1288
1362
  sceneManager,
@@ -1290,12 +1364,64 @@ function NeuronWeb({
1290
1364
  edgeRenderer,
1291
1365
  animationController,
1292
1366
  hoveredNodeId,
1293
- hoverCardOffset
1367
+ hoverCardOffset,
1368
+ selectedNodeId,
1369
+ clickCardOffset
1294
1370
  ]);
1295
1371
  react.useEffect(() => {
1296
1372
  if (!interactionManager || !nodeRenderer) return;
1297
1373
  interactionManager.setTargets(nodeRenderer.getNodeObjects(), nodeMap);
1298
1374
  }, [interactionManager, nodeRenderer, nodeMap]);
1375
+ react.useEffect(() => {
1376
+ if (!nodeRenderer) return;
1377
+ if (hoveredNodeId && !nodeMap.has(hoveredNodeId)) {
1378
+ setHoveredNodeId(null);
1379
+ nodeRenderer.setHoveredNode(null);
1380
+ }
1381
+ }, [hoveredNodeId, nodeMap, nodeRenderer]);
1382
+ react.useEffect(() => {
1383
+ if (!nodeRenderer || !edgeRenderer) return;
1384
+ if (selectedNodeId && !nodeMap.has(selectedNodeId)) {
1385
+ setSelectedNodeId(null);
1386
+ nodeRenderer.setSelectedNode(null);
1387
+ edgeRenderer.setFocusEdges(null);
1388
+ }
1389
+ }, [selectedNodeId, nodeMap, nodeRenderer, edgeRenderer]);
1390
+ react.useEffect(() => {
1391
+ if (!focusNodeSlug || !nodeRenderer || !edgeRenderer) return;
1392
+ const node = nodeByIdentifier.get(focusNodeSlug);
1393
+ if (!node) {
1394
+ onFocusConsumed?.();
1395
+ return;
1396
+ }
1397
+ setSelectedNodeId(node.id);
1398
+ nodeRenderer.setSelectedNode(node.id);
1399
+ nodeRenderer.pulseNode(node.id);
1400
+ edgeRenderer.setFocusEdges(node.slug ? edgesBySlug.get(node.slug) ?? [] : []);
1401
+ if (clickZoomEnabled) {
1402
+ const nodePosition = nodeRenderer.getNodePosition(node.id);
1403
+ if (nodePosition) {
1404
+ animationController?.focusOnNode(nodePosition, () => {
1405
+ if (onNodeFocused) onNodeFocused(node);
1406
+ });
1407
+ } else if (onNodeFocused) {
1408
+ onNodeFocused(node);
1409
+ }
1410
+ } else if (onNodeFocused) {
1411
+ onNodeFocused(node);
1412
+ }
1413
+ onFocusConsumed?.();
1414
+ }, [
1415
+ focusNodeSlug,
1416
+ nodeByIdentifier,
1417
+ nodeRenderer,
1418
+ edgeRenderer,
1419
+ edgesBySlug,
1420
+ clickZoomEnabled,
1421
+ animationController,
1422
+ onNodeFocused,
1423
+ onFocusConsumed
1424
+ ]);
1299
1425
  react.useEffect(() => {
1300
1426
  if (!interactionManager || !nodeRenderer || !edgeRenderer) return;
1301
1427
  interactionManager.onNodeHover = (node) => {
@@ -1321,6 +1447,14 @@ function NeuronWeb({
1321
1447
  setSelectedNodeId(node.id);
1322
1448
  nodeRenderer.setSelectedNode(node.id);
1323
1449
  nodeRenderer.pulseNode(node.id);
1450
+ if (clickZoomEnabled) {
1451
+ const nodePosition = nodeRenderer.getNodePosition(node.id);
1452
+ if (nodePosition) {
1453
+ animationController?.focusOnNode(nodePosition, () => {
1454
+ if (onNodeFocused) onNodeFocused(node);
1455
+ });
1456
+ }
1457
+ }
1324
1458
  const slug = nodeSlugById.get(node.id);
1325
1459
  edgeRenderer.setFocusEdges(slug ? edgesBySlug.get(slug) ?? [] : []);
1326
1460
  if (onNodeClick) {
@@ -1355,6 +1489,7 @@ function NeuronWeb({
1355
1489
  animationController,
1356
1490
  hoveredNodeId,
1357
1491
  selectedNodeId,
1492
+ clickZoomEnabled,
1358
1493
  onNodeHover,
1359
1494
  onNodeClick,
1360
1495
  onNodeDoubleClick,
@@ -1385,6 +1520,27 @@ function NeuronWeb({
1385
1520
  if (!resolvedNodes.length) {
1386
1521
  return /* @__PURE__ */ jsxRuntime.jsx("div", { className, style, "aria-label": ariaLabel, children: renderEmptyState ? renderEmptyState() : /* @__PURE__ */ jsxRuntime.jsx("div", { children: "No data" }) });
1387
1522
  }
1523
+ const sceneTransitionStyle = {
1524
+ position: "absolute",
1525
+ inset: 0,
1526
+ width: "100%",
1527
+ height: "100%",
1528
+ transition: `opacity ${resolvedTheme.animation.transitionDuration}ms ease, transform ${resolvedTheme.animation.transitionDuration}ms ease, filter ${resolvedTheme.animation.transitionDuration}ms ease`,
1529
+ opacity: filterTransitioning ? 0.85 : 1,
1530
+ transform: filterTransitioning ? "scale(0.985)" : "scale(1)",
1531
+ filter: filterTransitioning ? "blur(1px)" : "blur(0px)",
1532
+ transformOrigin: "center",
1533
+ willChange: "transform, filter, opacity"
1534
+ };
1535
+ const filterOverlayStyle = {
1536
+ position: "absolute",
1537
+ inset: 0,
1538
+ pointerEvents: "none",
1539
+ background: "radial-gradient(circle at 50% 35%, rgba(255,255,255,0.35), transparent 60%)",
1540
+ opacity: filterTransitioning ? 1 : 0,
1541
+ transition: `opacity ${resolvedTheme.animation.transitionDuration}ms ease`,
1542
+ zIndex: 2
1543
+ };
1388
1544
  const resolvedStyle = {
1389
1545
  position: isFullScreen ? "fixed" : "relative",
1390
1546
  inset: isFullScreen ? 0 : void 0,
@@ -1406,9 +1562,10 @@ function NeuronWeb({
1406
1562
  "div",
1407
1563
  {
1408
1564
  ref: containerRef,
1409
- style: { position: "absolute", inset: 0, width: "100%", height: "100%" }
1565
+ style: sceneTransitionStyle
1410
1566
  }
1411
1567
  ),
1568
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: filterOverlayStyle }),
1412
1569
  hoverCardEnabled && hoveredNode && /* @__PURE__ */ jsxRuntime.jsx(
1413
1570
  "div",
1414
1571
  {
@@ -1435,6 +1592,33 @@ function NeuronWeb({
1435
1592
  /* @__PURE__ */ jsxRuntime.jsx("div", { style: { opacity: 0.75 }, children: typeof hoveredNode.metadata?.summary === "string" ? hoveredNode.metadata.summary : "Click to focus this node and explore connections." })
1436
1593
  ] })
1437
1594
  }
1595
+ ),
1596
+ clickCardEnabled && selectedNodeId && /* @__PURE__ */ jsxRuntime.jsx(
1597
+ "div",
1598
+ {
1599
+ ref: clickCardRef,
1600
+ style: {
1601
+ position: "absolute",
1602
+ width: clickCardWidth,
1603
+ pointerEvents: "auto",
1604
+ padding: "14px 16px",
1605
+ borderRadius: 14,
1606
+ background: "linear-gradient(140deg, rgba(10, 14, 32, 0.98) 0%, rgba(20, 26, 58, 0.94) 100%)",
1607
+ border: "1px solid rgba(140, 170, 255, 0.4)",
1608
+ boxShadow: "0 22px 60px rgba(5, 10, 30, 0.6)",
1609
+ color: resolvedTheme.colors.labelText,
1610
+ fontFamily: resolvedTheme.typography.labelFontFamily,
1611
+ fontSize: 13,
1612
+ zIndex: 5,
1613
+ opacity: 0.98,
1614
+ transition: `opacity ${resolvedTheme.animation.hoverCardFadeDuration}ms ease`,
1615
+ transform: `translate(${clickCardOffset[0]}px, ${clickCardOffset[1]}px)`
1616
+ },
1617
+ children: renderNodeDetail ? renderNodeDetail(nodeMap.get(selectedNodeId)) : /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
1618
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 14, fontWeight: 600, marginBottom: 8 }, children: nodeMap.get(selectedNodeId)?.label ?? "Selected node" }),
1619
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { opacity: 0.75 }, children: typeof nodeMap.get(selectedNodeId)?.metadata?.summary === "string" ? nodeMap.get(selectedNodeId)?.metadata?.summary : "Click another node to explore more details." })
1620
+ ] })
1621
+ }
1438
1622
  )
1439
1623
  ]
1440
1624
  }
@@ -1511,5 +1695,5 @@ exports.NeuronWeb = NeuronWeb;
1511
1695
  exports.SceneManager = SceneManager;
1512
1696
  exports.ThemeEngine = ThemeEngine;
1513
1697
  exports.applyFuzzyLayout = applyFuzzyLayout;
1514
- //# sourceMappingURL=chunk-GGFSQOFW.cjs.map
1515
- //# sourceMappingURL=chunk-GGFSQOFW.cjs.map
1698
+ //# sourceMappingURL=chunk-AU557QID.cjs.map
1699
+ //# sourceMappingURL=chunk-AU557QID.cjs.map