@dxos/plugin-explorer 0.6.8-main.046e6cf

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 (124) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +3 -0
  3. package/dist/lib/browser/ExplorerArticle-IPAJQMAX.mjs +27 -0
  4. package/dist/lib/browser/ExplorerArticle-IPAJQMAX.mjs.map +7 -0
  5. package/dist/lib/browser/ExplorerMain-3KFXOEYO.mjs +33 -0
  6. package/dist/lib/browser/ExplorerMain-3KFXOEYO.mjs.map +7 -0
  7. package/dist/lib/browser/chunk-7YEM64IQ.mjs +473 -0
  8. package/dist/lib/browser/chunk-7YEM64IQ.mjs.map +7 -0
  9. package/dist/lib/browser/chunk-JIDPF2GF.mjs +27 -0
  10. package/dist/lib/browser/chunk-JIDPF2GF.mjs.map +7 -0
  11. package/dist/lib/browser/chunk-TL6ADY3P.mjs +21 -0
  12. package/dist/lib/browser/chunk-TL6ADY3P.mjs.map +7 -0
  13. package/dist/lib/browser/index.mjs +38151 -0
  14. package/dist/lib/browser/index.mjs.map +7 -0
  15. package/dist/lib/browser/meta.json +1 -0
  16. package/dist/lib/browser/meta.mjs +9 -0
  17. package/dist/lib/browser/meta.mjs.map +7 -0
  18. package/dist/lib/browser/types/index.mjs +10 -0
  19. package/dist/lib/browser/types/index.mjs.map +7 -0
  20. package/dist/lib/node/ExplorerArticle-PYOGBY3Z.cjs +53 -0
  21. package/dist/lib/node/ExplorerArticle-PYOGBY3Z.cjs.map +7 -0
  22. package/dist/lib/node/ExplorerMain-HGCLO5O4.cjs +59 -0
  23. package/dist/lib/node/ExplorerMain-HGCLO5O4.cjs.map +7 -0
  24. package/dist/lib/node/chunk-2GOPBQBC.cjs +494 -0
  25. package/dist/lib/node/chunk-2GOPBQBC.cjs.map +7 -0
  26. package/dist/lib/node/chunk-HYXFS3AG.cjs +45 -0
  27. package/dist/lib/node/chunk-HYXFS3AG.cjs.map +7 -0
  28. package/dist/lib/node/chunk-UJZYAOD2.cjs +54 -0
  29. package/dist/lib/node/chunk-UJZYAOD2.cjs.map +7 -0
  30. package/dist/lib/node/index.cjs +38158 -0
  31. package/dist/lib/node/index.cjs.map +7 -0
  32. package/dist/lib/node/meta.cjs +30 -0
  33. package/dist/lib/node/meta.cjs.map +7 -0
  34. package/dist/lib/node/meta.json +1 -0
  35. package/dist/lib/node/types/index.cjs +32 -0
  36. package/dist/lib/node/types/index.cjs.map +7 -0
  37. package/dist/types/src/ExplorerPlugin.d.ts +4 -0
  38. package/dist/types/src/ExplorerPlugin.d.ts.map +1 -0
  39. package/dist/types/src/components/Chart/Chart.d.ts +10 -0
  40. package/dist/types/src/components/Chart/Chart.d.ts.map +1 -0
  41. package/dist/types/src/components/Chart/Chart.stories.d.ts +11 -0
  42. package/dist/types/src/components/Chart/Chart.stories.d.ts.map +1 -0
  43. package/dist/types/src/components/Chart/index.d.ts +2 -0
  44. package/dist/types/src/components/Chart/index.d.ts.map +1 -0
  45. package/dist/types/src/components/ExplorerArticle.d.ts +7 -0
  46. package/dist/types/src/components/ExplorerArticle.d.ts.map +1 -0
  47. package/dist/types/src/components/ExplorerMain.d.ts +7 -0
  48. package/dist/types/src/components/ExplorerMain.d.ts.map +1 -0
  49. package/dist/types/src/components/Globe/Globe.d.ts +12 -0
  50. package/dist/types/src/components/Globe/Globe.d.ts.map +1 -0
  51. package/dist/types/src/components/Globe/Globe.stories.d.ts +12 -0
  52. package/dist/types/src/components/Globe/Globe.stories.d.ts.map +1 -0
  53. package/dist/types/src/components/Globe/index.d.ts +2 -0
  54. package/dist/types/src/components/Globe/index.d.ts.map +1 -0
  55. package/dist/types/src/components/Graph/Graph.d.ts +8 -0
  56. package/dist/types/src/components/Graph/Graph.d.ts.map +1 -0
  57. package/dist/types/src/components/Graph/Graph.stories.d.ts +14 -0
  58. package/dist/types/src/components/Graph/Graph.stories.d.ts.map +1 -0
  59. package/dist/types/src/components/Graph/graph-model.d.ts +33 -0
  60. package/dist/types/src/components/Graph/graph-model.d.ts.map +1 -0
  61. package/dist/types/src/components/Graph/index.d.ts +3 -0
  62. package/dist/types/src/components/Graph/index.d.ts.map +1 -0
  63. package/dist/types/src/components/Tree/Tree.d.ts +27 -0
  64. package/dist/types/src/components/Tree/Tree.d.ts.map +1 -0
  65. package/dist/types/src/components/Tree/Tree.stories.d.ts +29 -0
  66. package/dist/types/src/components/Tree/Tree.stories.d.ts.map +1 -0
  67. package/dist/types/src/components/Tree/index.d.ts +2 -0
  68. package/dist/types/src/components/Tree/index.d.ts.map +1 -0
  69. package/dist/types/src/components/Tree/layout/HierarchicalEdgeBundling.d.ts +5 -0
  70. package/dist/types/src/components/Tree/layout/HierarchicalEdgeBundling.d.ts.map +1 -0
  71. package/dist/types/src/components/Tree/layout/RadialTree.d.ts +4 -0
  72. package/dist/types/src/components/Tree/layout/RadialTree.d.ts.map +1 -0
  73. package/dist/types/src/components/Tree/layout/TidyTree.d.ts +4 -0
  74. package/dist/types/src/components/Tree/layout/TidyTree.d.ts.map +1 -0
  75. package/dist/types/src/components/Tree/layout/index.d.ts +5 -0
  76. package/dist/types/src/components/Tree/layout/index.d.ts.map +1 -0
  77. package/dist/types/src/components/Tree/types.d.ts +8 -0
  78. package/dist/types/src/components/Tree/types.d.ts.map +1 -0
  79. package/dist/types/src/components/index.d.ts +12 -0
  80. package/dist/types/src/components/index.d.ts.map +1 -0
  81. package/dist/types/src/components/plot.d.ts +12 -0
  82. package/dist/types/src/components/plot.d.ts.map +1 -0
  83. package/dist/types/src/index.d.ts +5 -0
  84. package/dist/types/src/index.d.ts.map +1 -0
  85. package/dist/types/src/meta.d.ts +15 -0
  86. package/dist/types/src/meta.d.ts.map +1 -0
  87. package/dist/types/src/translations.d.ts +12 -0
  88. package/dist/types/src/translations.d.ts.map +1 -0
  89. package/dist/types/src/types/index.d.ts +3 -0
  90. package/dist/types/src/types/index.d.ts.map +1 -0
  91. package/dist/types/src/types/types.d.ts +7 -0
  92. package/dist/types/src/types/types.d.ts.map +1 -0
  93. package/dist/types/src/types/view.d.ts +14 -0
  94. package/dist/types/src/types/view.d.ts.map +1 -0
  95. package/package.json +98 -0
  96. package/src/ExplorerPlugin.tsx +103 -0
  97. package/src/components/Chart/Chart.stories.tsx +46 -0
  98. package/src/components/Chart/Chart.tsx +54 -0
  99. package/src/components/Chart/index.ts +5 -0
  100. package/src/components/ExplorerArticle.tsx +28 -0
  101. package/src/components/ExplorerMain.tsx +34 -0
  102. package/src/components/Globe/Globe.stories.tsx +115 -0
  103. package/src/components/Globe/Globe.tsx +65 -0
  104. package/src/components/Globe/index.ts +5 -0
  105. package/src/components/Graph/Graph.stories.tsx +59 -0
  106. package/src/components/Graph/Graph.tsx +151 -0
  107. package/src/components/Graph/graph-model.ts +146 -0
  108. package/src/components/Graph/index.ts +7 -0
  109. package/src/components/Tree/Tree.stories.tsx +97 -0
  110. package/src/components/Tree/Tree.tsx +109 -0
  111. package/src/components/Tree/index.ts +5 -0
  112. package/src/components/Tree/layout/HierarchicalEdgeBundling.ts +164 -0
  113. package/src/components/Tree/layout/RadialTree.ts +96 -0
  114. package/src/components/Tree/layout/TidyTree.ts +102 -0
  115. package/src/components/Tree/layout/index.ts +9 -0
  116. package/src/components/Tree/types.ts +39 -0
  117. package/src/components/index.ts +14 -0
  118. package/src/components/plot.ts +15 -0
  119. package/src/index.ts +12 -0
  120. package/src/meta.tsx +19 -0
  121. package/src/translations.ts +18 -0
  122. package/src/types/index.ts +6 -0
  123. package/src/types/types.ts +27 -0
  124. package/src/types/view.ts +11 -0
