@omiron33/omi-neuron-web 0.2.16 → 0.2.19

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
@@ -686,7 +686,7 @@ export interface NeuronWebProps {
686
686
 
687
687
  Props currently used inside `NeuronWeb` (others are reserved for future use):
688
688
 
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`.
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`, `studyPathRequest`, `onStudyPathComplete`.
690
690
  - Used: `cameraFit` (auto-fit bounds to a viewport fraction).
691
691
  - Reserved (declared but not used in the component yet): `selectedNode`, `onEdgeClick`, `onCameraChange`, `studyPathRequest`, `onStudyPathComplete`, `domainColors`, `graphData.storyBeats`.
692
692
 
@@ -847,6 +847,37 @@ Semantics:
847
847
  <NeuronWeb graphData={graphData} visibleNodeSlugs={[]} />;
848
848
  ```
849
849
 
850
+ ### Study path playback (follow the path between nodes)
851
+
852
+ Use `studyPathRequest` to step through an ordered list of nodes. Each step:
853
+ - selects the node
854
+ - tweens the camera to the node (if `clickZoom.enabled`)
855
+ - highlights the edge between the current and next step
856
+
857
+ ```tsx
858
+ <NeuronWeb
859
+ graphData={graphData}
860
+ studyPathRequest={{
861
+ steps: [
862
+ { nodeSlug: 'uap', label: 'Start' },
863
+ { nodeSlug: 'neph', label: 'Next' },
864
+ { nodeSlug: 'jude6', label: 'Finish' },
865
+ ],
866
+ stepDurationMs: 4200,
867
+ }}
868
+ onStudyPathComplete={() => console.log('study path done')}
869
+ />
870
+ ```
871
+
872
+ Fallback form (two-step path):
873
+
874
+ ```tsx
875
+ <NeuronWeb
876
+ graphData={graphData}
877
+ studyPathRequest={{ fromNodeId: 'uap', toNodeId: 'neph' }}
878
+ />
879
+ ```
880
+
850
881
  ### Click cards + click zoom
851
882
 
852
883
  Enable a persistent card on click and optional zoom-to-node behavior:
@@ -859,6 +890,10 @@ Enable a persistent card on click and optional zoom-to-node behavior:
859
890
  />
860
891
  ```
861
892
 
893
+ Orbit pivot behavior:
894
+ - On pointer down, the orbit target shifts to the cursor (or the node under it),
895
+ so rotating after a focus doesn’t stay locked to the previously focused node.
896
+
862
897
  ### Card mode (global override)
863
898
 
864
899
  `cardsMode` lets you force card behavior irrespective of `hoverCard.enabled` or `clickCard.enabled`
@@ -6,9 +6,30 @@ interface NeuronStoryBeat {
6
6
  label: string;
7
7
  nodeIds: string[];
8
8
  }
9
+ interface StudyPathStep {
10
+ nodeSlug?: string;
11
+ nodeId?: string;
12
+ label?: string;
13
+ summary?: string;
14
+ }
9
15
  interface StudyPathRequest {
10
- fromNodeId: string;
11
- toNodeId: string;
16
+ /**
17
+ * Ordered steps to follow (slugs or ids). When provided, this takes precedence.
18
+ */
19
+ steps?: StudyPathStep[];
20
+ /**
21
+ * Optional label for the study path (consumer UI usage).
22
+ */
23
+ label?: string;
24
+ /**
25
+ * Time to hold each step before advancing (ms). Defaults to 4200.
26
+ */
27
+ stepDurationMs?: number;
28
+ /**
29
+ * Minimal fallback when steps are not provided.
30
+ */
31
+ fromNodeId?: string;
32
+ toNodeId?: string;
12
33
  }
13
34
  interface NeuronWebTheme {
14
35
  colors: {
@@ -137,6 +158,6 @@ interface NeuronWebProps {
137
158
  ariaLabel?: string;
138
159
  }
139
160
 
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;
161
+ declare function NeuronWeb({ graphData, className, style, fullHeight, isFullScreen, isLoading, error, focusNodeSlug, onFocusConsumed, visibleNodeSlugs, renderEmptyState, renderLoadingState, ariaLabel, theme, layout, cameraFit, cardsMode, clickCard, clickZoom, studyPathRequest, onStudyPathComplete, renderNodeHover, renderNodeDetail, hoverCard, onNodeHover, onNodeClick, onNodeDoubleClick, onNodeFocused, onBackgroundClick, performanceMode, }: NeuronWebProps): React__default.ReactElement;
141
162
 
142
163
  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 };
@@ -6,9 +6,30 @@ interface NeuronStoryBeat {
6
6
  label: string;
7
7
  nodeIds: string[];
8
8
  }
9
+ interface StudyPathStep {
10
+ nodeSlug?: string;
11
+ nodeId?: string;
12
+ label?: string;
13
+ summary?: string;
14
+ }
9
15
  interface StudyPathRequest {
10
- fromNodeId: string;
11
- toNodeId: string;
16
+ /**
17
+ * Ordered steps to follow (slugs or ids). When provided, this takes precedence.
18
+ */
19
+ steps?: StudyPathStep[];
20
+ /**
21
+ * Optional label for the study path (consumer UI usage).
22
+ */
23
+ label?: string;
24
+ /**
25
+ * Time to hold each step before advancing (ms). Defaults to 4200.
26
+ */
27
+ stepDurationMs?: number;
28
+ /**
29
+ * Minimal fallback when steps are not provided.
30
+ */
31
+ fromNodeId?: string;
32
+ toNodeId?: string;
12
33
  }
13
34
  interface NeuronWebTheme {
14
35
  colors: {
@@ -137,6 +158,6 @@ interface NeuronWebProps {
137
158
  ariaLabel?: string;
138
159
  }
139
160
 
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;
161
+ declare function NeuronWeb({ graphData, className, style, fullHeight, isFullScreen, isLoading, error, focusNodeSlug, onFocusConsumed, visibleNodeSlugs, renderEmptyState, renderLoadingState, ariaLabel, theme, layout, cameraFit, cardsMode, clickCard, clickZoom, studyPathRequest, onStudyPathComplete, renderNodeHover, renderNodeDetail, hoverCard, onNodeHover, onNodeClick, onNodeDoubleClick, onNodeFocused, onBackgroundClick, performanceMode, }: NeuronWebProps): React__default.ReactElement;
141
162
 
142
163
  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 };
@@ -1061,6 +1061,8 @@ function NeuronWeb({
1061
1061
  cardsMode,
1062
1062
  clickCard,
1063
1063
  clickZoom,
1064
+ studyPathRequest,
1065
+ onStudyPathComplete,
1064
1066
  renderNodeHover,
1065
1067
  renderNodeDetail,
1066
1068
  hoverCard,
@@ -1076,9 +1078,12 @@ function NeuronWeb({
1076
1078
  const clickCardRef = react.useRef(null);
1077
1079
  const [hoveredNodeId, setHoveredNodeId] = react.useState(null);
1078
1080
  const [selectedNodeId, setSelectedNodeId] = react.useState(null);
1081
+ const [studyPathPlayer, setStudyPathPlayer] = react.useState(null);
1079
1082
  const fitStateRef = react.useRef({ hasFit: false, signature: "" });
1080
1083
  const firstFilterChangeRef = react.useRef(true);
1081
1084
  const [filterTransitioning, setFilterTransitioning] = react.useState(false);
1085
+ const pathEdgeIdsRef = react.useRef([]);
1086
+ const focusEdgesRef = react.useRef(null);
1082
1087
  const filteredGraphData = react.useMemo(() => {
1083
1088
  if (visibleNodeSlugs === null || visibleNodeSlugs === void 0) {
1084
1089
  return graphData;
@@ -1250,6 +1255,43 @@ function NeuronWeb({
1250
1255
  });
1251
1256
  return map;
1252
1257
  }, [workingGraph.edges]);
1258
+ const applyFocusEdges = react.useCallback(
1259
+ (edgeIds) => {
1260
+ if (!edgeRenderer) return;
1261
+ focusEdgesRef.current = edgeIds;
1262
+ const merged = new Set(edgeIds ?? []);
1263
+ pathEdgeIdsRef.current.forEach((id) => merged.add(id));
1264
+ edgeRenderer.setFocusEdges(merged.size ? Array.from(merged) : null);
1265
+ },
1266
+ [edgeRenderer]
1267
+ );
1268
+ const resolvedStudyPathSteps = react.useMemo(() => {
1269
+ if (!studyPathRequest) return null;
1270
+ if (studyPathRequest.steps && studyPathRequest.steps.length) {
1271
+ return studyPathRequest.steps;
1272
+ }
1273
+ if (studyPathRequest.fromNodeId && studyPathRequest.toNodeId) {
1274
+ return [
1275
+ { nodeId: studyPathRequest.fromNodeId },
1276
+ { nodeId: studyPathRequest.toNodeId }
1277
+ ];
1278
+ }
1279
+ return null;
1280
+ }, [studyPathRequest]);
1281
+ react.useEffect(() => {
1282
+ if (!resolvedStudyPathSteps || resolvedStudyPathSteps.length === 0) {
1283
+ setStudyPathPlayer(null);
1284
+ pathEdgeIdsRef.current = [];
1285
+ applyFocusEdges(focusEdgesRef.current);
1286
+ return;
1287
+ }
1288
+ setStudyPathPlayer({
1289
+ steps: resolvedStudyPathSteps,
1290
+ index: 0,
1291
+ playing: true,
1292
+ stepDurationMs: studyPathRequest?.stepDurationMs ?? 4200
1293
+ });
1294
+ }, [resolvedStudyPathSteps, studyPathRequest?.stepDurationMs, applyFocusEdges]);
1253
1295
  const nodeByIdentifier = react.useMemo(() => {
1254
1296
  const map = /* @__PURE__ */ new Map();
1255
1297
  resolvedNodes.forEach((node) => {
@@ -1266,6 +1308,70 @@ function NeuronWeb({
1266
1308
  const clickCardOffset = clickCard?.offset ?? [24, 24];
1267
1309
  const clickCardWidth = clickCard?.width ?? 320;
1268
1310
  const clickZoomEnabled = clickZoom?.enabled ?? true;
1311
+ react.useEffect(() => {
1312
+ if (!studyPathPlayer) return;
1313
+ const step = studyPathPlayer.steps[studyPathPlayer.index];
1314
+ const stepKey = step?.nodeSlug ?? step?.nodeId ?? null;
1315
+ const node = stepKey ? nodeByIdentifier.get(stepKey) ?? null : null;
1316
+ if (node && nodeRenderer && edgeRenderer) {
1317
+ setSelectedNodeId(node.id);
1318
+ nodeRenderer.setSelectedNode(node.id);
1319
+ nodeRenderer.pulseNode(node.id);
1320
+ if (clickZoomEnabled) {
1321
+ const nodePosition = nodeRenderer.getNodePosition(node.id);
1322
+ if (nodePosition) {
1323
+ animationController?.focusOnNode(nodePosition, () => {
1324
+ if (onNodeFocused) onNodeFocused(node);
1325
+ });
1326
+ }
1327
+ }
1328
+ const slug = node.slug;
1329
+ applyFocusEdges(slug ? edgesBySlug.get(slug) ?? [] : []);
1330
+ }
1331
+ const nextStep = studyPathPlayer.index < studyPathPlayer.steps.length - 1 ? studyPathPlayer.steps[studyPathPlayer.index + 1] : null;
1332
+ const currentSlug = node?.slug ?? (step?.nodeSlug ?? null);
1333
+ const nextKey = nextStep?.nodeSlug ?? nextStep?.nodeId ?? null;
1334
+ const nextNode = nextKey ? nodeByIdentifier.get(nextKey) ?? null : null;
1335
+ const nextSlug = nextNode?.slug ?? (nextStep?.nodeSlug ?? null);
1336
+ if (currentSlug && nextSlug) {
1337
+ pathEdgeIdsRef.current = workingGraph.edges.filter(
1338
+ (edge) => edge.from === currentSlug && edge.to === nextSlug || edge.to === currentSlug && edge.from === nextSlug
1339
+ ).map((edge) => edge.id);
1340
+ } else {
1341
+ pathEdgeIdsRef.current = [];
1342
+ }
1343
+ applyFocusEdges(focusEdgesRef.current);
1344
+ }, [
1345
+ studyPathPlayer,
1346
+ nodeByIdentifier,
1347
+ nodeRenderer,
1348
+ edgeRenderer,
1349
+ edgesBySlug,
1350
+ workingGraph.edges,
1351
+ animationController,
1352
+ clickZoomEnabled,
1353
+ onNodeFocused,
1354
+ applyFocusEdges
1355
+ ]);
1356
+ react.useEffect(() => {
1357
+ if (!studyPathPlayer || !studyPathPlayer.playing) return;
1358
+ const timer = window.setTimeout(() => {
1359
+ setStudyPathPlayer((prev) => {
1360
+ if (!prev) return prev;
1361
+ if (prev.index >= prev.steps.length - 1) {
1362
+ if (onStudyPathComplete) onStudyPathComplete();
1363
+ return { ...prev, playing: false };
1364
+ }
1365
+ return { ...prev, index: prev.index + 1 };
1366
+ });
1367
+ }, studyPathPlayer.stepDurationMs);
1368
+ return () => window.clearTimeout(timer);
1369
+ }, [studyPathPlayer, onStudyPathComplete]);
1370
+ react.useEffect(() => {
1371
+ if (!studyPathPlayer || studyPathPlayer.playing) return;
1372
+ pathEdgeIdsRef.current = [];
1373
+ applyFocusEdges(focusEdgesRef.current);
1374
+ }, [studyPathPlayer, applyFocusEdges]);
1269
1375
  react.useEffect(() => {
1270
1376
  if (!sceneManager || !nodeRenderer || !edgeRenderer) return;
1271
1377
  nodeRenderer.renderNodes(resolvedNodes);
@@ -1280,6 +1386,55 @@ function NeuronWeb({
1280
1386
  if (!sceneManager) return;
1281
1387
  sceneManager.updateBackground(resolvedTheme.colors.background);
1282
1388
  }, [sceneManager, resolvedTheme.colors.background]);
1389
+ react.useEffect(() => {
1390
+ if (!sceneManager || !nodeRenderer) return;
1391
+ const raycaster = new THREE__namespace.Raycaster();
1392
+ const pointer = new THREE__namespace.Vector2();
1393
+ const tempDir = new THREE__namespace.Vector3();
1394
+ const plane = new THREE__namespace.Plane();
1395
+ const intersection = new THREE__namespace.Vector3();
1396
+ const updatePointer = (event) => {
1397
+ const rect = sceneManager.renderer.domElement.getBoundingClientRect();
1398
+ pointer.x = (event.clientX - rect.left) / rect.width * 2 - 1;
1399
+ pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
1400
+ };
1401
+ const onPointerDown = (event) => {
1402
+ updatePointer(event);
1403
+ raycaster.setFromCamera(pointer, sceneManager.camera);
1404
+ const nodes = nodeRenderer.getNodeObjects();
1405
+ if (nodes.length) {
1406
+ const intersects = raycaster.intersectObjects(nodes, true);
1407
+ if (intersects.length) {
1408
+ const hit = intersects[0].object;
1409
+ const nodeId = hit.userData?.nodeId;
1410
+ if (nodeId) {
1411
+ const nodePosition = nodeRenderer.getNodePosition(nodeId);
1412
+ if (nodePosition) {
1413
+ sceneManager.controls.target.copy(nodePosition);
1414
+ sceneManager.controls.update();
1415
+ return;
1416
+ }
1417
+ }
1418
+ if (intersects[0].point) {
1419
+ sceneManager.controls.target.copy(intersects[0].point);
1420
+ sceneManager.controls.update();
1421
+ return;
1422
+ }
1423
+ }
1424
+ }
1425
+ sceneManager.camera.getWorldDirection(tempDir).normalize();
1426
+ plane.setFromNormalAndCoplanarPoint(tempDir, sceneManager.controls.target.clone());
1427
+ if (raycaster.ray.intersectPlane(plane, intersection)) {
1428
+ sceneManager.controls.target.copy(intersection);
1429
+ sceneManager.controls.update();
1430
+ }
1431
+ };
1432
+ const dom = sceneManager.renderer.domElement;
1433
+ dom.addEventListener("pointerdown", onPointerDown);
1434
+ return () => {
1435
+ dom.removeEventListener("pointerdown", onPointerDown);
1436
+ };
1437
+ }, [sceneManager, nodeRenderer]);
1283
1438
  const cameraFitSuspended = Boolean(selectedNodeId || focusNodeSlug);
1284
1439
  react.useEffect(() => {
1285
1440
  if (!sceneManager || !animationController || !resolvedCameraFit.enabled) return;
@@ -1393,9 +1548,9 @@ function NeuronWeb({
1393
1548
  if (selectedNodeId && !nodeMap.has(selectedNodeId)) {
1394
1549
  setSelectedNodeId(null);
1395
1550
  nodeRenderer.setSelectedNode(null);
1396
- edgeRenderer.setFocusEdges(null);
1551
+ applyFocusEdges(null);
1397
1552
  }
1398
- }, [selectedNodeId, nodeMap, nodeRenderer, edgeRenderer]);
1553
+ }, [selectedNodeId, nodeMap, nodeRenderer, edgeRenderer, applyFocusEdges]);
1399
1554
  react.useEffect(() => {
1400
1555
  if (!focusNodeSlug || !nodeRenderer || !edgeRenderer) return;
1401
1556
  const node = nodeByIdentifier.get(focusNodeSlug);
@@ -1406,7 +1561,7 @@ function NeuronWeb({
1406
1561
  setSelectedNodeId(node.id);
1407
1562
  nodeRenderer.setSelectedNode(node.id);
1408
1563
  nodeRenderer.pulseNode(node.id);
1409
- edgeRenderer.setFocusEdges(node.slug ? edgesBySlug.get(node.slug) ?? [] : []);
1564
+ applyFocusEdges(node.slug ? edgesBySlug.get(node.slug) ?? [] : []);
1410
1565
  if (clickZoomEnabled) {
1411
1566
  const nodePosition = nodeRenderer.getNodePosition(node.id);
1412
1567
  if (nodePosition) {
@@ -1429,7 +1584,8 @@ function NeuronWeb({
1429
1584
  clickZoomEnabled,
1430
1585
  animationController,
1431
1586
  onNodeFocused,
1432
- onFocusConsumed
1587
+ onFocusConsumed,
1588
+ applyFocusEdges
1433
1589
  ]);
1434
1590
  react.useEffect(() => {
1435
1591
  if (!interactionManager || !nodeRenderer || !edgeRenderer) return;
@@ -1439,13 +1595,13 @@ function NeuronWeb({
1439
1595
  nodeRenderer.setHoveredNode(nodeId);
1440
1596
  if (nodeId) {
1441
1597
  const slug = nodeSlugById.get(nodeId);
1442
- edgeRenderer.setFocusEdges(slug ? edgesBySlug.get(slug) ?? [] : []);
1598
+ applyFocusEdges(slug ? edgesBySlug.get(slug) ?? [] : []);
1443
1599
  } else {
1444
1600
  const selectedSlug = selectedNodeId ? nodeSlugById.get(selectedNodeId) : null;
1445
1601
  if (selectedSlug) {
1446
- edgeRenderer.setFocusEdges(edgesBySlug.get(selectedSlug) ?? []);
1602
+ applyFocusEdges(edgesBySlug.get(selectedSlug) ?? []);
1447
1603
  } else {
1448
- edgeRenderer.setFocusEdges(null);
1604
+ applyFocusEdges(null);
1449
1605
  }
1450
1606
  }
1451
1607
  if (onNodeHover) {
@@ -1465,7 +1621,7 @@ function NeuronWeb({
1465
1621
  }
1466
1622
  }
1467
1623
  const slug = nodeSlugById.get(node.id);
1468
- edgeRenderer.setFocusEdges(slug ? edgesBySlug.get(slug) ?? [] : []);
1624
+ applyFocusEdges(slug ? edgesBySlug.get(slug) ?? [] : []);
1469
1625
  if (onNodeClick) {
1470
1626
  onNodeClick(node);
1471
1627
  }
@@ -1485,7 +1641,7 @@ function NeuronWeb({
1485
1641
  setSelectedNodeId(null);
1486
1642
  nodeRenderer.setSelectedNode(null);
1487
1643
  if (!hoveredNodeId) {
1488
- edgeRenderer.setFocusEdges(null);
1644
+ applyFocusEdges(null);
1489
1645
  }
1490
1646
  if (onBackgroundClick) onBackgroundClick();
1491
1647
  };
@@ -1503,7 +1659,8 @@ function NeuronWeb({
1503
1659
  onNodeClick,
1504
1660
  onNodeDoubleClick,
1505
1661
  onNodeFocused,
1506
- onBackgroundClick
1662
+ onBackgroundClick,
1663
+ applyFocusEdges
1507
1664
  ]);
1508
1665
  react.useEffect(() => {
1509
1666
  if (!sceneManager || !interactionManager) return;
@@ -1704,5 +1861,5 @@ exports.NeuronWeb = NeuronWeb;
1704
1861
  exports.SceneManager = SceneManager;
1705
1862
  exports.ThemeEngine = ThemeEngine;
1706
1863
  exports.applyFuzzyLayout = applyFuzzyLayout;
1707
- //# sourceMappingURL=chunk-MVSWEBGR.cjs.map
1708
- //# sourceMappingURL=chunk-MVSWEBGR.cjs.map
1864
+ //# sourceMappingURL=chunk-OUO3CKBM.cjs.map
1865
+ //# sourceMappingURL=chunk-OUO3CKBM.cjs.map