@dxos/plugin-explorer 0.8.4-main.ead640a → 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.
- package/LICENSE +102 -5
- package/dist/lib/neutral/ExplorerArticle-LLNHXWNG.mjs +420 -0
- package/dist/lib/neutral/ExplorerArticle-LLNHXWNG.mjs.map +7 -0
- package/dist/lib/neutral/ExplorerPlugin.mjs +10 -0
- package/dist/lib/neutral/capabilities/index.mjs +11 -0
- package/dist/lib/neutral/capabilities/index.mjs.map +7 -0
- package/dist/lib/neutral/chunk-7FSP4SPO.mjs +69 -0
- package/dist/lib/neutral/chunk-7FSP4SPO.mjs.map +7 -0
- package/dist/lib/neutral/chunk-CWN2BELW.mjs +287 -0
- package/dist/lib/neutral/chunk-CWN2BELW.mjs.map +7 -0
- package/dist/lib/neutral/chunk-GRJXLL4Z.mjs +25 -0
- package/dist/lib/neutral/chunk-GRJXLL4Z.mjs.map +7 -0
- package/dist/lib/neutral/chunk-IKHJV3Q4.mjs +20 -0
- package/dist/lib/neutral/chunk-IKHJV3Q4.mjs.map +7 -0
- package/dist/lib/neutral/chunk-LL3PXKB5.mjs +40 -0
- package/dist/lib/neutral/chunk-LL3PXKB5.mjs.map +7 -0
- package/dist/lib/{browser/chunk-MOM5KCKC.mjs → neutral/components/index.mjs} +882 -556
- package/dist/lib/neutral/components/index.mjs.map +7 -0
- package/dist/lib/neutral/containers/index.mjs +9 -0
- package/dist/lib/neutral/containers/index.mjs.map +7 -0
- package/dist/lib/neutral/create-object-F6TKVAGV.mjs +39 -0
- package/dist/lib/neutral/create-object-F6TKVAGV.mjs.map +7 -0
- package/dist/lib/neutral/hooks/index.mjs +45 -0
- package/dist/lib/neutral/hooks/index.mjs.map +7 -0
- package/dist/lib/neutral/index.mjs +14 -0
- package/dist/lib/neutral/meta.json +1 -0
- package/dist/lib/{browser → neutral}/meta.mjs +1 -1
- package/dist/lib/neutral/plugin.mjs +12 -0
- package/dist/lib/neutral/plugin.mjs.map +7 -0
- package/dist/lib/neutral/react-surface-APBW2VQG.mjs +26 -0
- package/dist/lib/neutral/react-surface-APBW2VQG.mjs.map +7 -0
- package/dist/lib/neutral/testing/index.mjs +193 -0
- package/dist/lib/neutral/testing/index.mjs.map +7 -0
- package/dist/lib/neutral/translations.mjs +33 -0
- package/dist/lib/neutral/translations.mjs.map +7 -0
- package/dist/lib/{browser → neutral}/types/index.mjs +1 -2
- package/dist/types/data/cities.d.ts +4 -4
- package/dist/types/data/cities.d.ts.map +1 -1
- package/dist/types/data/countries-110m.d.ts +19 -22
- package/dist/types/data/countries-110m.d.ts.map +1 -1
- package/dist/types/src/ExplorerPlugin.d.ts +3 -1
- package/dist/types/src/ExplorerPlugin.d.ts.map +1 -1
- package/dist/types/src/ExplorerPlugin.test.d.ts +2 -0
- package/dist/types/src/ExplorerPlugin.test.d.ts.map +1 -0
- package/dist/types/src/capabilities/create-object.d.ts +11 -0
- package/dist/types/src/capabilities/create-object.d.ts.map +1 -0
- package/dist/types/src/capabilities/index.d.ts +8 -2
- package/dist/types/src/capabilities/index.d.ts.map +1 -1
- package/dist/types/src/capabilities/react-surface.d.ts +3 -2
- package/dist/types/src/capabilities/react-surface.d.ts.map +1 -1
- package/dist/types/src/components/Chart/Chart.d.ts +1 -1
- package/dist/types/src/components/Chart/Chart.d.ts.map +1 -1
- package/dist/types/src/components/Chart/Chart.stories.d.ts +4 -1
- package/dist/types/src/components/Chart/Chart.stories.d.ts.map +1 -1
- package/dist/types/src/components/Globe/Globe.d.ts +1 -1
- package/dist/types/src/components/Globe/Globe.d.ts.map +1 -1
- package/dist/types/src/components/Globe/Globe.stories.d.ts +5 -2
- package/dist/types/src/components/Globe/Globe.stories.d.ts.map +1 -1
- package/dist/types/src/components/Graph/CanvasForceGraph.d.ts +13 -0
- package/dist/types/src/components/Graph/CanvasForceGraph.d.ts.map +1 -0
- package/dist/types/src/components/Graph/CanvasForceGraph.stories.d.ts +17 -0
- package/dist/types/src/components/Graph/CanvasForceGraph.stories.d.ts.map +1 -0
- package/dist/types/src/components/Graph/ForceGraph.d.ts +12 -5
- package/dist/types/src/components/Graph/ForceGraph.d.ts.map +1 -1
- package/dist/types/src/components/Graph/ForceGraph.stories.d.ts +4 -2
- package/dist/types/src/components/Graph/ForceGraph.stories.d.ts.map +1 -1
- package/dist/types/src/components/Graph/{adapter.d.ts → graph-adapter.d.ts} +2 -2
- package/dist/types/src/components/Graph/graph-adapter.d.ts.map +1 -0
- package/dist/types/src/components/Graph/index.d.ts +1 -1
- package/dist/types/src/components/Graph/index.d.ts.map +1 -1
- package/dist/types/src/components/Lattice/Lattice.d.ts +20 -0
- package/dist/types/src/components/Lattice/Lattice.d.ts.map +1 -0
- package/dist/types/src/components/Lattice/Lattice.stories.d.ts +8 -0
- package/dist/types/src/components/Lattice/Lattice.stories.d.ts.map +1 -0
- package/dist/types/src/components/Lattice/index.d.ts +2 -0
- package/dist/types/src/components/Lattice/index.d.ts.map +1 -0
- package/dist/types/src/components/Tree/EdgeBundling.stories.d.ts +21 -0
- package/dist/types/src/components/Tree/EdgeBundling.stories.d.ts.map +1 -0
- package/dist/types/src/components/Tree/Tree.d.ts +20 -23
- package/dist/types/src/components/Tree/Tree.d.ts.map +1 -1
- package/dist/types/src/components/Tree/Tree.stories.d.ts +5 -12
- package/dist/types/src/components/Tree/Tree.stories.d.ts.map +1 -1
- package/dist/types/src/components/Tree/index.d.ts +2 -0
- package/dist/types/src/components/Tree/index.d.ts.map +1 -1
- package/dist/types/src/components/Tree/layout/HierarchicalEdgeBundling.d.ts +37 -2
- package/dist/types/src/components/Tree/layout/HierarchicalEdgeBundling.d.ts.map +1 -1
- package/dist/types/src/components/Tree/layout/RadialTree.d.ts +35 -2
- package/dist/types/src/components/Tree/layout/RadialTree.d.ts.map +1 -1
- package/dist/types/src/components/Tree/layout/TidyTree.d.ts +24 -2
- package/dist/types/src/components/Tree/layout/TidyTree.d.ts.map +1 -1
- package/dist/types/src/components/Tree/layout/hierarchy.d.ts +17 -0
- package/dist/types/src/components/Tree/layout/hierarchy.d.ts.map +1 -0
- package/dist/types/src/components/Tree/layout/index.d.ts +5 -4
- package/dist/types/src/components/Tree/layout/index.d.ts.map +1 -1
- package/dist/types/src/components/Tree/layout/slots.d.ts +7 -0
- package/dist/types/src/components/Tree/layout/slots.d.ts.map +1 -0
- package/dist/types/src/components/Tree/layout/useContainerSize.d.ts +15 -0
- package/dist/types/src/components/Tree/layout/useContainerSize.d.ts.map +1 -0
- package/dist/types/src/components/Tree/types/tree.d.ts +45 -22
- package/dist/types/src/components/Tree/types/tree.d.ts.map +1 -1
- package/dist/types/src/components/Tree/types/types.d.ts +14 -4
- package/dist/types/src/components/Tree/types/types.d.ts.map +1 -1
- package/dist/types/src/components/index.d.ts +1 -4
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/containers/ExplorerArticle/ExplorerArticle.d.ts +13 -0
- package/dist/types/src/containers/ExplorerArticle/ExplorerArticle.d.ts.map +1 -0
- package/dist/types/src/containers/ExplorerArticle/ExplorerArticle.stories.d.ts +30 -0
- package/dist/types/src/containers/ExplorerArticle/ExplorerArticle.stories.d.ts.map +1 -0
- package/dist/types/src/containers/ExplorerArticle/Visualization.d.ts +24 -0
- package/dist/types/src/containers/ExplorerArticle/Visualization.d.ts.map +1 -0
- package/dist/types/src/containers/ExplorerArticle/experimental.stories.d.ts +7 -0
- package/dist/types/src/containers/ExplorerArticle/experimental.stories.d.ts.map +1 -0
- package/dist/types/src/containers/ExplorerArticle/index.d.ts +2 -0
- package/dist/types/src/containers/ExplorerArticle/index.d.ts.map +1 -0
- package/dist/types/src/containers/ExplorerArticle/variants.d.ts +9 -0
- package/dist/types/src/containers/ExplorerArticle/variants.d.ts.map +1 -0
- package/dist/types/src/containers/index.d.ts +3 -0
- package/dist/types/src/containers/index.d.ts.map +1 -0
- package/dist/types/src/hooks/useGraphModel.d.ts +2 -2
- package/dist/types/src/hooks/useGraphModel.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +1 -3
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/meta.d.ts +2 -2
- package/dist/types/src/meta.d.ts.map +1 -1
- package/dist/types/src/plugin.d.ts +3 -0
- package/dist/types/src/plugin.d.ts.map +1 -0
- package/dist/types/src/{components/Tree/testing → testing}/generator.d.ts +1 -1
- package/dist/types/src/testing/generator.d.ts.map +1 -0
- package/dist/types/src/testing/index.d.ts +4 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/relations.d.ts +47 -0
- package/dist/types/src/testing/relations.d.ts.map +1 -0
- package/dist/types/src/translations.d.ts +31 -22
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/src/types/ExplorerAction.d.ts +1 -18
- package/dist/types/src/types/ExplorerAction.d.ts.map +1 -1
- package/dist/types/src/types/Graph.d.ts +14 -25
- package/dist/types/src/types/Graph.d.ts.map +1 -1
- package/dist/types/src/util/index.d.ts +3 -0
- package/dist/types/src/util/index.d.ts.map +1 -0
- package/dist/types/src/util/node-color.d.ts +13 -0
- package/dist/types/src/util/node-color.d.ts.map +1 -0
- package/dist/types/src/{components → util}/plot.d.ts +1 -1
- package/dist/types/src/util/plot.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +114 -61
- package/src/ExplorerPlugin.test.ts +26 -0
- package/src/ExplorerPlugin.tsx +15 -56
- package/src/capabilities/create-object.ts +36 -0
- package/src/capabilities/index.ts +3 -3
- package/src/capabilities/react-surface.tsx +24 -19
- package/src/components/Chart/Chart.stories.tsx +16 -23
- package/src/components/Chart/Chart.tsx +1 -1
- package/src/components/Globe/Globe.stories.tsx +19 -22
- package/src/components/Globe/Globe.tsx +1 -1
- package/src/components/Graph/CanvasForceGraph.stories.tsx +83 -0
- package/src/components/Graph/CanvasForceGraph.tsx +124 -0
- package/src/components/Graph/ForceGraph.stories.tsx +83 -41
- package/src/components/Graph/ForceGraph.tsx +105 -85
- package/src/components/Graph/{adapter.ts → graph-adapter.ts} +14 -8
- package/src/components/Graph/index.ts +1 -1
- package/src/components/Lattice/Lattice.stories.tsx +90 -0
- package/src/components/Lattice/Lattice.tsx +182 -0
- package/src/components/Lattice/index.ts +5 -0
- package/src/components/Tree/EdgeBundling.stories.tsx +144 -0
- package/src/components/Tree/Tree.stories.tsx +20 -38
- package/src/components/Tree/Tree.tsx +69 -95
- package/src/components/Tree/index.ts +2 -0
- package/src/components/Tree/layout/HierarchicalEdgeBundling.tsx +335 -0
- package/src/components/Tree/layout/RadialTree.tsx +242 -0
- package/src/components/Tree/layout/TidyTree.tsx +246 -0
- package/src/components/Tree/layout/hierarchy.ts +32 -0
- package/src/components/Tree/layout/index.ts +5 -5
- package/src/components/Tree/layout/slots.ts +19 -0
- package/src/components/Tree/layout/useContainerSize.ts +43 -0
- package/src/components/Tree/types/tree.test.ts +8 -7
- package/src/components/Tree/types/tree.ts +42 -26
- package/src/components/Tree/types/types.ts +38 -29
- package/src/components/index.ts +1 -4
- package/src/containers/ExplorerArticle/ExplorerArticle.stories.tsx +142 -0
- package/src/containers/ExplorerArticle/ExplorerArticle.tsx +112 -0
- package/src/containers/ExplorerArticle/Visualization.tsx +497 -0
- package/src/containers/ExplorerArticle/experimental.stories.tsx +446 -0
- package/src/containers/ExplorerArticle/index.ts +5 -0
- package/src/containers/ExplorerArticle/variants.ts +37 -0
- package/src/containers/index.ts +7 -0
- package/src/hooks/useGraphModel.ts +25 -14
- package/src/index.ts +1 -4
- package/src/meta.ts +28 -5
- package/src/plugin.ts +9 -0
- package/src/{components/Tree/testing → testing}/generator.ts +5 -3
- package/src/testing/index.ts +9 -0
- package/src/testing/relations.ts +192 -0
- package/src/translations.ts +16 -13
- package/src/types/ExplorerAction.ts +10 -19
- package/src/types/Graph.ts +25 -24
- package/src/typings.d.ts +8 -0
- package/src/util/index.ts +6 -0
- package/src/util/node-color.ts +23 -0
- package/src/{components → util}/plot.ts +16 -4
- package/dist/lib/browser/ExplorerContainer-6SHZJ6AK.mjs +0 -50
- package/dist/lib/browser/ExplorerContainer-6SHZJ6AK.mjs.map +0 -7
- package/dist/lib/browser/chunk-2DGFNLRO.mjs +0 -19
- package/dist/lib/browser/chunk-2DGFNLRO.mjs.map +0 -7
- package/dist/lib/browser/chunk-2MKBRIUT.mjs +0 -31
- package/dist/lib/browser/chunk-2MKBRIUT.mjs.map +0 -7
- package/dist/lib/browser/chunk-BZ65T5M3.mjs +0 -79
- package/dist/lib/browser/chunk-BZ65T5M3.mjs.map +0 -7
- package/dist/lib/browser/chunk-MOM5KCKC.mjs.map +0 -7
- package/dist/lib/browser/chunk-NXGP6NTP.mjs +0 -203
- package/dist/lib/browser/chunk-NXGP6NTP.mjs.map +0 -7
- package/dist/lib/browser/index.mjs +0 -119
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/intent-resolver-K57C3LIX.mjs +0 -31
- package/dist/lib/browser/intent-resolver-K57C3LIX.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/browser/react-surface-OPBND5W3.mjs +0 -35
- package/dist/lib/browser/react-surface-OPBND5W3.mjs.map +0 -7
- package/dist/lib/node-esm/ExplorerContainer-FRTDXZI5.mjs +0 -51
- package/dist/lib/node-esm/ExplorerContainer-FRTDXZI5.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-3ODK27PU.mjs +0 -33
- package/dist/lib/node-esm/chunk-3ODK27PU.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-6JACZE7E.mjs +0 -205
- package/dist/lib/node-esm/chunk-6JACZE7E.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-ES6AOMCY.mjs +0 -80
- package/dist/lib/node-esm/chunk-ES6AOMCY.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +0 -11
- package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-PX6LHR2N.mjs +0 -21
- package/dist/lib/node-esm/chunk-PX6LHR2N.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-ZCV4U7LT.mjs +0 -11343
- package/dist/lib/node-esm/chunk-ZCV4U7LT.mjs.map +0 -7
- package/dist/lib/node-esm/index.mjs +0 -120
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/intent-resolver-BLPPTTEY.mjs +0 -32
- package/dist/lib/node-esm/intent-resolver-BLPPTTEY.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
- package/dist/lib/node-esm/meta.mjs +0 -9
- package/dist/lib/node-esm/react-surface-HCKQSHKJ.mjs +0 -36
- package/dist/lib/node-esm/react-surface-HCKQSHKJ.mjs.map +0 -7
- package/dist/lib/node-esm/types/index.mjs +0 -12
- package/dist/types/src/capabilities/intent-resolver.d.ts +0 -4
- package/dist/types/src/capabilities/intent-resolver.d.ts.map +0 -1
- package/dist/types/src/components/ExplorerContainer.d.ts +0 -9
- package/dist/types/src/components/ExplorerContainer.d.ts.map +0 -1
- package/dist/types/src/components/Graph/D3ForceGraph.d.ts +0 -14
- package/dist/types/src/components/Graph/D3ForceGraph.d.ts.map +0 -1
- package/dist/types/src/components/Graph/D3ForceGraph.stories.d.ts +0 -15
- package/dist/types/src/components/Graph/D3ForceGraph.stories.d.ts.map +0 -1
- package/dist/types/src/components/Graph/adapter.d.ts.map +0 -1
- package/dist/types/src/components/Graph/testing.d.ts +0 -14
- package/dist/types/src/components/Graph/testing.d.ts.map +0 -1
- package/dist/types/src/components/Tree/testing/generator.d.ts.map +0 -1
- package/dist/types/src/components/Tree/testing/index.d.ts +0 -2
- package/dist/types/src/components/Tree/testing/index.d.ts.map +0 -1
- package/dist/types/src/components/plot.d.ts.map +0 -1
- package/src/capabilities/intent-resolver.ts +0 -21
- package/src/components/ExplorerContainer.tsx +0 -54
- package/src/components/Graph/D3ForceGraph.stories.tsx +0 -77
- package/src/components/Graph/D3ForceGraph.tsx +0 -101
- package/src/components/Graph/testing.ts +0 -55
- package/src/components/Tree/layout/HierarchicalEdgeBundling.ts +0 -162
- package/src/components/Tree/layout/RadialTree.ts +0 -94
- package/src/components/Tree/layout/TidyTree.ts +0 -101
- package/src/components/Tree/testing/index.ts +0 -5
- /package/dist/lib/{browser/chunk-J5LGTIGS.mjs.map → neutral/ExplorerPlugin.mjs.map} +0 -0
- /package/dist/lib/{browser → neutral}/chunk-J5LGTIGS.mjs +0 -0
- /package/dist/lib/{browser/meta.mjs.map → neutral/chunk-J5LGTIGS.mjs.map} +0 -0
- /package/dist/lib/{browser/types → neutral}/index.mjs.map +0 -0
- /package/dist/lib/{node-esm → neutral}/meta.mjs.map +0 -0
- /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
|
+
};
|