@dxos/plugin-explorer 0.8.4-main.e8ec1fe → 0.8.4-main.effb148878

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 (271) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/neutral/ExplorerArticle-LLNHXWNG.mjs +420 -0
  3. package/dist/lib/neutral/ExplorerArticle-LLNHXWNG.mjs.map +7 -0
  4. package/dist/lib/neutral/ExplorerPlugin.mjs +10 -0
  5. package/dist/lib/neutral/capabilities/index.mjs +11 -0
  6. package/dist/lib/neutral/capabilities/index.mjs.map +7 -0
  7. package/dist/lib/neutral/chunk-7FSP4SPO.mjs +69 -0
  8. package/dist/lib/neutral/chunk-7FSP4SPO.mjs.map +7 -0
  9. package/dist/lib/neutral/chunk-CWN2BELW.mjs +287 -0
  10. package/dist/lib/neutral/chunk-CWN2BELW.mjs.map +7 -0
  11. package/dist/lib/neutral/chunk-GRJXLL4Z.mjs +25 -0
  12. package/dist/lib/neutral/chunk-GRJXLL4Z.mjs.map +7 -0
  13. package/dist/lib/neutral/chunk-IKHJV3Q4.mjs +20 -0
  14. package/dist/lib/neutral/chunk-IKHJV3Q4.mjs.map +7 -0
  15. package/dist/lib/neutral/chunk-LL3PXKB5.mjs +40 -0
  16. package/dist/lib/neutral/chunk-LL3PXKB5.mjs.map +7 -0
  17. package/dist/lib/{node-esm/chunk-NPIP4VEH.mjs → neutral/components/index.mjs} +890 -314
  18. package/dist/lib/neutral/components/index.mjs.map +7 -0
  19. package/dist/lib/neutral/containers/index.mjs +9 -0
  20. package/dist/lib/neutral/containers/index.mjs.map +7 -0
  21. package/dist/lib/neutral/create-object-F6TKVAGV.mjs +39 -0
  22. package/dist/lib/neutral/create-object-F6TKVAGV.mjs.map +7 -0
  23. package/dist/lib/neutral/hooks/index.mjs +45 -0
  24. package/dist/lib/neutral/hooks/index.mjs.map +7 -0
  25. package/dist/lib/neutral/index.mjs +14 -0
  26. package/dist/lib/neutral/meta.json +1 -0
  27. package/dist/lib/{browser → neutral}/meta.mjs +1 -1
  28. package/dist/lib/neutral/plugin.mjs +12 -0
  29. package/dist/lib/neutral/plugin.mjs.map +7 -0
  30. package/dist/lib/neutral/react-surface-APBW2VQG.mjs +26 -0
  31. package/dist/lib/neutral/react-surface-APBW2VQG.mjs.map +7 -0
  32. package/dist/lib/neutral/testing/index.mjs +193 -0
  33. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  34. package/dist/lib/neutral/translations.mjs +33 -0
  35. package/dist/lib/neutral/translations.mjs.map +7 -0
  36. package/dist/lib/{browser → neutral}/types/index.mjs +1 -2
  37. package/dist/types/data/cities.d.ts +4 -4
  38. package/dist/types/data/cities.d.ts.map +1 -1
  39. package/dist/types/data/countries-110m.d.ts +19 -22
  40. package/dist/types/data/countries-110m.d.ts.map +1 -1
  41. package/dist/types/src/ExplorerPlugin.d.ts +3 -1
  42. package/dist/types/src/ExplorerPlugin.d.ts.map +1 -1
  43. package/dist/types/src/ExplorerPlugin.test.d.ts +2 -0
  44. package/dist/types/src/ExplorerPlugin.test.d.ts.map +1 -0
  45. package/dist/types/src/capabilities/create-object.d.ts +11 -0
  46. package/dist/types/src/capabilities/create-object.d.ts.map +1 -0
  47. package/dist/types/src/capabilities/index.d.ts +8 -2
  48. package/dist/types/src/capabilities/index.d.ts.map +1 -1
  49. package/dist/types/src/capabilities/react-surface.d.ts +3 -2
  50. package/dist/types/src/capabilities/react-surface.d.ts.map +1 -1
  51. package/dist/types/src/components/Chart/Chart.d.ts +1 -1
  52. package/dist/types/src/components/Chart/Chart.d.ts.map +1 -1
  53. package/dist/types/src/components/Chart/Chart.stories.d.ts +4 -1
  54. package/dist/types/src/components/Chart/Chart.stories.d.ts.map +1 -1
  55. package/dist/types/src/components/Globe/Globe.d.ts +1 -1
  56. package/dist/types/src/components/Globe/Globe.d.ts.map +1 -1
  57. package/dist/types/src/components/Globe/Globe.stories.d.ts +5 -2
  58. package/dist/types/src/components/Globe/Globe.stories.d.ts.map +1 -1
  59. package/dist/types/src/components/Graph/CanvasForceGraph.d.ts +13 -0
  60. package/dist/types/src/components/Graph/CanvasForceGraph.d.ts.map +1 -0
  61. package/dist/types/src/components/Graph/CanvasForceGraph.stories.d.ts +17 -0
  62. package/dist/types/src/components/Graph/CanvasForceGraph.stories.d.ts.map +1 -0
  63. package/dist/types/src/components/Graph/ForceGraph.d.ts +12 -5
  64. package/dist/types/src/components/Graph/ForceGraph.d.ts.map +1 -1
  65. package/dist/types/src/components/Graph/ForceGraph.stories.d.ts +4 -2
  66. package/dist/types/src/components/Graph/ForceGraph.stories.d.ts.map +1 -1
  67. package/dist/types/src/components/Graph/{adapter.d.ts → graph-adapter.d.ts} +2 -2
  68. package/dist/types/src/components/Graph/graph-adapter.d.ts.map +1 -0
  69. package/dist/types/src/components/Graph/index.d.ts +1 -1
  70. package/dist/types/src/components/Graph/index.d.ts.map +1 -1
  71. package/dist/types/src/components/Lattice/Lattice.d.ts +20 -0
  72. package/dist/types/src/components/Lattice/Lattice.d.ts.map +1 -0
  73. package/dist/types/src/components/Lattice/Lattice.stories.d.ts +8 -0
  74. package/dist/types/src/components/Lattice/Lattice.stories.d.ts.map +1 -0
  75. package/dist/types/src/components/Lattice/index.d.ts +2 -0
  76. package/dist/types/src/components/Lattice/index.d.ts.map +1 -0
  77. package/dist/types/src/components/Tree/EdgeBundling.stories.d.ts +21 -0
  78. package/dist/types/src/components/Tree/EdgeBundling.stories.d.ts.map +1 -0
  79. package/dist/types/src/components/Tree/Tree.d.ts +20 -23
  80. package/dist/types/src/components/Tree/Tree.d.ts.map +1 -1
  81. package/dist/types/src/components/Tree/Tree.stories.d.ts +5 -12
  82. package/dist/types/src/components/Tree/Tree.stories.d.ts.map +1 -1
  83. package/dist/types/src/components/Tree/index.d.ts +2 -0
  84. package/dist/types/src/components/Tree/index.d.ts.map +1 -1
  85. package/dist/types/src/components/Tree/layout/HierarchicalEdgeBundling.d.ts +37 -2
  86. package/dist/types/src/components/Tree/layout/HierarchicalEdgeBundling.d.ts.map +1 -1
  87. package/dist/types/src/components/Tree/layout/RadialTree.d.ts +35 -2
  88. package/dist/types/src/components/Tree/layout/RadialTree.d.ts.map +1 -1
  89. package/dist/types/src/components/Tree/layout/TidyTree.d.ts +24 -2
  90. package/dist/types/src/components/Tree/layout/TidyTree.d.ts.map +1 -1
  91. package/dist/types/src/components/Tree/layout/hierarchy.d.ts +17 -0
  92. package/dist/types/src/components/Tree/layout/hierarchy.d.ts.map +1 -0
  93. package/dist/types/src/components/Tree/layout/index.d.ts +5 -4
  94. package/dist/types/src/components/Tree/layout/index.d.ts.map +1 -1
  95. package/dist/types/src/components/Tree/layout/slots.d.ts +7 -0
  96. package/dist/types/src/components/Tree/layout/slots.d.ts.map +1 -0
  97. package/dist/types/src/components/Tree/layout/useContainerSize.d.ts +15 -0
  98. package/dist/types/src/components/Tree/layout/useContainerSize.d.ts.map +1 -0
  99. package/dist/types/src/components/Tree/types/tree.d.ts +45 -22
  100. package/dist/types/src/components/Tree/types/tree.d.ts.map +1 -1
  101. package/dist/types/src/components/Tree/types/types.d.ts +14 -4
  102. package/dist/types/src/components/Tree/types/types.d.ts.map +1 -1
  103. package/dist/types/src/components/index.d.ts +1 -2
  104. package/dist/types/src/components/index.d.ts.map +1 -1
  105. package/dist/types/src/containers/ExplorerArticle/ExplorerArticle.d.ts +13 -0
  106. package/dist/types/src/containers/ExplorerArticle/ExplorerArticle.d.ts.map +1 -0
  107. package/dist/types/src/containers/ExplorerArticle/ExplorerArticle.stories.d.ts +30 -0
  108. package/dist/types/src/containers/ExplorerArticle/ExplorerArticle.stories.d.ts.map +1 -0
  109. package/dist/types/src/containers/ExplorerArticle/Visualization.d.ts +24 -0
  110. package/dist/types/src/containers/ExplorerArticle/Visualization.d.ts.map +1 -0
  111. package/dist/types/src/containers/ExplorerArticle/experimental.stories.d.ts +7 -0
  112. package/dist/types/src/containers/ExplorerArticle/experimental.stories.d.ts.map +1 -0
  113. package/dist/types/src/containers/ExplorerArticle/index.d.ts +2 -0
  114. package/dist/types/src/containers/ExplorerArticle/index.d.ts.map +1 -0
  115. package/dist/types/src/containers/ExplorerArticle/variants.d.ts +9 -0
  116. package/dist/types/src/containers/ExplorerArticle/variants.d.ts.map +1 -0
  117. package/dist/types/src/containers/index.d.ts +3 -0
  118. package/dist/types/src/containers/index.d.ts.map +1 -0
  119. package/dist/types/src/hooks/useGraphModel.d.ts +2 -2
  120. package/dist/types/src/hooks/useGraphModel.d.ts.map +1 -1
  121. package/dist/types/src/index.d.ts +1 -3
  122. package/dist/types/src/index.d.ts.map +1 -1
  123. package/dist/types/src/meta.d.ts +2 -2
  124. package/dist/types/src/meta.d.ts.map +1 -1
  125. package/dist/types/src/plugin.d.ts +3 -0
  126. package/dist/types/src/plugin.d.ts.map +1 -0
  127. package/dist/types/src/{components/Tree/testing → testing}/generator.d.ts +1 -1
  128. package/dist/types/src/testing/generator.d.ts.map +1 -0
  129. package/dist/types/src/testing/index.d.ts +4 -0
  130. package/dist/types/src/testing/index.d.ts.map +1 -0
  131. package/dist/types/src/testing/relations.d.ts +47 -0
  132. package/dist/types/src/testing/relations.d.ts.map +1 -0
  133. package/dist/types/src/translations.d.ts +31 -22
  134. package/dist/types/src/translations.d.ts.map +1 -1
  135. package/dist/types/src/types/ExplorerAction.d.ts +1 -18
  136. package/dist/types/src/types/ExplorerAction.d.ts.map +1 -1
  137. package/dist/types/src/types/Graph.d.ts +14 -25
  138. package/dist/types/src/types/Graph.d.ts.map +1 -1
  139. package/dist/types/src/util/index.d.ts +3 -0
  140. package/dist/types/src/util/index.d.ts.map +1 -0
  141. package/dist/types/src/util/node-color.d.ts +13 -0
  142. package/dist/types/src/util/node-color.d.ts.map +1 -0
  143. package/dist/types/src/{components → util}/plot.d.ts +1 -1
  144. package/dist/types/src/util/plot.d.ts.map +1 -0
  145. package/dist/types/tsconfig.tsbuildinfo +1 -1
  146. package/package.json +114 -62
  147. package/src/ExplorerPlugin.test.ts +26 -0
  148. package/src/ExplorerPlugin.tsx +15 -56
  149. package/src/capabilities/create-object.ts +36 -0
  150. package/src/capabilities/index.ts +3 -3
  151. package/src/capabilities/react-surface.tsx +24 -19
  152. package/src/components/Chart/Chart.stories.tsx +16 -23
  153. package/src/components/Chart/Chart.tsx +1 -1
  154. package/src/components/Globe/Globe.stories.tsx +19 -22
  155. package/src/components/Globe/Globe.tsx +1 -1
  156. package/src/components/Graph/CanvasForceGraph.stories.tsx +83 -0
  157. package/src/components/Graph/CanvasForceGraph.tsx +124 -0
  158. package/src/components/Graph/ForceGraph.stories.tsx +83 -42
  159. package/src/components/Graph/ForceGraph.tsx +105 -85
  160. package/src/components/Graph/{adapter.ts → graph-adapter.ts} +14 -8
  161. package/src/components/Graph/index.ts +1 -1
  162. package/src/components/Lattice/Lattice.stories.tsx +90 -0
  163. package/src/components/Lattice/Lattice.tsx +182 -0
  164. package/src/components/Lattice/index.ts +5 -0
  165. package/src/components/Tree/EdgeBundling.stories.tsx +144 -0
  166. package/src/components/Tree/Tree.stories.tsx +20 -38
  167. package/src/components/Tree/Tree.tsx +69 -95
  168. package/src/components/Tree/index.ts +2 -0
  169. package/src/components/Tree/layout/HierarchicalEdgeBundling.tsx +335 -0
  170. package/src/components/Tree/layout/RadialTree.tsx +242 -0
  171. package/src/components/Tree/layout/TidyTree.tsx +246 -0
  172. package/src/components/Tree/layout/hierarchy.ts +32 -0
  173. package/src/components/Tree/layout/index.ts +5 -5
  174. package/src/components/Tree/layout/slots.ts +19 -0
  175. package/src/components/Tree/layout/useContainerSize.ts +43 -0
  176. package/src/components/Tree/types/tree.test.ts +6 -5
  177. package/src/components/Tree/types/tree.ts +42 -26
  178. package/src/components/Tree/types/types.ts +38 -29
  179. package/src/components/index.ts +1 -4
  180. package/src/containers/ExplorerArticle/ExplorerArticle.stories.tsx +142 -0
  181. package/src/containers/ExplorerArticle/ExplorerArticle.tsx +112 -0
  182. package/src/containers/ExplorerArticle/Visualization.tsx +497 -0
  183. package/src/containers/ExplorerArticle/experimental.stories.tsx +446 -0
  184. package/src/containers/ExplorerArticle/index.ts +5 -0
  185. package/src/containers/ExplorerArticle/variants.ts +37 -0
  186. package/src/containers/index.ts +7 -0
  187. package/src/hooks/useGraphModel.ts +25 -14
  188. package/src/index.ts +1 -4
  189. package/src/meta.ts +24 -6
  190. package/src/plugin.ts +9 -0
  191. package/src/{components/Tree/testing → testing}/generator.ts +5 -3
  192. package/src/testing/index.ts +9 -0
  193. package/src/testing/relations.ts +192 -0
  194. package/src/translations.ts +16 -13
  195. package/src/types/ExplorerAction.ts +9 -19
  196. package/src/types/Graph.ts +25 -25
  197. package/src/typings.d.ts +8 -0
  198. package/src/util/index.ts +6 -0
  199. package/src/util/node-color.ts +23 -0
  200. package/src/{components → util}/plot.ts +16 -4
  201. package/dist/lib/browser/ExplorerContainer-NOLLVUTE.mjs +0 -50
  202. package/dist/lib/browser/ExplorerContainer-NOLLVUTE.mjs.map +0 -7
  203. package/dist/lib/browser/chunk-2MKBRIUT.mjs +0 -31
  204. package/dist/lib/browser/chunk-2MKBRIUT.mjs.map +0 -7
  205. package/dist/lib/browser/chunk-6BVXZQPP.mjs +0 -188
  206. package/dist/lib/browser/chunk-6BVXZQPP.mjs.map +0 -7
  207. package/dist/lib/browser/chunk-ARBGXQFH.mjs +0 -11089
  208. package/dist/lib/browser/chunk-ARBGXQFH.mjs.map +0 -7
  209. package/dist/lib/browser/chunk-JDSUIUNR.mjs +0 -80
  210. package/dist/lib/browser/chunk-JDSUIUNR.mjs.map +0 -7
  211. package/dist/lib/browser/chunk-UBHZGWZQ.mjs +0 -24
  212. package/dist/lib/browser/chunk-UBHZGWZQ.mjs.map +0 -7
  213. package/dist/lib/browser/index.mjs +0 -119
  214. package/dist/lib/browser/index.mjs.map +0 -7
  215. package/dist/lib/browser/intent-resolver-YS5LZC3A.mjs +0 -31
  216. package/dist/lib/browser/intent-resolver-YS5LZC3A.mjs.map +0 -7
  217. package/dist/lib/browser/meta.json +0 -1
  218. package/dist/lib/browser/react-surface-BVTCOVLK.mjs +0 -35
  219. package/dist/lib/browser/react-surface-BVTCOVLK.mjs.map +0 -7
  220. package/dist/lib/node-esm/ExplorerContainer-N3S5KSUX.mjs +0 -51
  221. package/dist/lib/node-esm/ExplorerContainer-N3S5KSUX.mjs.map +0 -7
  222. package/dist/lib/node-esm/chunk-3ODK27PU.mjs +0 -33
  223. package/dist/lib/node-esm/chunk-3ODK27PU.mjs.map +0 -7
  224. package/dist/lib/node-esm/chunk-CRSVAZNA.mjs +0 -190
  225. package/dist/lib/node-esm/chunk-CRSVAZNA.mjs.map +0 -7
  226. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +0 -11
  227. package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +0 -7
  228. package/dist/lib/node-esm/chunk-MS72BATS.mjs +0 -81
  229. package/dist/lib/node-esm/chunk-MS72BATS.mjs.map +0 -7
  230. package/dist/lib/node-esm/chunk-NPIP4VEH.mjs.map +0 -7
  231. package/dist/lib/node-esm/chunk-UXZM5VJB.mjs +0 -26
  232. package/dist/lib/node-esm/chunk-UXZM5VJB.mjs.map +0 -7
  233. package/dist/lib/node-esm/index.mjs +0 -120
  234. package/dist/lib/node-esm/index.mjs.map +0 -7
  235. package/dist/lib/node-esm/intent-resolver-VCEC67WX.mjs +0 -32
  236. package/dist/lib/node-esm/intent-resolver-VCEC67WX.mjs.map +0 -7
  237. package/dist/lib/node-esm/meta.json +0 -1
  238. package/dist/lib/node-esm/meta.mjs +0 -9
  239. package/dist/lib/node-esm/react-surface-4HFEX52O.mjs +0 -36
  240. package/dist/lib/node-esm/react-surface-4HFEX52O.mjs.map +0 -7
  241. package/dist/lib/node-esm/types/index.mjs +0 -12
  242. package/dist/types/src/capabilities/intent-resolver.d.ts +0 -4
  243. package/dist/types/src/capabilities/intent-resolver.d.ts.map +0 -1
  244. package/dist/types/src/components/ExplorerContainer.d.ts +0 -9
  245. package/dist/types/src/components/ExplorerContainer.d.ts.map +0 -1
  246. package/dist/types/src/components/Graph/D3ForceGraph.d.ts +0 -14
  247. package/dist/types/src/components/Graph/D3ForceGraph.d.ts.map +0 -1
  248. package/dist/types/src/components/Graph/D3ForceGraph.stories.d.ts +0 -15
  249. package/dist/types/src/components/Graph/D3ForceGraph.stories.d.ts.map +0 -1
  250. package/dist/types/src/components/Graph/adapter.d.ts.map +0 -1
  251. package/dist/types/src/components/Graph/testing.d.ts +0 -14
  252. package/dist/types/src/components/Graph/testing.d.ts.map +0 -1
  253. package/dist/types/src/components/Tree/testing/generator.d.ts.map +0 -1
  254. package/dist/types/src/components/Tree/testing/index.d.ts +0 -2
  255. package/dist/types/src/components/Tree/testing/index.d.ts.map +0 -1
  256. package/dist/types/src/components/plot.d.ts.map +0 -1
  257. package/src/capabilities/intent-resolver.ts +0 -21
  258. package/src/components/ExplorerContainer.tsx +0 -54
  259. package/src/components/Graph/D3ForceGraph.stories.tsx +0 -78
  260. package/src/components/Graph/D3ForceGraph.tsx +0 -101
  261. package/src/components/Graph/testing.ts +0 -55
  262. package/src/components/Tree/layout/HierarchicalEdgeBundling.ts +0 -162
  263. package/src/components/Tree/layout/RadialTree.ts +0 -94
  264. package/src/components/Tree/layout/TidyTree.ts +0 -101
  265. package/src/components/Tree/testing/index.ts +0 -5
  266. /package/dist/lib/{browser/chunk-J5LGTIGS.mjs.map → neutral/ExplorerPlugin.mjs.map} +0 -0
  267. /package/dist/lib/{browser → neutral}/chunk-J5LGTIGS.mjs +0 -0
  268. /package/dist/lib/{browser/meta.mjs.map → neutral/chunk-J5LGTIGS.mjs.map} +0 -0
  269. /package/dist/lib/{browser/types → neutral}/index.mjs.map +0 -0
  270. /package/dist/lib/{node-esm → neutral}/meta.mjs.map +0 -0
  271. /package/dist/lib/{node-esm → neutral}/types/index.mjs.map +0 -0
