@prnt/dagr-explorer 0.1.3
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/CHANGELOG.md +93 -0
- package/LICENSE +21 -0
- package/README.md +441 -0
- package/dist/base.d.ts +63 -0
- package/dist/base.d.ts.map +1 -0
- package/dist/base.js +20 -0
- package/dist/base.js.map +1 -0
- package/dist/camera.d.ts +61 -0
- package/dist/camera.d.ts.map +1 -0
- package/dist/camera.js +142 -0
- package/dist/camera.js.map +1 -0
- package/dist/context.d.ts +110 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +71 -0
- package/dist/context.js.map +1 -0
- package/dist/dagr-explorer.d.ts +41 -0
- package/dist/dagr-explorer.d.ts.map +1 -0
- package/dist/dagr-explorer.js +13 -0
- package/dist/dagr-explorer.js.map +1 -0
- package/dist/errors.d.ts +55 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +57 -0
- package/dist/errors.js.map +1 -0
- package/dist/explorer-details.d.ts +44 -0
- package/dist/explorer-details.d.ts.map +1 -0
- package/dist/explorer-details.js +76 -0
- package/dist/explorer-details.js.map +1 -0
- package/dist/explorer-search.d.ts +31 -0
- package/dist/explorer-search.d.ts.map +1 -0
- package/dist/explorer-search.js +86 -0
- package/dist/explorer-search.js.map +1 -0
- package/dist/explorer-toolbar.d.ts +17 -0
- package/dist/explorer-toolbar.d.ts.map +1 -0
- package/dist/explorer-toolbar.js +44 -0
- package/dist/explorer-toolbar.js.map +1 -0
- package/dist/explorer-trace-toggle.d.ts +16 -0
- package/dist/explorer-trace-toggle.d.ts.map +1 -0
- package/dist/explorer-trace-toggle.js +8 -0
- package/dist/explorer-trace-toggle.js.map +1 -0
- package/dist/explorer-viewport.d.ts +69 -0
- package/dist/explorer-viewport.d.ts.map +1 -0
- package/dist/explorer-viewport.js +69 -0
- package/dist/explorer-viewport.js.map +1 -0
- package/dist/explorer-views.d.ts +24 -0
- package/dist/explorer-views.d.ts.map +1 -0
- package/dist/explorer-views.js +16 -0
- package/dist/explorer-views.js.map +1 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/isomorphic-layout-effect.d.ts +7 -0
- package/dist/isomorphic-layout-effect.d.ts.map +1 -0
- package/dist/isomorphic-layout-effect.js +7 -0
- package/dist/isomorphic-layout-effect.js.map +1 -0
- package/dist/labels.d.ts +67 -0
- package/dist/labels.d.ts.map +1 -0
- package/dist/labels.js +50 -0
- package/dist/labels.js.map +1 -0
- package/dist/layout.d.ts +82 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +244 -0
- package/dist/layout.js.map +1 -0
- package/dist/navigation.d.ts +20 -0
- package/dist/navigation.d.ts.map +1 -0
- package/dist/navigation.js +44 -0
- package/dist/navigation.js.map +1 -0
- package/dist/root.d.ts +89 -0
- package/dist/root.d.ts.map +1 -0
- package/dist/root.js +401 -0
- package/dist/root.js.map +1 -0
- package/dist/search.d.ts +19 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +31 -0
- package/dist/search.js.map +1 -0
- package/dist/size.d.ts +12 -0
- package/dist/size.d.ts.map +1 -0
- package/dist/size.js +20 -0
- package/dist/size.js.map +1 -0
- package/dist/svg-base.d.ts +33 -0
- package/dist/svg-base.d.ts.map +1 -0
- package/dist/svg-base.js +89 -0
- package/dist/svg-base.js.map +1 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/use-explorer-camera.d.ts +56 -0
- package/dist/use-explorer-camera.d.ts.map +1 -0
- package/dist/use-explorer-camera.js +641 -0
- package/dist/use-explorer-camera.js.map +1 -0
- package/dist/use-explorer.d.ts +23 -0
- package/dist/use-explorer.d.ts.map +1 -0
- package/dist/use-explorer.js +26 -0
- package/dist/use-explorer.js.map +1 -0
- package/dist/validate.d.ts +20 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +99 -0
- package/dist/validate.js.map +1 -0
- package/dist/viewport-surface.d.ts +90 -0
- package/dist/viewport-surface.d.ts.map +1 -0
- package/dist/viewport-surface.js +485 -0
- package/dist/viewport-surface.js.map +1 -0
- package/dist/visible-set.d.ts +65 -0
- package/dist/visible-set.d.ts.map +1 -0
- package/dist/visible-set.js +168 -0
- package/dist/visible-set.js.map +1 -0
- package/package.json +62 -0
- package/src/base.ts +67 -0
- package/src/camera.ts +206 -0
- package/src/context.ts +168 -0
- package/src/dagr-explorer.tsx +85 -0
- package/src/errors.ts +85 -0
- package/src/explorer-details.tsx +156 -0
- package/src/explorer-search.tsx +140 -0
- package/src/explorer-toolbar.tsx +77 -0
- package/src/explorer-trace-toggle.tsx +34 -0
- package/src/explorer-viewport.tsx +181 -0
- package/src/explorer-views.tsx +61 -0
- package/src/index.ts +76 -0
- package/src/isomorphic-layout-effect.ts +7 -0
- package/src/labels.ts +109 -0
- package/src/layout.ts +298 -0
- package/src/navigation.ts +51 -0
- package/src/root.tsx +544 -0
- package/src/search.ts +36 -0
- package/src/size.ts +23 -0
- package/src/svg-base.tsx +173 -0
- package/src/types.ts +69 -0
- package/src/use-explorer-camera.ts +675 -0
- package/src/use-explorer.ts +32 -0
- package/src/validate.ts +173 -0
- package/src/viewport-surface.tsx +650 -0
- package/src/visible-set.ts +225 -0
- package/styles.css +253 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `DagrExplorer`: the explorer, preassembled.
|
|
3
|
+
*
|
|
4
|
+
* It is composed only from the public parts, with no private access, so
|
|
5
|
+
* anything it does a host can do by composing the parts itself. Its props are
|
|
6
|
+
* the root's, plus the parts' slots and options under the names below.
|
|
7
|
+
*
|
|
8
|
+
* `N` and `E` are inferred from `views` (or `nodes` and `edges`), and every
|
|
9
|
+
* slot is typed from them. That is the check hand-composed parts cannot
|
|
10
|
+
* give, because they talk through a context.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ReactElement } from 'react';
|
|
14
|
+
import type { ExplorerBase } from './base.js';
|
|
15
|
+
import { ExplorerDetails } from './explorer-details.js';
|
|
16
|
+
import type { ExplorerDetailsProps } from './explorer-details.js';
|
|
17
|
+
import { ExplorerSearch } from './explorer-search.js';
|
|
18
|
+
import { ExplorerToolbar } from './explorer-toolbar.js';
|
|
19
|
+
import { ExplorerTraceToggle } from './explorer-trace-toggle.js';
|
|
20
|
+
import { ExplorerViewport } from './explorer-viewport.js';
|
|
21
|
+
import type { ExplorerViewportProps } from './explorer-viewport.js';
|
|
22
|
+
import { ExplorerViews } from './explorer-views.js';
|
|
23
|
+
import type { ExplorerViewsProps } from './explorer-views.js';
|
|
24
|
+
import { ExplorerRoot } from './root.js';
|
|
25
|
+
import type { ExplorerRootProps } from './root.js';
|
|
26
|
+
import type { ExplorerEdge, ExplorerNode } from './types.js';
|
|
27
|
+
import type { ExplorerTiers } from './visible-set.js';
|
|
28
|
+
|
|
29
|
+
interface DagrExplorerParts<N extends ExplorerNode, E extends ExplorerEdge> {
|
|
30
|
+
/** `ExplorerViewport`'s `renderNode`. */
|
|
31
|
+
readonly renderNode?: ExplorerViewportProps<N>['renderNode'];
|
|
32
|
+
/** `ExplorerViewport`'s `nodeAriaLabel`. */
|
|
33
|
+
readonly nodeAriaLabel?: ExplorerViewportProps<N>['nodeAriaLabel'];
|
|
34
|
+
readonly tiers?: ExplorerTiers | undefined;
|
|
35
|
+
readonly maxOverlayNodes?: number | undefined;
|
|
36
|
+
/** Experimental. See `ExplorerBase`. */
|
|
37
|
+
readonly base?: ExplorerBase | undefined;
|
|
38
|
+
/** `ExplorerDetails`'s children. */
|
|
39
|
+
readonly renderDetails?: ExplorerDetailsProps<N, E>['children'];
|
|
40
|
+
/** `ExplorerDetails`'s `renderConnection`. */
|
|
41
|
+
readonly renderConnection?: ExplorerDetailsProps<N, E>['renderConnection'];
|
|
42
|
+
/** `ExplorerViews`'s children. */
|
|
43
|
+
readonly renderViews?: ExplorerViewsProps<N, E>['children'];
|
|
44
|
+
/** Preassembled: compose the parts to add your own. */
|
|
45
|
+
readonly children?: never;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type DagrExplorerProps<
|
|
49
|
+
N extends ExplorerNode = ExplorerNode,
|
|
50
|
+
E extends ExplorerEdge = ExplorerEdge,
|
|
51
|
+
> = ExplorerRootProps<N, E> & DagrExplorerParts<N, E>;
|
|
52
|
+
|
|
53
|
+
export function DagrExplorer<N extends ExplorerNode = ExplorerNode, E extends ExplorerEdge = ExplorerEdge>(
|
|
54
|
+
props: DagrExplorerProps<N, E>,
|
|
55
|
+
): ReactElement {
|
|
56
|
+
const {
|
|
57
|
+
renderNode,
|
|
58
|
+
nodeAriaLabel,
|
|
59
|
+
tiers,
|
|
60
|
+
maxOverlayNodes,
|
|
61
|
+
base,
|
|
62
|
+
renderDetails,
|
|
63
|
+
renderConnection,
|
|
64
|
+
renderViews,
|
|
65
|
+
...root
|
|
66
|
+
} = props;
|
|
67
|
+
return (
|
|
68
|
+
<ExplorerRoot<N, E> {...(root as ExplorerRootProps<N, E>)}>
|
|
69
|
+
<ExplorerViews<N, E>>{renderViews}</ExplorerViews>
|
|
70
|
+
<ExplorerSearch />
|
|
71
|
+
<ExplorerTraceToggle />
|
|
72
|
+
{/* The drawer in the viewport's stage overlays the graph, not the whole explorer or the hint. */}
|
|
73
|
+
<ExplorerViewport<N, E>
|
|
74
|
+
renderNode={renderNode}
|
|
75
|
+
nodeAriaLabel={nodeAriaLabel}
|
|
76
|
+
tiers={tiers}
|
|
77
|
+
maxOverlayNodes={maxOverlayNodes}
|
|
78
|
+
base={base}
|
|
79
|
+
>
|
|
80
|
+
<ExplorerDetails<N, E> renderConnection={renderConnection}>{renderDetails}</ExplorerDetails>
|
|
81
|
+
</ExplorerViewport>
|
|
82
|
+
<ExplorerToolbar />
|
|
83
|
+
</ExplorerRoot>
|
|
84
|
+
);
|
|
85
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors thrown by `@prnt/dagr-explorer` for data it cannot draw honestly.
|
|
3
|
+
*
|
|
4
|
+
* One class with a `code`, because a caller switches on the code and never on
|
|
5
|
+
* the class: the failures are all "this view is malformed", and they differ
|
|
6
|
+
* only in how. Codes are UPPER_SNAKE and the type is named for the package, as
|
|
7
|
+
* in every sibling.
|
|
8
|
+
*
|
|
9
|
+
* **The offender is a field, not only a phrase in the message.** A host that
|
|
10
|
+
* wants to highlight the bad node, or list the errors of one view, reads `id`
|
|
11
|
+
* and `viewId` and never parses prose. What `id` names depends on the code:
|
|
12
|
+
*
|
|
13
|
+
* - `INVALID_ID`: the empty id itself, so `id` is `''`. `viewId` is the view, or `undefined` when the empty id is a view's.
|
|
14
|
+
* - `DUPLICATE_VIEW_ID`: the view. `viewId` is `undefined`.
|
|
15
|
+
* - `INVALID_LAYOUT_OPTION`: the view. `viewId` is `undefined`.
|
|
16
|
+
* - `DUPLICATE_NODE_ID`, `INVALID_NODE_SIZE`: the node.
|
|
17
|
+
* - `DUPLICATE_EDGE_ID`, `MISSING_EDGE_ENDPOINT`: the edge.
|
|
18
|
+
* - `DUPLICATE_GROUP_ID`, `EMPTY_GROUP`, `MISSING_GROUP_MEMBER`,
|
|
19
|
+
* `GROUP_ENCLOSES_NON_MEMBER`: the group.
|
|
20
|
+
*
|
|
21
|
+
* The message still names every id involved, including the second one a
|
|
22
|
+
* missing endpoint or an enclosed node adds, so a log line is enough to fix it.
|
|
23
|
+
*
|
|
24
|
+
* The prototype is restored explicitly, as every sibling package does, so
|
|
25
|
+
* `instanceof` stays correct when the output is downlevelled below ES2022.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The `code` of every data error this package throws. */
|
|
29
|
+
export type DagrExplorerErrorCode =
|
|
30
|
+
| 'INVALID_ID'
|
|
31
|
+
| 'DUPLICATE_VIEW_ID'
|
|
32
|
+
| 'DUPLICATE_NODE_ID'
|
|
33
|
+
| 'DUPLICATE_EDGE_ID'
|
|
34
|
+
| 'DUPLICATE_GROUP_ID'
|
|
35
|
+
| 'INVALID_NODE_SIZE'
|
|
36
|
+
| 'INVALID_LAYOUT_OPTION'
|
|
37
|
+
| 'MISSING_EDGE_ENDPOINT'
|
|
38
|
+
| 'MISSING_GROUP_MEMBER'
|
|
39
|
+
| 'EMPTY_GROUP'
|
|
40
|
+
| 'GROUP_ENCLOSES_NON_MEMBER';
|
|
41
|
+
|
|
42
|
+
export class ExplorerDataError extends Error {
|
|
43
|
+
readonly code: DagrExplorerErrorCode;
|
|
44
|
+
|
|
45
|
+
/** The view, node, edge or group the error is about. See the table above. */
|
|
46
|
+
readonly id: string;
|
|
47
|
+
|
|
48
|
+
/** The view it was found in. `undefined` when the error is about a view. */
|
|
49
|
+
readonly viewId: string | undefined;
|
|
50
|
+
|
|
51
|
+
constructor(code: DagrExplorerErrorCode, message: string, id: string, viewId?: string) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = 'ExplorerDataError';
|
|
54
|
+
this.code = code;
|
|
55
|
+
this.id = id;
|
|
56
|
+
this.viewId = viewId;
|
|
57
|
+
Object.setPrototypeOf(this, ExplorerDataError.prototype);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The `code` of every composition error: a part used where it cannot work.
|
|
63
|
+
*
|
|
64
|
+
* - `OUTSIDE_EXPLORER`: a part, or `useExplorer()`, rendered outside an
|
|
65
|
+
* `ExplorerRoot`. The message names the part.
|
|
66
|
+
* - `SECOND_VIEWPORT`: a second `ExplorerViewport` in one root.
|
|
67
|
+
*/
|
|
68
|
+
export type ExplorerContextErrorCode = 'OUTSIDE_EXPLORER' | 'SECOND_VIEWPORT';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A mistake in how the parts are put together, as opposed to the data. A
|
|
72
|
+
* separate class from {@link ExplorerDataError}, because a host that shows
|
|
73
|
+
* data errors to its users should never show these: they are the host's own
|
|
74
|
+
* bug, found the first time the page renders.
|
|
75
|
+
*/
|
|
76
|
+
export class ExplorerContextError extends Error {
|
|
77
|
+
readonly code: ExplorerContextErrorCode;
|
|
78
|
+
|
|
79
|
+
constructor(code: ExplorerContextErrorCode, message: string) {
|
|
80
|
+
super(message);
|
|
81
|
+
this.name = 'ExplorerContextError';
|
|
82
|
+
this.code = code;
|
|
83
|
+
Object.setPrototypeOf(this, ExplorerContextError.prototype);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ExplorerDetails`: the drawer that shows the inspected node.
|
|
3
|
+
*
|
|
4
|
+
* It renders nothing while closed. Open, it is an overlay at every width,
|
|
5
|
+
* positioned against the nearest positioned ancestor (the viewport's stage
|
|
6
|
+
* when it is `ExplorerViewport`'s child, else the root or a host's own
|
|
7
|
+
* wrapper), so opening it never resizes the graph.
|
|
8
|
+
*
|
|
9
|
+
* The body is a keyboard scroll stop and scrolls to the top when the
|
|
10
|
+
* inspected node changes. Its content is the slot's, or by default the
|
|
11
|
+
* node's label and its connections as buttons, each inspecting the node at
|
|
12
|
+
* the other end. Those buttons keep the drawer's original opener, so
|
|
13
|
+
* `Escape` after following three edges still returns where the reader began.
|
|
14
|
+
*
|
|
15
|
+
* Closing restores focus, and the root does it, because every way the drawer
|
|
16
|
+
* closes (this part's `Escape` and close button, the api, the selected node
|
|
17
|
+
* leaving the data) ends in the same place. See `ExplorerRoot`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useEffect, useId, useMemo, useRef } from 'react';
|
|
21
|
+
import type { CSSProperties, KeyboardEvent, ReactElement, ReactNode } from 'react';
|
|
22
|
+
import { useExplorerContext } from './context.js';
|
|
23
|
+
import type { ExplorerEdge, ExplorerNode } from './types.js';
|
|
24
|
+
|
|
25
|
+
/** An edge touching the inspected node, and the node at its other end. */
|
|
26
|
+
export interface ExplorerConnection<N extends ExplorerNode = ExplorerNode, E extends ExplorerEdge = ExplorerEdge> {
|
|
27
|
+
readonly edge: E;
|
|
28
|
+
/** The other end. For a self loop, the node itself. */
|
|
29
|
+
readonly node: N;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What the drawer's slot receives. */
|
|
33
|
+
export interface ExplorerDetailsContext<N extends ExplorerNode = ExplorerNode, E extends ExplorerEdge = ExplorerEdge> {
|
|
34
|
+
readonly node: N;
|
|
35
|
+
/** Every edge touching `node`, in data order. */
|
|
36
|
+
readonly connections: readonly ExplorerConnection<N, E>[];
|
|
37
|
+
/** Inspects another node, keeping the drawer's opener. */
|
|
38
|
+
readonly inspect: (id: string) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ExplorerDetailsProps<N extends ExplorerNode = ExplorerNode, E extends ExplorerEdge = ExplorerEdge> {
|
|
42
|
+
/** The drawer's content. Default: the label and the connection list. */
|
|
43
|
+
readonly children?: ((context: ExplorerDetailsContext<N, E>) => ReactNode) | undefined;
|
|
44
|
+
/** One connection's content in the default list. Default: the other node's label. */
|
|
45
|
+
readonly renderConnection?: ((edge: E, otherNode: N) => ReactNode) | undefined;
|
|
46
|
+
readonly className?: string | undefined;
|
|
47
|
+
readonly style?: CSSProperties | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function ExplorerDetails<N extends ExplorerNode = ExplorerNode, E extends ExplorerEdge = ExplorerEdge>(
|
|
51
|
+
props: ExplorerDetailsProps<N, E>,
|
|
52
|
+
): ReactElement | null {
|
|
53
|
+
const { children, renderConnection, className, style } = props;
|
|
54
|
+
const { state } = useExplorerContext('ExplorerDetails');
|
|
55
|
+
const { labels, activeView, detailsOpen } = state;
|
|
56
|
+
// The part's type parameters are a claim about the root's data. See context.ts.
|
|
57
|
+
const node = detailsOpen ? (state.selectedNode as N | null) : null;
|
|
58
|
+
const nodeId = node?.id ?? null;
|
|
59
|
+
const titleId = useId();
|
|
60
|
+
const connectionsId = useId();
|
|
61
|
+
const bodyRef = useRef<HTMLDivElement>(null);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (nodeId !== null && bodyRef.current !== null) bodyRef.current.scrollTop = 0;
|
|
65
|
+
}, [nodeId]);
|
|
66
|
+
|
|
67
|
+
const connections = useMemo(() => {
|
|
68
|
+
if (nodeId === null || activeView === null) return [];
|
|
69
|
+
const byId = new Map(activeView.nodes.map((each) => [each.id, each as N]));
|
|
70
|
+
const list: ExplorerConnection<N, E>[] = [];
|
|
71
|
+
for (const edge of activeView.edges as readonly E[]) {
|
|
72
|
+
if (edge.source !== nodeId && edge.target !== nodeId) continue;
|
|
73
|
+
const other = byId.get(edge.source === nodeId ? edge.target : edge.source);
|
|
74
|
+
if (other !== undefined) list.push({ edge, node: other });
|
|
75
|
+
}
|
|
76
|
+
return list;
|
|
77
|
+
}, [nodeId, activeView]);
|
|
78
|
+
|
|
79
|
+
if (node === null) return null;
|
|
80
|
+
|
|
81
|
+
// No trigger, and the drawer is open: the root keeps the opener it has.
|
|
82
|
+
const inspect = (id: string): void => state.inspect(id);
|
|
83
|
+
|
|
84
|
+
const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {
|
|
85
|
+
if (event.key !== 'Escape') return;
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
state.closeDetails();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<aside
|
|
92
|
+
data-dagr-explorer="details"
|
|
93
|
+
aria-labelledby={titleId}
|
|
94
|
+
className={className}
|
|
95
|
+
onKeyDown={onKeyDown}
|
|
96
|
+
style={{
|
|
97
|
+
position: 'absolute',
|
|
98
|
+
top: 0,
|
|
99
|
+
right: 0,
|
|
100
|
+
bottom: 0,
|
|
101
|
+
width: 'min(360px, 100%)',
|
|
102
|
+
display: 'flex',
|
|
103
|
+
flexDirection: 'column',
|
|
104
|
+
zIndex: 1,
|
|
105
|
+
// The stylesheet's fallback, so the drawer looks the same with or without it.
|
|
106
|
+
background: 'var(--dagr-explorer-bg, #ffffff)',
|
|
107
|
+
...style,
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
<div
|
|
111
|
+
data-dagr-explorer="details-header"
|
|
112
|
+
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flex: 'none' }}
|
|
113
|
+
>
|
|
114
|
+
<span id={titleId}>{labels.drawerTitle}</span>
|
|
115
|
+
<button type="button" data-dagr-explorer="details-close" onClick={() => state.closeDetails()}>
|
|
116
|
+
{labels.close}
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
<div
|
|
120
|
+
ref={bodyRef}
|
|
121
|
+
data-dagr-explorer="details-body"
|
|
122
|
+
tabIndex={0}
|
|
123
|
+
// A keyboard scroll stop is announced, so it needs a name: the drawer's title.
|
|
124
|
+
role="region"
|
|
125
|
+
aria-labelledby={titleId}
|
|
126
|
+
style={{ overflow: 'auto', flex: '1 1 auto', minHeight: 0 }}
|
|
127
|
+
>
|
|
128
|
+
{children !== undefined ? (
|
|
129
|
+
children({ node, connections, inspect })
|
|
130
|
+
) : (
|
|
131
|
+
<>
|
|
132
|
+
<p data-dagr-explorer="details-label">{node.label}</p>
|
|
133
|
+
{connections.length === 0 ? null : (
|
|
134
|
+
<>
|
|
135
|
+
<p id={connectionsId}>{labels.connections}</p>
|
|
136
|
+
<ul
|
|
137
|
+
data-dagr-explorer="connections"
|
|
138
|
+
aria-labelledby={connectionsId}
|
|
139
|
+
style={{ listStyle: 'none', margin: 0, padding: 0 }}
|
|
140
|
+
>
|
|
141
|
+
{connections.map(({ edge, node: other }) => (
|
|
142
|
+
<li key={edge.id}>
|
|
143
|
+
<button type="button" data-node-id={other.id} onClick={() => inspect(other.id)}>
|
|
144
|
+
{renderConnection === undefined ? other.label : renderConnection(edge, other)}
|
|
145
|
+
</button>
|
|
146
|
+
</li>
|
|
147
|
+
))}
|
|
148
|
+
</ul>
|
|
149
|
+
</>
|
|
150
|
+
)}
|
|
151
|
+
</>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
</aside>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ExplorerSearch`: a labeled field, a live match count, and the matches as
|
|
3
|
+
* a list of buttons.
|
|
4
|
+
*
|
|
5
|
+
* Search is the complete way to every node. A virtualized node has no
|
|
6
|
+
* element, so the graph alone cannot reach it by keyboard or screen reader,
|
|
7
|
+
* and search can: every match is counted, and `Enter` reaches the first.
|
|
8
|
+
*
|
|
9
|
+
* **The list is capped,** at `maxResults` (default 50), in data order, with
|
|
10
|
+
* a plain line from `labels.moreMatches` for the rest, so a short query on a
|
|
11
|
+
* large graph does not mount thousands of buttons. A longer query narrows
|
|
12
|
+
* it. The list is memoized on the matches, which the root keeps by identity
|
|
13
|
+
* while the same nodes match.
|
|
14
|
+
*
|
|
15
|
+
* `Enter` inspects the first match and flies the camera to it, and choosing
|
|
16
|
+
* a result does the same for that result. The list stays mounted while a
|
|
17
|
+
* node is inspected, so its scroll position survives.
|
|
18
|
+
*
|
|
19
|
+
* `Escape` in the field closes the drawer if it is open, and only then
|
|
20
|
+
* clears the query, so a reader who opened a result from here can close it
|
|
21
|
+
* without losing the list.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { useEffect, useId, useMemo, useRef } from 'react';
|
|
25
|
+
import type { CSSProperties, KeyboardEvent, ReactElement } from 'react';
|
|
26
|
+
import { useExplorerContext } from './context.js';
|
|
27
|
+
|
|
28
|
+
/** How many results the list shows before it says how many more there are. */
|
|
29
|
+
const DEFAULT_MAX_RESULTS = 50;
|
|
30
|
+
|
|
31
|
+
export interface ExplorerSearchProps {
|
|
32
|
+
/** The most results listed, in data order. Default 50. The count and `Enter` cover every match. */
|
|
33
|
+
readonly maxResults?: number | undefined;
|
|
34
|
+
readonly className?: string | undefined;
|
|
35
|
+
readonly style?: CSSProperties | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function ExplorerSearch(props: ExplorerSearchProps): ReactElement {
|
|
39
|
+
const { state, internals } = useExplorerContext('ExplorerSearch');
|
|
40
|
+
const { labels, query, matches, detailsOpen, selectedId, inspect, focusNode } = state;
|
|
41
|
+
const { maxResults } = props;
|
|
42
|
+
const cap =
|
|
43
|
+
maxResults !== undefined && Number.isFinite(maxResults) && maxResults >= 0
|
|
44
|
+
? Math.floor(maxResults)
|
|
45
|
+
: DEFAULT_MAX_RESULTS;
|
|
46
|
+
const inputId = useId();
|
|
47
|
+
const countId = useId();
|
|
48
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
49
|
+
|
|
50
|
+
// The field is where focus goes when the drawer closes and its opener is gone.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const field = inputRef.current;
|
|
53
|
+
internals.searchInputRef.current = field;
|
|
54
|
+
return () => {
|
|
55
|
+
if (internals.searchInputRef.current === field) internals.searchInputRef.current = null;
|
|
56
|
+
};
|
|
57
|
+
}, [internals]);
|
|
58
|
+
|
|
59
|
+
const blank = query.trim() === '';
|
|
60
|
+
|
|
61
|
+
// `inspect` and `focusNode` are the root's stable methods.
|
|
62
|
+
const pick = (id: string, trigger: HTMLElement): void => {
|
|
63
|
+
inspect(id, trigger);
|
|
64
|
+
focusNode(id);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
|
68
|
+
if (event.key === 'Enter') {
|
|
69
|
+
const first = matches[0];
|
|
70
|
+
if (first === undefined) return;
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
pick(first.id, event.currentTarget);
|
|
73
|
+
} else if (event.key === 'Escape') {
|
|
74
|
+
if (detailsOpen) state.closeDetails();
|
|
75
|
+
else if (query !== '') state.setQuery('');
|
|
76
|
+
else return;
|
|
77
|
+
// A search field clears itself on Escape in some browsers, which would
|
|
78
|
+
// skip the drawer's turn.
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const { searchResults, moreMatches } = labels;
|
|
84
|
+
const list = useMemo(() => {
|
|
85
|
+
if (matches.length === 0) return null;
|
|
86
|
+
const shown = cap < matches.length ? matches.slice(0, cap) : matches;
|
|
87
|
+
const more = matches.length - shown.length;
|
|
88
|
+
return (
|
|
89
|
+
<>
|
|
90
|
+
{shown.length === 0 ? null : (
|
|
91
|
+
<ul
|
|
92
|
+
data-dagr-explorer="search-results"
|
|
93
|
+
aria-label={searchResults}
|
|
94
|
+
style={{ listStyle: 'none', margin: 0, padding: 0, overflow: 'auto' }}
|
|
95
|
+
>
|
|
96
|
+
{shown.map((node) => (
|
|
97
|
+
<li key={node.id}>
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
data-node-id={node.id}
|
|
101
|
+
data-selected={node.id === selectedId ? 'true' : undefined}
|
|
102
|
+
aria-current={node.id === selectedId ? 'true' : undefined}
|
|
103
|
+
onClick={(event) => {
|
|
104
|
+
inspect(node.id, event.currentTarget);
|
|
105
|
+
focusNode(node.id);
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
{node.label}
|
|
109
|
+
</button>
|
|
110
|
+
</li>
|
|
111
|
+
))}
|
|
112
|
+
</ul>
|
|
113
|
+
)}
|
|
114
|
+
{more === 0 ? null : <p data-dagr-explorer="search-more">{moreMatches(more)}</p>}
|
|
115
|
+
</>
|
|
116
|
+
);
|
|
117
|
+
}, [matches, cap, selectedId, searchResults, moreMatches, inspect, focusNode]);
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div data-dagr-explorer="search" role="search" className={props.className} style={props.style}>
|
|
121
|
+
<label htmlFor={inputId}>{labels.search}</label>
|
|
122
|
+
<input
|
|
123
|
+
ref={inputRef}
|
|
124
|
+
id={inputId}
|
|
125
|
+
type="search"
|
|
126
|
+
value={query}
|
|
127
|
+
placeholder={labels.searchPlaceholder}
|
|
128
|
+
autoComplete="off"
|
|
129
|
+
spellCheck={false}
|
|
130
|
+
aria-describedby={countId}
|
|
131
|
+
onChange={(event) => state.setQuery(event.currentTarget.value)}
|
|
132
|
+
onKeyDown={onKeyDown}
|
|
133
|
+
/>
|
|
134
|
+
<p id={countId} data-dagr-explorer="search-count" aria-live="polite">
|
|
135
|
+
{blank ? '' : labels.matches(matches.length)}
|
|
136
|
+
</p>
|
|
137
|
+
{list}
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ExplorerToolbar`: zoom out, the zoom readout, zoom in, fit, and zoom to
|
|
3
|
+
* the selected node.
|
|
4
|
+
*
|
|
5
|
+
* **The readout follows the camera without a render.** It subscribes to the
|
|
6
|
+
* root's camera source and writes its own text when the whole percent
|
|
7
|
+
* changes, so a zoom that runs for twenty frames re-renders nothing. React
|
|
8
|
+
* renders the element with no children, so it never overwrites that text.
|
|
9
|
+
* It is not a live region: announcing every frame of a zoom is noise.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useEffect, useRef } from 'react';
|
|
13
|
+
import type { CSSProperties, ReactElement } from 'react';
|
|
14
|
+
import type { ExplorerCamera } from './camera.js';
|
|
15
|
+
import { useExplorerContext } from './context.js';
|
|
16
|
+
|
|
17
|
+
/** The same steps as the `+` and `-` keys. */
|
|
18
|
+
const ZOOM_IN = 1.25;
|
|
19
|
+
const ZOOM_OUT = 0.8;
|
|
20
|
+
|
|
21
|
+
export interface ExplorerToolbarProps {
|
|
22
|
+
readonly className?: string | undefined;
|
|
23
|
+
readonly style?: CSSProperties | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ExplorerToolbar(props: ExplorerToolbarProps): ReactElement {
|
|
27
|
+
const { state } = useExplorerContext('ExplorerToolbar');
|
|
28
|
+
const { labels, selectedNode, camera } = state;
|
|
29
|
+
const readoutRef = useRef<HTMLSpanElement>(null);
|
|
30
|
+
const { zoomLevel } = labels;
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const readout = readoutRef.current;
|
|
34
|
+
if (readout === null) return undefined;
|
|
35
|
+
let shown: number | null = null;
|
|
36
|
+
const write = (now: ExplorerCamera | null): void => {
|
|
37
|
+
if (now === null) return;
|
|
38
|
+
const percent = Math.round(now.scale * 100);
|
|
39
|
+
if (percent === shown) return;
|
|
40
|
+
shown = percent;
|
|
41
|
+
readout.textContent = zoomLevel(percent);
|
|
42
|
+
};
|
|
43
|
+
write(camera.get());
|
|
44
|
+
return camera.subscribe(write);
|
|
45
|
+
}, [camera, zoomLevel]);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
data-dagr-explorer="toolbar"
|
|
50
|
+
role="group"
|
|
51
|
+
aria-label={labels.zoomControls}
|
|
52
|
+
className={props.className}
|
|
53
|
+
style={props.style}
|
|
54
|
+
>
|
|
55
|
+
<button type="button" data-action="zoom-out" onClick={() => state.zoomBy(ZOOM_OUT)}>
|
|
56
|
+
{labels.zoomOut}
|
|
57
|
+
</button>
|
|
58
|
+
<span ref={readoutRef} data-dagr-explorer="zoom-level" />
|
|
59
|
+
<button type="button" data-action="zoom-in" onClick={() => state.zoomBy(ZOOM_IN)}>
|
|
60
|
+
{labels.zoomIn}
|
|
61
|
+
</button>
|
|
62
|
+
<button type="button" data-action="fit" onClick={() => state.fit()}>
|
|
63
|
+
{labels.fit}
|
|
64
|
+
</button>
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
data-action="zoom-to-selected"
|
|
68
|
+
disabled={selectedNode === null}
|
|
69
|
+
onClick={() => {
|
|
70
|
+
if (selectedNode !== null) state.focusNode(selectedNode.id);
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
{selectedNode === null ? labels.zoomToSelected : labels.zoomTo(selectedNode.label)}
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ExplorerTraceToggle`: trace on and off. With trace on and a node
|
|
3
|
+
* selected, every node not adjacent to it is dimmed.
|
|
4
|
+
*
|
|
5
|
+
* The text names what a press does (`labels.traceOn` while off), so the
|
|
6
|
+
* button is not also `aria-pressed`: a toggle whose name flips and that
|
|
7
|
+
* reports a pressed state reads its state twice. `data-active` is the
|
|
8
|
+
* styling hook.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { CSSProperties, ReactElement } from 'react';
|
|
12
|
+
import { useExplorerContext } from './context.js';
|
|
13
|
+
|
|
14
|
+
export interface ExplorerTraceToggleProps {
|
|
15
|
+
readonly className?: string | undefined;
|
|
16
|
+
readonly style?: CSSProperties | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ExplorerTraceToggle(props: ExplorerTraceToggleProps): ReactElement {
|
|
20
|
+
const { state } = useExplorerContext('ExplorerTraceToggle');
|
|
21
|
+
const { trace, labels } = state;
|
|
22
|
+
return (
|
|
23
|
+
<button
|
|
24
|
+
type="button"
|
|
25
|
+
data-dagr-explorer="trace"
|
|
26
|
+
data-active={trace ? 'true' : undefined}
|
|
27
|
+
className={props.className}
|
|
28
|
+
style={props.style}
|
|
29
|
+
onClick={() => state.setTrace(!trace)}
|
|
30
|
+
>
|
|
31
|
+
{trace ? labels.traceOff : labels.traceOn}
|
|
32
|
+
</button>
|
|
33
|
+
);
|
|
34
|
+
}
|