@@ -0,0 +1,151 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import React, { type FC, useEffect, useMemo, useRef, useState } from 'react';
6
+
7
+ import { type Space } from '@dxos/client/echo';
8
+ import { type EchoReactiveObject, getType } from '@dxos/echo-schema';
9
+ import {
10
+ createSvgContext,
11
+ darkGridStyles,
12
+ defaultGridStyles,
13
+ Grid,
14
+ SVG,
15
+ SVGContextProvider,
16
+ Zoom,
17
+ } from '@dxos/gem-core';
18
+ import { Graph as GraphComponent, GraphForceProjector, type GraphLayoutNode, Markers } from '@dxos/gem-spore';
19
+ import { filterObjectsSync, type SearchResult } from '@dxos/plugin-search';
20
+ import { useThemeContext } from '@dxos/react-ui';
21
+ import { mx } from '@dxos/react-ui-theme';
22
+
23
+ import { type EchoGraphNode, SpaceGraphModel } from './graph-model';
24
+ import { Tree } from '../Tree';
25
+
26
+ type Slots = {
27
+ root?: { className?: string };
28
+ grid?: { className?: string };
29
+ };
30
+
31
+ const slots: Slots = {};
32
+
33
+ const colors = [
34
+ '[&>circle]:!fill-black-300 [&>circle]:!stroke-black-600',
35
+ '[&>circle]:!fill-slate-300 [&>circle]:!stroke-slate-600',
36
+ '[&>circle]:!fill-green-300 [&>circle]:!stroke-green-600',
37
+ '[&>circle]:!fill-sky-300 [&>circle]:!stroke-sky-600',
38
+ '[&>circle]:!fill-cyan-300 [&>circle]:!stroke-cyan-600',
39
+ '[&>circle]:!fill-rose-300 [&>circle]:!stroke-rose-600',
40
+ '[&>circle]:!fill-purple-300 [&>circle]:!stroke-purple-600',
41
+ '[&>circle]:!fill-orange-300 [&>circle]:!stroke-orange-600',
42
+ '[&>circle]:!fill-teal-300 [&>circle]:!stroke-teal-600',
43
+ '[&>circle]:!fill-indigo-300 [&>circle]:!stroke-indigo-600',
44
+ ];
45
+
46
+ export type GraphProps = {
47
+ space: Space;
48
+ match?: RegExp;
49
+ };
50
+
51
+ export const Graph: FC<GraphProps> = ({ space, match }) => {
52
+ const model = useMemo(() => (space ? new SpaceGraphModel({ schema: true }).open(space) : undefined), [space]);
53
+ const [selected, setSelected] = useState<string>();
54
+ const { themeMode } = useThemeContext();
55
+
56
+ const context = createSvgContext();
57
+ const projector = useMemo(
58
+ () =>
59
+ new GraphForceProjector<EchoGraphNode>(context, {
60
+ forces: {
61
+ manyBody: {
62
+ strength: -100,
63
+ },
64
+ link: {
65
+ distance: 180,
66
+ },
67
+ radial: {
68
+ radius: 200,
69
+ strength: 0.05,
70
+ },
71
+ },
72
+ attributes: {
73
+ radius: (node: GraphLayoutNode<EchoGraphNode>) => (node.data?.type === 'schema' ? 24 : 12),
74
+ },
75
+ }),
76
+ [],
77
+ );
78
+
79
+ const filteredRef = useRef<SearchResult[]>();
80
+ filteredRef.current = filterObjectsSync(model?.objects ?? [], match);
81
+ useEffect(() => {
82
+ void projector.start();
83
+ }, [match]);
84
+
85
+ const [colorMap] = useState(new Map<string, string>());
86
+
87
+ if (!model) {
88
+ return null;
89
+ }
90
+
91
+ if (selected) {
92
+ return <Tree space={space} selected={selected} variant='tidy' onNodeClick={() => setSelected(undefined)} />;
93
+ }
94
+
95
+ return (
96
+ <SVGContextProvider context={context}>
97
+ <SVG className={slots?.root?.className}>
98
+ <Markers arrowSize={6} />
99
+ <Grid className={slots?.grid?.className ?? themeMode === 'dark' ? darkGridStyles : defaultGridStyles} />
100
+ <Zoom extent={[1 / 2, 4]}>
101
+ <GraphComponent
102
+ model={model}
103
+ projector={projector}
104
+ drag
105
+ arrows
106
+ onSelect={(node) => setSelected(node?.data?.id)}
107
+ labels={{
108
+ text: (node: GraphLayoutNode<EchoReactiveObject<any>>) => {
109
+ if (filteredRef.current?.length && !filteredRef.current.some((object) => object.id === node.data?.id)) {
110
+ return undefined;
111
+ }
112
+
113
+ // TODO(burdon): Use schema.
114
+ return node.data?.label ?? node.data?.title ?? node.data?.name ?? node.data?.id.slice(0, 8);
115
+ },
116
+ }}
117
+ attributes={{
118
+ node: (node: GraphLayoutNode<EchoReactiveObject<any>>) => {
119
+ let className: string | undefined;
120
+ if (node.data) {
121
+ const typename = getType(node.data)?.objectId;
122
+ if (typename) {
123
+ className = colorMap.get(typename);
124
+ if (!className) {
125
+ className = colors[colorMap.size % colors.length];
126
+ colorMap.set(typename, className);
127
+ }
128
+ }
129
+ }
130
+
131
+ const selected = filteredRef.current?.some((object) => object.id === node.data?.id);
132
+ return {
133
+ class: mx(
134
+ filteredRef.current?.length
135
+ ? selected
136
+ ? [className]
137
+ : '[&>text]:!fill-neutral-300'
138
+ : ['[&>text]:!fill-neutral-700', className],
139
+ ),
140
+ };
141
+ },
142
+ link: () => ({
143
+ class: '[&>path]:!stroke-neutral-300',
144
+ }),
145
+ }}
146
+ />
147
+ </Zoom>
148
+ </SVG>
149
+ </SVGContextProvider>
150
+ );
151
+ };
@@ -0,0 +1,146 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import {
6
+ AST,
7
+ DynamicSchema,
8
+ getSchema,
9
+ getType,
10
+ ReferenceAnnotationId,
11
+ type S,
12
+ SchemaValidator,
13
+ StoredSchema,
14
+ } from '@dxos/echo-schema';
15
+ import { type GraphData, type GraphLink, GraphModel } from '@dxos/gem-spore';
16
+ import { log } from '@dxos/log';
17
+ import { CollectionType } from '@dxos/plugin-space/types';
18
+ import { type EchoReactiveObject, type Space, type Subscription } from '@dxos/react-client/echo';
19
+
20
+ export type SpaceGraphModelOptions = {
21
+ schema?: boolean;
22
+ };
23
+
24
+ export type EchoGraphNode = SchemaGraphNode | EchoObjectGraphNode;
25
+
26
+ type EchoObjectGraphNode = {
27
+ id: string;
28
+ type: 'echo-object';
29
+ object: EchoReactiveObject<any>;
30
+ };
31
+
32
+ type SchemaGraphNode = {
33
+ id: string;
34
+ type: 'schema';
35
+ schema: S.Schema<any>;
36
+ };
37
+
38
+ /**
39
+ * Converts ECHO objects to a graph.
40
+ */
41
+ export class SpaceGraphModel extends GraphModel<EchoGraphNode> {
42
+ private readonly _graph: GraphData<EchoGraphNode> = {
43
+ nodes: [],
44
+ links: [],
45
+ };
46
+
47
+ private _subscription?: Subscription;
48
+ private _objects?: EchoReactiveObject<any>[];
49
+
50
+ constructor(private readonly _options: SpaceGraphModelOptions = {}) {
51
+ super();
52
+ }
53
+
54
+ override get graph(): GraphData<EchoGraphNode> {
55
+ return this._graph;
56
+ }
57
+
58
+ get objects(): EchoReactiveObject<any>[] {
59
+ return this._objects ?? [];
60
+ }
61
+
62
+ open(space: Space, objectId?: string) {
63
+ if (!this._subscription) {
64
+ // TODO(burdon): Filter.
65
+ const query = space.db.query((object: EchoReactiveObject<any>) => !(object instanceof CollectionType));
66
+
67
+ this._subscription = query.subscribe(
68
+ ({ objects }) => {
69
+ this._objects = objects;
70
+ this._graph.nodes = objects.map((object) => {
71
+ if (object instanceof StoredSchema) {
72
+ const effectSchema = space.db.schema.getSchemaById(object.id)!;
73
+ return { type: 'schema', id: object.id, schema: effectSchema.schema };
74
+ }
75
+ return { type: 'echo-object', id: object.id, object };
76
+ });
77
+ this._graph.links = objects.reduce<GraphLink[]>((links, object) => {
78
+ const objectSchema = getSchema(object);
79
+ const typename = getType(object)?.objectId;
80
+ if (objectSchema == null || typename == null) {
81
+ log.info('no schema for object:', { id: object.id.slice(0, 8) });
82
+ return links;
83
+ }
84
+
85
+ if (!(objectSchema instanceof DynamicSchema)) {
86
+ const idx = objects.findIndex((obj) => obj.id === typename);
87
+ if (idx === -1) {
88
+ this._graph.nodes.push({
89
+ id: typename,
90
+ type: 'schema',
91
+ schema: objectSchema,
92
+ });
93
+ }
94
+ }
95
+
96
+ // Link to schema.
97
+ if (this._options.schema) {
98
+ links.push({
99
+ id: `${object.id}-${typename}`,
100
+ source: object.id,
101
+ target: typename,
102
+ });
103
+ }
104
+
105
+ // Parse schema to follow referenced objects.
106
+ AST.getPropertySignatures(objectSchema.ast).forEach((prop) => {
107
+ if (!SchemaValidator.hasTypeAnnotation(objectSchema, prop.name.toString(), ReferenceAnnotationId)) {
108
+ return;
109
+ }
110
+ const value = object[String(prop.name)];
111
+ if (value) {
112
+ const refs = Array.isArray(value) ? value : [value];
113
+ for (const ref of refs) {
114
+ if (objects.findIndex((obj) => obj.id === ref.id) !== -1) {
115
+ links.push({
116
+ id: `${object.id}-${String(prop.name)}-${ref.id}`,
117
+ source: object.id,
118
+ target: ref.id,
119
+ });
120
+ }
121
+ }
122
+ }
123
+ });
124
+
125
+ return links;
126
+ }, []);
127
+
128
+ this.triggerUpdate();
129
+ },
130
+ { fire: true },
131
+ );
132
+ }
133
+
134
+ this.setSelected(objectId);
135
+ return this;
136
+ }
137
+
138
+ close() {
139
+ if (this._subscription) {
140
+ this._subscription();
141
+ this._subscription = undefined;
142
+ }
143
+
144
+ return this;
145
+ }
146
+ }
@@ -0,0 +1,7 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ export * from './graph-model';
6
+
7
+ export * from './Graph';
@@ -0,0 +1,97 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import '@dxosTheme';
6
+
7
+ import React, { type FC, useEffect, useState } from 'react';
8
+
9
+ import { range } from '@dxos/echo-generator';
10
+ import { create } from '@dxos/echo-schema';
11
+ import { TreeItemType, TreeType } from '@dxos/plugin-outliner/types';
12
+ import { faker } from '@dxos/random';
13
+ import { useClient } from '@dxos/react-client';
14
+ import { ClientRepeater } from '@dxos/react-client/testing';
15
+ import { withFullscreen, withTheme } from '@dxos/storybook-utils';
16
+
17
+ import { Tree, type TreeComponentProps } from './Tree';
18
+
19
+ // TODO(burdon): Storybook for Graph/Tree/Plot (generics); incl. GraphModel.
20
+ // TODO(burdon): Type for all Explorer components (Space, Object, Query, etc.) incl.
21
+ // TODO(burdon): Factor out to gem?
22
+
23
+ faker.seed(1);
24
+
25
+ const makeTreeItems = <T extends number>(count: T, items: TreeItemType[] = []) => {
26
+ return range(() => create(TreeItemType, { content: '', items }), count);
27
+ };
28
+
29
+ const Story: FC<{ type?: TreeComponentProps<any>['variant'] }> = ({ type } = {}) => {
30
+ const client = useClient();
31
+ const space = client.spaces.default;
32
+ const [object, setObject] = useState<TreeType>();
33
+ useEffect(() => {
34
+ setTimeout(() => {
35
+ const tree = create(TreeType, {
36
+ root: makeTreeItems(1, [
37
+ ...makeTreeItems(7),
38
+ ...makeTreeItems(1, [
39
+ ...makeTreeItems(1),
40
+ ...makeTreeItems(1, [
41
+ ...makeTreeItems(3),
42
+ ...makeTreeItems(1, makeTreeItems(2)),
43
+ ...makeTreeItems(2),
44
+ ...makeTreeItems(1, makeTreeItems(2)),
45
+ ...makeTreeItems(2),
46
+ ]),
47
+ ...makeTreeItems(1),
48
+ ]),
49
+ ...makeTreeItems(2),
50
+ ...makeTreeItems(1, [
51
+ ...makeTreeItems(1),
52
+ ...makeTreeItems(1, [...makeTreeItems(2), ...makeTreeItems(1, makeTreeItems(2))]),
53
+ ...makeTreeItems(1),
54
+ ]),
55
+ ...makeTreeItems(2),
56
+ ])[0],
57
+ });
58
+
59
+ space.db.add(tree);
60
+ setObject(tree);
61
+ });
62
+ }, []);
63
+
64
+ if (!object) {
65
+ return null;
66
+ }
67
+
68
+ return <Tree space={space} selected={object?.id} variant={type} />;
69
+ };
70
+
71
+ export default {
72
+ title: 'plugin-explorer/Tree',
73
+ component: Tree,
74
+ render: () => <ClientRepeater component={Story} types={[TreeType, TreeItemType]} createSpace />,
75
+ decorators: [withTheme, withFullscreen()],
76
+ parameters: {
77
+ layout: 'fullscreen',
78
+ },
79
+ };
80
+
81
+ export const Tidy = {
82
+ args: {
83
+ type: 'tidy',
84
+ },
85
+ };
86
+
87
+ export const Radial = {
88
+ args: {
89
+ type: 'radial',
90
+ },
91
+ };
92
+
93
+ export const Edge = {
94
+ args: {
95
+ type: 'edge',
96
+ },
97
+ };
@@ -0,0 +1,109 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import React, { useEffect, useMemo, useState } from 'react';
6
+ import { useResizeDetector } from 'react-resize-detector';
7
+
8
+ import { type Space } from '@dxos/client/echo';
9
+ import { createSvgContext, SVG, SVGContextProvider } from '@dxos/gem-core';
10
+
11
+ import { HierarchicalEdgeBundling, RadialTree, TidyTree } from './layout';
12
+ import { mapGraphToTreeData, type TreeNode } from './types';
13
+ import { SpaceGraphModel } from '../Graph';
14
+
15
+ // TODO(burdon): Create dge bundling graph using d3.hierarchy.
16
+ // https://observablehq.com/@d3/hierarchical-edge-bundling?intent=fork
17
+
18
+ type Renderer = (svg: SVGSVGElement, data: any, options: any) => void;
19
+
20
+ export type LayoutVariant = 'tidy' | 'radial' | 'edge';
21
+
22
+ // TODO(burdon): Remove slots?
23
+ export type TreeLayoutSlots = {
24
+ node?: string;
25
+ path?: string;
26
+ text?: string;
27
+ };
28
+
29
+ export type TreeOptions = {
30
+ label: (d: any) => string;
31
+
32
+ slots?: TreeLayoutSlots;
33
+ radius?: number;
34
+
35
+ width: number;
36
+ height: number;
37
+ margin?: number;
38
+
39
+ padding?: number;
40
+ // Radius of nodes.
41
+ r?: number;
42
+ };
43
+
44
+ export const defaultTreeLayoutSlots: TreeLayoutSlots = {
45
+ node: 'fill-blue-600',
46
+ path: 'fill-none stroke-blue-400 stroke-[0.5px]',
47
+ text: 'stroke-[0.5px] stroke-neutral-700 text-xs', // TODO(burdon): Create box instead of halo.
48
+ };
49
+
50
+ const renderers = new Map<LayoutVariant, Renderer>([
51
+ ['tidy', TidyTree],
52
+ ['radial', RadialTree],
53
+ ['edge', HierarchicalEdgeBundling],
54
+ ]);
55
+
56
+ export type TreeComponentProps<N = unknown> = {
57
+ space: Space;
58
+ selected?: string;
59
+ variant?: LayoutVariant;
60
+ onNodeClick?: (node?: N) => void;
61
+ };
62
+
63
+ // TODO(burdon): Label accessor.
64
+ export const Tree = <N,>({ space, selected, variant = 'tidy', onNodeClick }: TreeComponentProps<N>) => {
65
+ const model = useMemo(() => (space ? new SpaceGraphModel().open(space, selected) : undefined), [space, selected]);
66
+ const [tree, setTree] = useState<TreeNode>();
67
+ useEffect(() => {
68
+ return model?.subscribe(() => {
69
+ const tree = mapGraphToTreeData(model);
70
+ setTree(tree);
71
+ }, true);
72
+ }, [model]);
73
+
74
+ const context = createSvgContext();
75
+ const { ref, width = 0, height = 0 } = useResizeDetector();
76
+
77
+ useEffect(() => {
78
+ if (width && height) {
79
+ const size = Math.min(width, height);
80
+ const radius = size * 0.4;
81
+ const options = {
82
+ // TODO(burdon): Type.
83
+ label: (d: any) => d.label ?? d.id,
84
+ width,
85
+ height,
86
+ radius,
87
+ marginLeft: (width - radius * 2) / 2,
88
+ marginRight: (width - radius * 2) / 2,
89
+ marginTop: (height - radius * 2) / 2,
90
+ marginBottom: (height - radius * 2) / 2,
91
+ slots: defaultTreeLayoutSlots,
92
+ };
93
+
94
+ if (tree) {
95
+ const renderer = renderers.get(variant);
96
+ renderer?.(context.ref.current!, tree, options);
97
+ }
98
+ }
99
+ }, [tree, width, height]);
100
+
101
+ // TODO(burdon): Provider should expand.
102
+ return (
103
+ <div ref={ref} className='flex grow overflow-hidden' onClick={() => onNodeClick?.()}>
104
+ <SVGContextProvider context={context}>
105
+ <SVG />
106
+ </SVGContextProvider>
107
+ </div>
108
+ );
109
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ export * from './Tree';
@@ -0,0 +1,164 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ // Copyright 2022 Observable, Inc.
4
+ //
5
+
6
+ import * as d3 from 'd3';
7
+ import { type HierarchyNode } from 'd3-hierarchy';
8
+
9
+ import { type TreeOptions } from '../Tree';
10
+ import { type TreeNode } from '../types';
11
+
12
+ // Create hierarchical ID.
13
+ // eslint-disable-next-line unused-imports/no-unused-vars
14
+ const getId = (node: HierarchyNode<TreeNode>): string =>
15
+ `${node.parent ? getId(node.parent) + '/' : ''}${node.data.id.slice(0, 4)}`;
16
+
17
+ // https://github.com/d3/d3-hierarchy
18
+ // https://observablehq.com/@d3/hierarchical-edge-bundling?intent=fork
19
+ const HierarchicalEdgeBundling = (s: SVGSVGElement, data: TreeNode, options: TreeOptions) => {
20
+ const svg = d3.select(s);
21
+ svg.selectAll('*').remove();
22
+
23
+ const { radius = 600, padding = 100, slots } = options;
24
+
25
+ // https://d3js.org/d3-hierarchy/hierarchy
26
+ const root = d3.hierarchy(flatten(data));
27
+ // .sort((a, b) => d3.ascending(a.height, b.height) || d3.ascending(getName(a.data), getName(b.data)));
28
+
29
+ const tree = d3.cluster<TreeNode>().size([2 * Math.PI, radius - padding]);
30
+ const layout = tree(addLinks(root));
31
+
32
+ // eslint-disable-next-line unused-imports/no-unused-vars
33
+ const node = svg
34
+ .append('g')
35
+ .selectAll()
36
+ .data(layout.leaves())
37
+ .join('g')
38
+ .attr('transform', (d) => `rotate(${d.x * (180 / Math.PI) - 90}) translate(${d.y},0)`)
39
+ .append('text')
40
+ .attr('class', slots?.text ?? '')
41
+ .attr('dy', '0.31em') // TODO(burdon): Based on font size.
42
+ .attr('x', (d) => (d.x < Math.PI ? 6 : -6))
43
+ .attr('text-anchor', (d) => (d.x < Math.PI ? 'start' : 'end'))
44
+ .attr('transform', (d) => (d.x >= Math.PI ? 'rotate(180)' : null))
45
+ // .text((d: any) => d.data.id)
46
+ // .each(function (d: any) {
47
+ // d.text = this;
48
+ // })
49
+ // .on('mouseover', overed)
50
+ // .on('mouseout', outed)
51
+ .call(
52
+ (text) => text.text((d: any) => d.data.id.slice(0, 8)),
53
+ // .text((d: any) => `${getId(d)} [${[(d as any).outgoing?.length ?? 0]}]`),
54
+ );
55
+
56
+ // https://d3js.org/d3-shape/radial-line
57
+ const line = d3
58
+ .lineRadial()
59
+ .curve(d3.curveBundle.beta(0.85))
60
+ .radius((d: any) => d.y)
61
+ .angle((d: any) => d.x);
62
+
63
+ // eslint-disable-next-line unused-imports/no-unused-vars
64
+ const links = svg
65
+ .append('g')
66
+ .selectAll()
67
+ .data(layout.leaves().flatMap((leaf: any) => leaf.outgoing))
68
+ .join('path')
69
+ .style('mix-blend-mode', 'multiply')
70
+ .attr('class', slots?.path ?? '')
71
+ .attr('d', ([i, o]) => {
72
+ return line(i.path(o));
73
+ })
74
+ .each(function (d) {
75
+ d.path = this;
76
+ });
77
+
78
+ // function overed(event: any, d: X) {
79
+ // link.style('mix-blend-mode', null);
80
+ // d3.select(this).attr('font-weight', 'bold');
81
+ // d3.selectAll(d.incoming.map((d) => d.path))
82
+ // .attr('stroke', color.in)
83
+ // .raise();
84
+ // d3.selectAll((d as any).incoming.map(([d]) => d.text))
85
+ // .attr('fill', color.in)
86
+ // .attr('font-weight', 'bold');
87
+ // d3.selectAll(d.outgoing.map((d) => d.path))
88
+ // .attr('stroke', color.out)
89
+ // .raise();
90
+ // d3.selectAll(d.outgoing.map(([, d]) => d.text))
91
+ // .attr('fill', color.out)
92
+ // .attr('font-weight', 'bold');
93
+ // }
94
+
95
+ // function outed(event: any, d: HierarchyNode<Datum>) {
96
+ // // @ts-ignore
97
+ // d3.select(this).attr('font-weight', null);
98
+ // d3.selectAll(d.incoming.map((d) => d.path)).attr('stroke', null);
99
+ // d3.selectAll(d.incoming.map(([d]) => d.text))
100
+ // .attr('fill', null)
101
+ // .attr('font-weight', null);
102
+ // d3.selectAll(d.outgoing.map((d) => d.path)).attr('stroke', null);
103
+ // d3.selectAll(d.outgoing.map(([, d]) => d.text))
104
+ // .attr('fill', null)
105
+ // .attr('font-weight', null);
106
+ // }
107
+ };
108
+
109
+ // Monkey-patch with incoming/outgoing nodes.
110
+ const addLinks = (root: HierarchyNode<TreeNode>) => {
111
+ // Map of nodes indexed by ID.
112
+ const nodes = new Map(root.descendants().map((d) => [d.data.id, d]));
113
+ const parents = root.descendants().reduce((map, d) => {
114
+ if (d.children?.length) {
115
+ map.set(d.data.id, d);
116
+ }
117
+ return map;
118
+ }, new Map<string, HierarchyNode<TreeNode>>());
119
+
120
+ for (const d of root.leaves()) {
121
+ // (d as any).incoming = [];
122
+
123
+ const parent = parents.get(d.data.id);
124
+ if (parent) {
125
+ // Skip the first node which is a placeholder created by flatten().
126
+ (d as any).outgoing =
127
+ parent.data.children?.slice(1).map((child) => {
128
+ return [d, nodes.get(child.id)!];
129
+ }) ?? [];
130
+ } else {
131
+ (d as any).outgoing = [];
132
+ }
133
+ }
134
+
135
+ // for (const d of root.leaves()) {
136
+ // for (const [_, o] of (d as any).outgoing) {
137
+ // o.incoming.push(o);
138
+ // }
139
+ // }
140
+
141
+ return root;
142
+ };
143
+
144
+ /**
145
+ * We are using a hierarchy in order to group nodes by parent, but we want the parent
146
+ * nodes to be positioned at the first level along with all descendents.
147
+ * So we add a placeholder for all parents at the head of each group.
148
+ * @param node
149
+ */
150
+ const flatten = (node: TreeNode) => {
151
+ const clone: TreeNode = {
152
+ id: node.id,
153
+ };
154
+
155
+ // TODO(burdon): NOTE: Should exclude schema (since requires a tree).
156
+ if (node.children?.length) {
157
+ const children = node.children.map((child) => flatten(child));
158
+ clone.children = [{ id: node.id }, ...children];
159
+ }
160
+
161
+ return clone;
162
+ };
163
+
164
+ export default HierarchicalEdgeBundling;