@@ -0,0 +1,497 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { RegistryContext } from '@effect-atom/atom-react';
6
+ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
7
+
8
+ import { Obj } from '@dxos/echo';
9
+ import { useThemeContext } from '@dxos/react-ui';
10
+ import {
11
+ CLUSTER_NODE_TYPE_GROUP,
12
+ CLUSTER_NODE_TYPE_LEAF,
13
+ CLUSTER_NODE_TYPE_ROOT,
14
+ GraphBundleProjector,
15
+ GraphClusterProjector,
16
+ GraphForceProjector,
17
+ type GraphLayout,
18
+ type GraphLayoutNode,
19
+ GraphLatticeProjector,
20
+ type GraphProjector,
21
+ type RenderNode,
22
+ SVG,
23
+ type SVGContext,
24
+ } from '@dxos/react-ui-graph';
25
+ import { Flock, type FlockBoid, FlockModel, Vec2 } from '@dxos/react-ui-sfx';
26
+ import { type SpaceGraphEdge, type SpaceGraphModel, type SpaceGraphNode } from '@dxos/schema';
27
+
28
+ import { type TreeNode } from '#components';
29
+
30
+ import { getNodeFillForObject } from '../../util';
31
+ import { type ExplorerArticleVariant } from './variants';
32
+
33
+ export type VisualizationProps = {
34
+ variant: ExplorerArticleVariant;
35
+ model: SpaceGraphModel;
36
+ onNodeHover?: (node: TreeNode | null, event?: MouseEvent) => void;
37
+ };
38
+
39
+ /**
40
+ * Renders the active visualization variant.
41
+ *
42
+ * For SVG variants (force, cluster, bundle, lattice), one `<SVG.Graph>` instance is
43
+ * kept mounted; only the projector swaps. Each new projector receives the previous
44
+ * layout so node x/y survive the swap and the projector's `animate()` tweens to the
45
+ * new target.
46
+ *
47
+ * The `swarm` variant is canvas-based and uses a `FlockModel`. On entry, boids are
48
+ * seeded from `model.graph.nodes` with positions taken from the latest SVG layout
49
+ * (matched by node id). On exit, the boids' current positions are written back to
50
+ * `lastLayoutRef` so the next SVG projector starts from where the swarm left off.
51
+ */
52
+ export const Visualization = ({ variant, model, onNodeHover }: VisualizationProps) => {
53
+ const registry = useContext(RegistryContext);
54
+ const { themeMode } = useThemeContext();
55
+ // Match the visible app background so the swarm canvas reads as continuous
56
+ // with the rest of the UI. Page bg comes from --color-baseSurface tokens
57
+ // (dark = neutral-950 ≈ #0a0a0a, light = neutral-50 ≈ #fafafa).
58
+ const flockBackground = themeMode === 'dark' ? '#0a0a0a' : '#fafafa';
59
+ const containerRef = useRef<HTMLDivElement | null>(null);
60
+ const [size, setSize] = useState({ width: 0, height: 0 });
61
+
62
+ const svgRef = useRef<SVGContext>(null);
63
+ const [projector, setProjector] = useState<GraphProjector<SpaceGraphNode> | undefined>();
64
+ const projectorRef = useRef<GraphProjector<SpaceGraphNode> | undefined>(undefined);
65
+ projectorRef.current = projector;
66
+
67
+ // Latest layout we hand to the next SVG projector as `prev` — captured both when
68
+ // leaving an SVG variant (live projector.layout) and when leaving swarm (boid
69
+ // positions translated back to center-origin).
70
+ const lastLayoutRef = useRef<GraphLayout<SpaceGraphNode> | undefined>(undefined);
71
+
72
+ // Reactive source of truth for the boid array used by the swarm view.
73
+ const flockModel = useMemo(() => new FlockModel(registry), [registry]);
74
+
75
+ // Subscribe to the graph atom — keeps it alive across child unmounts (effect-atom
76
+ // disposes atoms with no subscribers) and triggers re-renders when query results land.
77
+ const [modelRev, setModelRev] = useState(0);
78
+ useEffect(() => model?.subscribe(() => setModelRev((r) => r + 1)), [model]);
79
+
80
+ // Track the visualization container size so we can translate between canvas
81
+ // (top-left origin) and graph (center origin) coordinates when entering or
82
+ // leaving swarm.
83
+ useEffect(() => {
84
+ const el = containerRef.current;
85
+ if (!el) {
86
+ return;
87
+ }
88
+
89
+ const rect = el.getBoundingClientRect();
90
+ setSize({ width: rect.width, height: rect.height });
91
+
92
+ const observer = new ResizeObserver((entries) => {
93
+ const entry = entries[0];
94
+ if (!entry) {
95
+ return;
96
+ }
97
+ const { width, height } = entry.contentRect;
98
+ setSize((prev) => (prev.width === width && prev.height === height ? prev : { width, height }));
99
+ });
100
+
101
+ observer.observe(el);
102
+ return () => observer.disconnect();
103
+ }, []);
104
+
105
+ // Recreate the projector when the variant changes. Two transitions need special handling:
106
+ // - leaving an SVG variant: snapshot live projector.layout into lastLayoutRef.
107
+ // - leaving swarm: convert current boid positions (canvas-coord) back into a
108
+ // center-origin layout in lastLayoutRef so the next projector animates from
109
+ // where the boids ended up.
110
+ useEffect(() => {
111
+ if (projectorRef.current?.layout) {
112
+ lastLayoutRef.current = projectorRef.current.layout as GraphLayout<SpaceGraphNode>;
113
+ } else if (flockModel.boids.length > 0 && size.width > 0 && size.height > 0) {
114
+ lastLayoutRef.current = boidsToLayout(flockModel.boids, size, lastLayoutRef.current);
115
+ }
116
+
117
+ if (variant === 'swarm') {
118
+ setProjector(undefined);
119
+ return;
120
+ }
121
+
122
+ if (!svgRef.current) {
123
+ return;
124
+ }
125
+
126
+ setProjector(createProjector(variant, svgRef.current, lastLayoutRef.current));
127
+ }, [variant, flockModel, size.width, size.height]);
128
+
129
+ // Seed the flock with boids derived from current graph nodes whenever we enter
130
+ // swarm (or model data lands while in swarm). Positions are reused from the last
131
+ // SVG layout where the id matches; otherwise a small random spread around center.
132
+ useEffect(() => {
133
+ if (variant !== 'swarm' || !size.width || !size.height) {
134
+ return;
135
+ }
136
+ const nodes = model?.graph.nodes ?? [];
137
+ if (nodes.length === 0) {
138
+ return;
139
+ }
140
+ flockModel.setBoids(seedBoidsFromNodes(nodes, lastLayoutRef.current, size, flockModel));
141
+ }, [variant, flockModel, model, modelRev, size.width, size.height]);
142
+
143
+ const renderNode = useMemo(() => createRenderNode(variant), [variant]);
144
+
145
+ const handleInspect = useCallback(
146
+ (node: GraphLayoutNode<SpaceGraphNode> | null, event: MouseEvent) => {
147
+ // null = pointerleave: forward to the shared hover handler so it can clear any preview.
148
+ if (!node) {
149
+ onNodeHover?.(null);
150
+ return;
151
+ }
152
+ onNodeHover?.({ id: node.id, data: node.data?.data?.object }, event);
153
+ },
154
+ [onNodeHover],
155
+ );
156
+
157
+ // Cluster-only: clicking a root / group node toggles its subtree open/closed.
158
+ const handleSelect = useCallback(
159
+ (node: GraphLayoutNode<SpaceGraphNode>) => {
160
+ if (
161
+ variant !== 'cluster' ||
162
+ !node ||
163
+ (node.type !== CLUSTER_NODE_TYPE_ROOT && node.type !== CLUSTER_NODE_TYPE_GROUP)
164
+ ) {
165
+ return;
166
+ }
167
+
168
+ const cluster = projector as GraphClusterProjector<SpaceGraphNode> | undefined;
169
+ cluster?.toggleCollapsed(node.id);
170
+ },
171
+ [variant, projector],
172
+ );
173
+
174
+ return (
175
+ <div ref={containerRef} className='dx-expander relative'>
176
+ {variant === 'swarm' ? (
177
+ <Flock model={flockModel} coloring='Movement' background={flockBackground} />
178
+ ) : (
179
+ <SVG.Root ref={svgRef}>
180
+ <SVG.Zoom extent={[1 / 2, 2]}>
181
+ <SVG.Graph<SpaceGraphNode, SpaceGraphEdge>
182
+ model={model}
183
+ projector={projector}
184
+ renderNode={renderNode}
185
+ drag={variant === 'force'}
186
+ onInspect={handleInspect}
187
+ onSelect={handleSelect}
188
+ />
189
+ </SVG.Zoom>
190
+ </SVG.Root>
191
+ )}
192
+ </div>
193
+ );
194
+ };
195
+
196
+ /**
197
+ * Build the boid set for entering swarm. For each model node, take the position from
198
+ * `lastLayout` (id-matched), or a random spread around center if the id isn't there.
199
+ * Position reuse keeps in-flight transitions visually continuous.
200
+ */
201
+ const seedBoidsFromNodes = (
202
+ nodes: readonly SpaceGraphNode[],
203
+ lastLayout: GraphLayout<SpaceGraphNode> | undefined,
204
+ { width, height }: { width: number; height: number },
205
+ flockModel: FlockModel,
206
+ ): FlockBoid[] => {
207
+ const cx = width / 2;
208
+ const cy = height / 2;
209
+ const spread = Math.min(width, height) * 0.5;
210
+ const snapshot = new Map((lastLayout?.graph.nodes ?? []).map((n) => [n.id, n] as const));
211
+ return nodes.map((node) => {
212
+ const prev = snapshot.get(node.id);
213
+ const px = prev?.x ?? (Math.random() - 0.5) * spread;
214
+ const py = prev?.y ?? (Math.random() - 0.5) * spread;
215
+ // Preserve existing boid velocity/colour if we already have one for this id —
216
+ // keeps mid-air boids moving smoothly when the model emits multiple times.
217
+ const existing = flockModel.findBoid(node.id);
218
+ return {
219
+ id: node.id,
220
+ position: new Vec2(cx + px, cy + py),
221
+ velocity: existing?.velocity ?? new Vec2(),
222
+ color: existing?.color,
223
+ last: existing?.last ?? [],
224
+ };
225
+ });
226
+ };
227
+
228
+ /**
229
+ * Translate canvas-coord boid positions back into a center-origin layout that the
230
+ * next SVG projector can consume as `prev`. Matches nodes from `prevLayout` by id so
231
+ * label / data references are preserved; falls back to creating fresh layout nodes
232
+ * for boids whose ids aren't present in the prior layout.
233
+ */
234
+ const boidsToLayout = (
235
+ boids: readonly FlockBoid[],
236
+ { width, height }: { width: number; height: number },
237
+ prevLayout: GraphLayout<SpaceGraphNode> | undefined,
238
+ ): GraphLayout<SpaceGraphNode> => {
239
+ const cx = width / 2;
240
+ const cy = height / 2;
241
+ const previousById = new Map((prevLayout?.graph.nodes ?? []).map((n) => [n.id, n] as const));
242
+ const nodes: GraphLayoutNode<SpaceGraphNode>[] = boids
243
+ .filter((b) => b.id !== undefined)
244
+ .map((boid) => {
245
+ const id = boid.id!;
246
+ const prev = previousById.get(id);
247
+ return {
248
+ ...(prev ?? { id }),
249
+ x: boid.position.x - cx,
250
+ y: boid.position.y - cy,
251
+ };
252
+ });
253
+ return {
254
+ graph: {
255
+ nodes,
256
+ // Edges aren't represented in the swarm — keep whatever the previous layout had so
257
+ // the next projector still has a topology to bind to via mergeData.
258
+ edges: prevLayout?.graph.edges ?? [],
259
+ },
260
+ };
261
+ };
262
+
263
+ /** Cross-variant tween duration. Matches the renderer's edge fade timing so node movement and edge enter/exit complete together. */
264
+ const TWEEN_MS = 500;
265
+
266
+ const createProjector = (
267
+ variant: Exclude<ExplorerArticleVariant, 'swarm'>,
268
+ ctx: SVGContext,
269
+ prev?: GraphLayout<SpaceGraphNode>,
270
+ ): GraphProjector<SpaceGraphNode> => {
271
+ switch (variant) {
272
+ case 'force':
273
+ // Force has no `duration` — its own simulation drives motion via ticks.
274
+ return new GraphForceProjector<SpaceGraphNode>(ctx, undefined, undefined, prev);
275
+
276
+ case 'lattice':
277
+ return new GraphLatticeProjector<SpaceGraphNode>(
278
+ ctx,
279
+ {
280
+ duration: TWEEN_MS,
281
+ // Plugin-explorer overrides the projector's force-matched default (6)
282
+ // with a smaller node so the lattice reads as a dense matrix.
283
+ radius: 4,
284
+ // Cluster by typename first so same-type rects sit together; break ties by label.
285
+ sortBy: (node: GraphLayoutNode<SpaceGraphNode>) => {
286
+ const obj = node.data?.data?.object;
287
+ const typename = obj ? (Obj.getTypename(obj) ?? '(untyped)') : '(untyped)';
288
+ const label = (obj && Obj.getLabel(obj)) ?? node.data?.data?.label ?? node.id;
289
+ return `${typename} ${label}`;
290
+ },
291
+ },
292
+ undefined,
293
+ prev,
294
+ );
295
+
296
+ case 'cluster':
297
+ return new GraphClusterProjector<SpaceGraphNode>(
298
+ ctx,
299
+ {
300
+ duration: TWEEN_MS,
301
+ groupOf: typenameGroupOf,
302
+ rootLabel: 'Database',
303
+ groupLabel: shortTypename,
304
+ // All three node kinds share the same radius — leaves, groups, and root read
305
+ // as members of the same circle rather than ranked by size.
306
+ rootRadius: 4,
307
+ groupRadius: 4,
308
+ },
309
+ undefined,
310
+ prev,
311
+ );
312
+
313
+ case 'bundle':
314
+ return new GraphBundleProjector<SpaceGraphNode>(
315
+ ctx,
316
+ {
317
+ duration: TWEEN_MS,
318
+ groupOf: typenameGroupOf,
319
+ },
320
+ undefined,
321
+ prev,
322
+ );
323
+ }
324
+ };
325
+
326
+ /** Group leaves by typename so same-type leaves cluster together — used by both the
327
+ * cluster (visible structural nodes) and bundle (invisible routing anchors) projectors. */
328
+ const typenameGroupOf = (node: GraphLayoutNode<SpaceGraphNode>): string | undefined => {
329
+ const obj = node.data?.data?.object;
330
+ return obj ? (Obj.getTypename(obj) ?? '(untyped)') : undefined;
331
+ };
332
+
333
+ const createRenderNode = (variant: ExplorerArticleVariant): RenderNode<SpaceGraphNode> | undefined => {
334
+ switch (variant) {
335
+ case 'swarm':
336
+ return undefined;
337
+
338
+ case 'force':
339
+ return (group, node) => {
340
+ const r = node.r ?? 6;
341
+ group
342
+ .append('circle')
343
+ .attr('r', r)
344
+ .style('cursor', 'pointer')
345
+ .style('fill', getNodeFillForObject(node.data?.data?.object as Obj.Unknown | undefined));
346
+ };
347
+
348
+ case 'lattice':
349
+ return (group, node) => {
350
+ const r = node.r ?? 6;
351
+ const sz = r * 2;
352
+ group
353
+ .append('rect')
354
+ .attr('x', -r)
355
+ .attr('y', -r)
356
+ .attr('width', sz)
357
+ .attr('height', sz)
358
+ .attr('rx', r * 0.3)
359
+ .attr('ry', r * 0.3)
360
+ .style('cursor', 'pointer')
361
+ .style('fill', getNodeFillForObject(node.data?.data?.object as Obj.Unknown | undefined));
362
+ };
363
+
364
+ case 'cluster':
365
+ return (group, node) => {
366
+ const obj = node.data?.data?.object as Obj.Unknown | undefined;
367
+ const r = node.r ?? 4;
368
+ // Synthetic root / group nodes have no underlying ECHO object; render them as
369
+ // smaller, neutral circles so the hierarchy reads as "structure + leaves".
370
+ group
371
+ .append('circle')
372
+ .attr('r', r)
373
+ .style('cursor', 'pointer')
374
+ .style('fill', obj ? getNodeFillForObject(obj) : 'var(--color-neutral-500)');
375
+ if (node.type === CLUSTER_NODE_TYPE_LEAF) {
376
+ appendRadialLeafLabel(group, node, obj, r);
377
+ } else if (node.type === CLUSTER_NODE_TYPE_ROOT) {
378
+ appendRootLabel(group, node, r);
379
+ } else if (node.type === CLUSTER_NODE_TYPE_GROUP) {
380
+ appendRadialGroupLabel(group, node, r);
381
+ }
382
+ };
383
+
384
+ case 'bundle':
385
+ // Bundle layout renders ONLY leaves (root/group are invisible routing anchors).
386
+ return (group, node) => {
387
+ const obj = node.data?.data?.object as Obj.Unknown | undefined;
388
+ const r = node.r ?? 4;
389
+ group
390
+ .append('circle')
391
+ .attr('r', r)
392
+ .style('cursor', 'pointer')
393
+ .style('fill', obj ? getNodeFillForObject(obj) : 'var(--color-neutral-500)');
394
+ appendRadialLeafLabel(group, node, obj, r);
395
+ };
396
+ }
397
+ };
398
+
399
+ /** Fade-in duration applied to labels after the layout tween completes. */
400
+ const LABEL_FADE_MS = 200;
401
+
402
+ /**
403
+ * Append a radial leaf label outside the ring, oriented outward. Compute orientation
404
+ * from the TARGET position (tx/ty) — at enter time node.x/y is still at the previous
405
+ * projector's coordinates, so using current x/y would orient the label by the
406
+ * pre-transition layout (wrong) and the rotation wouldn't update during the tween.
407
+ */
408
+ const appendRadialLeafLabel = (
409
+ group: Parameters<RenderNode<SpaceGraphNode>>[0],
410
+ node: GraphLayoutNode<SpaceGraphNode>,
411
+ obj: Obj.Unknown | undefined,
412
+ r: number,
413
+ ): void => {
414
+ const label = (obj && Obj.getLabel(obj)) ?? node.data?.data?.label ?? node.id;
415
+ if (!label) {
416
+ return;
417
+ }
418
+
419
+ const targetX = (node as any).tx ?? node.x ?? 0;
420
+ const targetY = (node as any).ty ?? node.y ?? 0;
421
+ const angleDeg = (Math.atan2(targetY, targetX) * 180) / Math.PI;
422
+ const flipped = angleDeg > 90 || angleDeg < -90;
423
+ group
424
+ .append('text')
425
+ .classed('dx-cluster-label', true)
426
+ .attr('dy', '0.32em')
427
+ .attr('transform', `rotate(${flipped ? angleDeg + 180 : angleDeg})`)
428
+ .attr('x', flipped ? -(r + 4) : r + 4)
429
+ .attr('text-anchor', flipped ? 'end' : 'start')
430
+ .attr('opacity', 0)
431
+ .style('pointer-events', 'none')
432
+ .text(label)
433
+ .transition()
434
+ .delay(TWEEN_MS)
435
+ .duration(LABEL_FADE_MS)
436
+ .attr('opacity', 1);
437
+ };
438
+
439
+ const appendRadialGroupLabel = (
440
+ group: Parameters<RenderNode<SpaceGraphNode>>[0],
441
+ node: GraphLayoutNode<SpaceGraphNode>,
442
+ r: number,
443
+ ): void => {
444
+ if (!node.label) {
445
+ return;
446
+ }
447
+
448
+ const targetX = (node as any).tx ?? node.x ?? 0;
449
+ const targetY = (node as any).ty ?? node.y ?? 0;
450
+ const angleDeg = (Math.atan2(targetY, targetX) * 180) / Math.PI;
451
+ const flipped = angleDeg > 90 || angleDeg < -90;
452
+ group
453
+ .append('text')
454
+ .classed('dx-cluster-label', true)
455
+ .classed('dx-cluster-label-group', true)
456
+ .attr('dy', '0.32em')
457
+ .attr('transform', `rotate(${flipped ? angleDeg + 180 : angleDeg})`)
458
+ .attr('x', flipped ? r + 4 : -(r + 4))
459
+ .attr('text-anchor', flipped ? 'start' : 'end')
460
+ .attr('opacity', 0)
461
+ .style('pointer-events', 'none')
462
+ .text(node.label)
463
+ .transition()
464
+ .delay(TWEEN_MS)
465
+ .duration(LABEL_FADE_MS)
466
+ .attr('opacity', 1);
467
+ };
468
+
469
+ const appendRootLabel = (
470
+ group: Parameters<RenderNode<SpaceGraphNode>>[0],
471
+ node: GraphLayoutNode<SpaceGraphNode>,
472
+ r: number,
473
+ ): void => {
474
+ if (!node.label) {
475
+ return;
476
+ }
477
+
478
+ group
479
+ .append('text')
480
+ .classed('dx-cluster-label', true)
481
+ .classed('dx-cluster-label-root', true)
482
+ .attr('text-anchor', 'middle')
483
+ .attr('y', -(r + 6))
484
+ .attr('opacity', 0)
485
+ .style('pointer-events', 'none')
486
+ .text(node.label)
487
+ .transition()
488
+ .delay(TWEEN_MS)
489
+ .duration(LABEL_FADE_MS)
490
+ .attr('opacity', 1);
491
+ };
492
+
493
+ /** Drop the package prefix from a typename for display: `org.dxos.type.Person` → `Person`. */
494
+ const shortTypename = (typename: string): string => {
495
+ const last = typename.split('.').pop() ?? typename;
496
+ return last.charAt(0).toUpperCase() + last.slice(1);
497
+ };