@rokkit/graph 1.8.0 → 1.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -0
- package/dist/Graph.svelte +108 -134
- package/dist/Graph.svelte.d.ts +1 -1
- package/dist/GraphLegend.svelte +81 -10
- package/dist/GraphLegend.svelte.d.ts +15 -0
- package/dist/GraphState.svelte.d.ts +161 -52
- package/dist/GraphState.svelte.js +380 -304
- package/dist/actions/canvas.d.ts +18 -0
- package/dist/actions/canvas.js +59 -0
- package/dist/actions/choices.d.ts +12 -0
- package/dist/actions/choices.js +23 -0
- package/dist/actions/interactions.d.ts +20 -0
- package/dist/actions/interactions.js +57 -0
- package/dist/actions/listen.d.ts +2 -0
- package/dist/actions/listen.js +10 -0
- package/dist/canvas/frame.d.ts +41 -0
- package/dist/canvas/frame.js +34 -0
- package/dist/controls/BundleControl.svelte +11 -10
- package/dist/controls/BundleControl.svelte.d.ts +0 -7
- package/dist/controls/DensityControl.svelte +17 -9
- package/dist/controls/DensityControl.svelte.d.ts +0 -8
- package/dist/controls/DepthControl.svelte +11 -4
- package/dist/controls/DepthControl.svelte.d.ts +0 -9
- package/dist/controls/DrillBar.svelte +63 -0
- package/dist/controls/DrillBar.svelte.d.ts +9 -0
- package/dist/controls/EdgeStyleControl.svelte +10 -10
- package/dist/controls/EdgeStyleControl.svelte.d.ts +0 -6
- package/dist/controls/HiddenControl.svelte +27 -0
- package/dist/controls/HiddenControl.svelte.d.ts +7 -0
- package/dist/controls/MeasureControl.svelte +39 -0
- package/dist/controls/MeasureControl.svelte.d.ts +12 -0
- package/dist/controls/ToggleControl.svelte +33 -0
- package/dist/controls/ToggleControl.svelte.d.ts +13 -0
- package/dist/controls/ViolationsControl.svelte +27 -0
- package/dist/controls/ViolationsControl.svelte.d.ts +7 -0
- package/dist/controls/ZoomControl.svelte +21 -18
- package/dist/controls/zoom.d.ts +3 -0
- package/dist/controls/zoom.js +6 -0
- package/dist/diagrams/ArcDiagram.svelte +116 -0
- package/dist/diagrams/ArcDiagram.svelte.d.ts +31 -0
- package/dist/diagrams/CallTree.svelte +17 -5
- package/dist/diagrams/CallTree.svelte.d.ts +5 -1
- package/dist/diagrams/DependencyDiagram.svelte +18 -6
- package/dist/diagrams/DependencyDiagram.svelte.d.ts +5 -1
- package/dist/diagrams/DependencyMatrix.svelte +11 -32
- package/dist/diagrams/ErDiagram.svelte +18 -6
- package/dist/diagrams/ErDiagram.svelte.d.ts +5 -1
- package/dist/diagrams/LayersDiagram.svelte +124 -0
- package/dist/diagrams/LayersDiagram.svelte.d.ts +30 -0
- package/dist/diagrams/Neighborhood.svelte +26 -6
- package/dist/diagrams/Neighborhood.svelte.d.ts +11 -1
- package/dist/diagrams/PolymetricTree.svelte +116 -0
- package/dist/diagrams/PolymetricTree.svelte.d.ts +28 -0
- package/dist/diagrams/StructureDiagram.svelte +38 -8
- package/dist/diagrams/StructureDiagram.svelte.d.ts +9 -1
- package/dist/diagrams/Sunburst.svelte +44 -8
- package/dist/diagrams/Sunburst.svelte.d.ts +12 -2
- package/dist/diagrams/Treemap.svelte +44 -8
- package/dist/diagrams/Treemap.svelte.d.ts +12 -2
- package/dist/index.d.ts +10 -3
- package/dist/index.js +9 -2
- package/dist/layout/arcs.d.ts +6 -0
- package/dist/layout/arcs.js +141 -0
- package/dist/layout/cards.js +9 -2
- package/dist/layout/edges.d.ts +13 -1
- package/dist/layout/edges.js +26 -2
- package/dist/layout/extent.d.ts +17 -0
- package/dist/layout/extent.js +27 -0
- package/dist/layout/flow.js +3 -1
- package/dist/layout/index.d.ts +4 -1
- package/dist/layout/index.js +17 -2
- package/dist/layout/layers.d.ts +6 -0
- package/dist/layout/layers.js +180 -0
- package/dist/layout/matrix.d.ts +12 -0
- package/dist/layout/matrix.js +20 -0
- package/dist/layout/neighborhood.js +16 -7
- package/dist/layout/options.d.ts +1 -1
- package/dist/layout/options.js +16 -3
- package/dist/layout/percentile.d.ts +8 -0
- package/dist/layout/percentile.js +13 -0
- package/dist/layout/polymetric.d.ts +3 -0
- package/dist/layout/polymetric.js +221 -0
- package/dist/layout/radial.js +3 -1
- package/dist/layout/structure.js +7 -1
- package/dist/layout/sunburst.js +11 -4
- package/dist/layout/types.d.ts +79 -0
- package/dist/layout/world.js +11 -4
- package/dist/messages.d.ts +14 -0
- package/dist/messages.js +17 -0
- package/dist/model/condense.d.ts +8 -0
- package/dist/model/condense.js +114 -0
- package/dist/model/entities.d.ts +12 -0
- package/dist/model/entities.js +12 -0
- package/dist/model/normalize.d.ts +2 -6
- package/dist/model/normalize.js +64 -2
- package/dist/model/relationships.d.ts +30 -0
- package/dist/model/relationships.js +51 -0
- package/dist/model/share.d.ts +9 -0
- package/dist/model/share.js +43 -0
- package/dist/model/tree.d.ts +7 -1
- package/dist/model/tree.js +11 -4
- package/dist/preset.d.ts +18 -0
- package/dist/preset.js +34 -3
- package/dist/schema/EntitiesView.svelte +8 -6
- package/dist/schema/EntityView.svelte +13 -10
- package/dist/state/GraphConfig.svelte.d.ts +124 -0
- package/dist/state/GraphConfig.svelte.js +253 -0
- package/dist/state/GraphDrill.svelte.d.ts +52 -0
- package/dist/state/GraphDrill.svelte.js +115 -0
- package/dist/state/GraphGroups.svelte.d.ts +35 -0
- package/dist/state/GraphGroups.svelte.js +73 -0
- package/dist/state/GraphSelection.svelte.d.ts +53 -0
- package/dist/state/GraphSelection.svelte.js +94 -0
- package/dist/state/intents.d.ts +33 -0
- package/dist/state/intents.js +30 -0
- package/dist/state/text.d.ts +28 -0
- package/dist/state/text.js +56 -0
- package/dist/types.d.ts +72 -1
- package/package.json +5 -4
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Cluster } from '../layout/types.js';
|
|
2
|
+
import type { GraphModel } from '../types.js';
|
|
3
|
+
import type { GraphConfig } from './GraphConfig.svelte.js';
|
|
4
|
+
export type Breadcrumb = {
|
|
5
|
+
/** The drill path this crumb returns to. `[]` is the root. */
|
|
6
|
+
path: string[];
|
|
7
|
+
/** The segment's name; null for the root, which the view names (`All`, a title…). */
|
|
8
|
+
label: string | null;
|
|
9
|
+
/** Where the reader is now — the last crumb, which is shown but not a way back. */
|
|
10
|
+
current: boolean;
|
|
11
|
+
};
|
|
12
|
+
type Sources = {
|
|
13
|
+
config: GraphConfig;
|
|
14
|
+
model: () => GraphModel;
|
|
15
|
+
/** The layout's registry key, or 'custom'. */
|
|
16
|
+
layoutName: () => string;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Drilling: moving `focusPath` — which containment subtree is the whole canvas — and telling the
|
|
20
|
+
* host, who may have to FETCH that level (#165). docs/design/24-world-view.md, Decision 3.
|
|
21
|
+
*
|
|
22
|
+
* Drilling changes scope; it never selects. Only a layout that declares `focusPath` drills — a
|
|
23
|
+
* custom LayoutFn might ignore it, and a no-op gesture is worse than none.
|
|
24
|
+
*
|
|
25
|
+
* A handler may return a promise: the state is `pending` until it settles, only the LATEST
|
|
26
|
+
* drill's settlement counts (an older load finishing late changes nothing), and a rejection —
|
|
27
|
+
* or a handler that throws — restores the previous path and exposes the error, because an
|
|
28
|
+
* empty canvas would read as a broken level rather than a failed load.
|
|
29
|
+
*/
|
|
30
|
+
export declare class GraphDrill {
|
|
31
|
+
#private;
|
|
32
|
+
constructor(sources: Sources);
|
|
33
|
+
get path(): string[];
|
|
34
|
+
get breadcrumbs(): Breadcrumb[];
|
|
35
|
+
get pending(): boolean;
|
|
36
|
+
/** Why the last drill failed, until the next drill. */
|
|
37
|
+
get error(): unknown;
|
|
38
|
+
/** Whether the active layout drills at all. */
|
|
39
|
+
get enabled(): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Whether a box can be opened: in a drilling layout, with an address (a pathless box has
|
|
42
|
+
* none), not already the canvas, and with something below it — in the data, or behind the
|
|
43
|
+
* host's loader.
|
|
44
|
+
*/
|
|
45
|
+
canDrill(box: Cluster): boolean;
|
|
46
|
+
drillInto(box: Cluster): boolean;
|
|
47
|
+
/** Climb `levels` toward the root. False (and silent) when already there. */
|
|
48
|
+
drillOut(levels?: number): boolean;
|
|
49
|
+
/** Return to an ancestor — what a breadcrumb does. False for anything that is not one. */
|
|
50
|
+
drillTo(path: string[]): boolean;
|
|
51
|
+
}
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { LAYOUT_OPTIONS } from '../layout/options.js';
|
|
2
|
+
const isPrefix = (prefix, path) => prefix.length <= path.length && prefix.every((segment, i) => path[i] === segment);
|
|
3
|
+
const isThenable = (value) => value !== null && typeof value === 'object' && typeof value.then === 'function';
|
|
4
|
+
/**
|
|
5
|
+
* Drilling: moving `focusPath` — which containment subtree is the whole canvas — and telling the
|
|
6
|
+
* host, who may have to FETCH that level (#165). docs/design/24-world-view.md, Decision 3.
|
|
7
|
+
*
|
|
8
|
+
* Drilling changes scope; it never selects. Only a layout that declares `focusPath` drills — a
|
|
9
|
+
* custom LayoutFn might ignore it, and a no-op gesture is worse than none.
|
|
10
|
+
*
|
|
11
|
+
* A handler may return a promise: the state is `pending` until it settles, only the LATEST
|
|
12
|
+
* drill's settlement counts (an older load finishing late changes nothing), and a rejection —
|
|
13
|
+
* or a handler that throws — restores the previous path and exposes the error, because an
|
|
14
|
+
* empty canvas would read as a broken level rather than a failed load.
|
|
15
|
+
*/
|
|
16
|
+
export class GraphDrill {
|
|
17
|
+
#sources;
|
|
18
|
+
#pending = $state(false);
|
|
19
|
+
#error = $state(null);
|
|
20
|
+
/** Bumped by every drill; a settlement for an older token is ignored. */
|
|
21
|
+
#token = 0;
|
|
22
|
+
constructor(sources) {
|
|
23
|
+
this.#sources = sources;
|
|
24
|
+
}
|
|
25
|
+
get path() {
|
|
26
|
+
return this.#sources.config.focusPath;
|
|
27
|
+
}
|
|
28
|
+
get breadcrumbs() {
|
|
29
|
+
const path = this.path;
|
|
30
|
+
return [
|
|
31
|
+
{ path: [], label: null, current: path.length === 0 },
|
|
32
|
+
...path.map((segment, i) => ({
|
|
33
|
+
path: path.slice(0, i + 1),
|
|
34
|
+
label: segment,
|
|
35
|
+
current: i === path.length - 1
|
|
36
|
+
}))
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
get pending() {
|
|
40
|
+
return this.#pending;
|
|
41
|
+
}
|
|
42
|
+
/** Why the last drill failed, until the next drill. */
|
|
43
|
+
get error() {
|
|
44
|
+
return this.#error;
|
|
45
|
+
}
|
|
46
|
+
/** Whether the active layout drills at all. */
|
|
47
|
+
get enabled() {
|
|
48
|
+
return LAYOUT_OPTIONS[this.#sources.layoutName()]?.includes('focusPath') === true;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Whether a box can be opened: in a drilling layout, with an address (a pathless box has
|
|
52
|
+
* none), not already the canvas, and with something below it — in the data, or behind the
|
|
53
|
+
* host's loader.
|
|
54
|
+
*/
|
|
55
|
+
canDrill(box) {
|
|
56
|
+
const path = box.path ?? [];
|
|
57
|
+
if (!this.enabled || path.length === 0 || isPrefix(path, this.path))
|
|
58
|
+
return false;
|
|
59
|
+
return !box.leaf || this.#sources.config.ondrill !== undefined;
|
|
60
|
+
}
|
|
61
|
+
drillInto(box) {
|
|
62
|
+
if (!this.canDrill(box))
|
|
63
|
+
return false;
|
|
64
|
+
const path = box.path;
|
|
65
|
+
const node = box.declared ? (this.#sources.model().byId.get(box.declared) ?? null) : null;
|
|
66
|
+
this.#move(path, () => this.#sources.config.ondrill?.(path, node));
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
/** Climb `levels` toward the root. False (and silent) when already there. */
|
|
70
|
+
drillOut(levels = 1) {
|
|
71
|
+
if (this.path.length === 0)
|
|
72
|
+
return false;
|
|
73
|
+
return this.drillTo(this.path.slice(0, Math.max(0, this.path.length - levels)));
|
|
74
|
+
}
|
|
75
|
+
/** Return to an ancestor — what a breadcrumb does. False for anything that is not one. */
|
|
76
|
+
drillTo(path) {
|
|
77
|
+
if (path.length >= this.path.length || !isPrefix(path, this.path))
|
|
78
|
+
return false;
|
|
79
|
+
this.#move(path, () => this.#sources.config.ondrillup?.(path));
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
#move(path, fire) {
|
|
83
|
+
const previous = this.path;
|
|
84
|
+
const token = ++this.#token;
|
|
85
|
+
this.#pending = false;
|
|
86
|
+
this.#error = null;
|
|
87
|
+
this.#setPath(path);
|
|
88
|
+
const fail = (error) => {
|
|
89
|
+
if (token !== this.#token)
|
|
90
|
+
return;
|
|
91
|
+
this.#pending = false;
|
|
92
|
+
this.#error = error;
|
|
93
|
+
this.#setPath(previous);
|
|
94
|
+
};
|
|
95
|
+
let result;
|
|
96
|
+
try {
|
|
97
|
+
result = fire();
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
fail(error);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (!isThenable(result))
|
|
104
|
+
return;
|
|
105
|
+
this.#pending = true;
|
|
106
|
+
result.then(() => {
|
|
107
|
+
if (token === this.#token)
|
|
108
|
+
this.#pending = false;
|
|
109
|
+
}, fail);
|
|
110
|
+
}
|
|
111
|
+
#setPath(path) {
|
|
112
|
+
this.#sources.config.setFocusPath(path);
|
|
113
|
+
this.#sources.config.onfocuspath?.(path);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { GraphModel } from '../types.js';
|
|
2
|
+
import type { GraphConfig } from './GraphConfig.svelte.js';
|
|
3
|
+
/** What a drill bar can offer for the selection: open its group, or fold its group back. */
|
|
4
|
+
export type GroupAction = {
|
|
5
|
+
kind: 'expand' | 'collapse';
|
|
6
|
+
group: string;
|
|
7
|
+
label: string;
|
|
8
|
+
};
|
|
9
|
+
type Sources = {
|
|
10
|
+
/** The CANONICAL model — before condensation, so every group and member is visible here. */
|
|
11
|
+
model: () => GraphModel;
|
|
12
|
+
config: GraphConfig;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Which groups are collapsed (#166). A group starts as its node says (`collapsed`, default
|
|
16
|
+
* true — a node with members is a group to fold); the reader toggles from there. The state
|
|
17
|
+
* condenses the model through `collapsed` before any layout sees it.
|
|
18
|
+
*/
|
|
19
|
+
export declare class GraphGroups {
|
|
20
|
+
#private;
|
|
21
|
+
constructor(sources: Sources);
|
|
22
|
+
/** The collapsed group ids — what condensation reads. */
|
|
23
|
+
get collapsed(): Set<string>;
|
|
24
|
+
isGroup(id: string): boolean;
|
|
25
|
+
isCollapsed(id: string): boolean;
|
|
26
|
+
/** How many members the group names — loaded or not, since the host may send them later. */
|
|
27
|
+
memberCount(id: string): number;
|
|
28
|
+
/** The EXPANDED group a node belongs to, or null — what a member collapses back into. */
|
|
29
|
+
groupOf(id: string): string | null;
|
|
30
|
+
/** Flip a group. False for a node that is not one. Tells the host either way it went. */
|
|
31
|
+
toggle(id: string): boolean;
|
|
32
|
+
/** The selection's group action, or null when it is neither a collapsed group nor a member. */
|
|
33
|
+
actionFor(selected: string | null): GroupAction | null;
|
|
34
|
+
}
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
2
|
+
import { groupsOf } from '../model/condense.js';
|
|
3
|
+
/**
|
|
4
|
+
* Which groups are collapsed (#166). A group starts as its node says (`collapsed`, default
|
|
5
|
+
* true — a node with members is a group to fold); the reader toggles from there. The state
|
|
6
|
+
* condenses the model through `collapsed` before any layout sees it.
|
|
7
|
+
*/
|
|
8
|
+
export class GraphGroups {
|
|
9
|
+
#sources;
|
|
10
|
+
/** Groups the reader flipped from their starting state. */
|
|
11
|
+
#toggled = new SvelteSet();
|
|
12
|
+
#groups;
|
|
13
|
+
#collapsed;
|
|
14
|
+
constructor(sources) {
|
|
15
|
+
this.#sources = sources;
|
|
16
|
+
this.#groups = $derived(groupsOf(sources.model()));
|
|
17
|
+
// A SvelteSet, as GraphSelection's `related` is: the house shape for a derived id set.
|
|
18
|
+
this.#collapsed = $derived(new SvelteSet([...this.#groups.keys()].filter((id) => this.#startsCollapsed(id) !== this.#toggled.has(id))));
|
|
19
|
+
}
|
|
20
|
+
#node(id) {
|
|
21
|
+
return this.#sources.model().byId.get(id);
|
|
22
|
+
}
|
|
23
|
+
#startsCollapsed(id) {
|
|
24
|
+
return this.#node(id)?.collapsed ?? true;
|
|
25
|
+
}
|
|
26
|
+
/** The collapsed group ids — what condensation reads. */
|
|
27
|
+
get collapsed() {
|
|
28
|
+
return this.#collapsed;
|
|
29
|
+
}
|
|
30
|
+
isGroup(id) {
|
|
31
|
+
return this.#groups.has(id);
|
|
32
|
+
}
|
|
33
|
+
isCollapsed(id) {
|
|
34
|
+
return this.#collapsed.has(id);
|
|
35
|
+
}
|
|
36
|
+
/** How many members the group names — loaded or not, since the host may send them later. */
|
|
37
|
+
memberCount(id) {
|
|
38
|
+
return this.#node(id)?.members?.length ?? 0;
|
|
39
|
+
}
|
|
40
|
+
/** The EXPANDED group a node belongs to, or null — what a member collapses back into. */
|
|
41
|
+
groupOf(id) {
|
|
42
|
+
for (const [group, members] of this.#groups) {
|
|
43
|
+
if (!this.#collapsed.has(group) && members.includes(id))
|
|
44
|
+
return group;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/** Flip a group. False for a node that is not one. Tells the host either way it went. */
|
|
49
|
+
toggle(id) {
|
|
50
|
+
const node = this.#node(id);
|
|
51
|
+
if (!node || !this.isGroup(id))
|
|
52
|
+
return false;
|
|
53
|
+
const expanding = this.isCollapsed(id);
|
|
54
|
+
if (this.#toggled.has(id))
|
|
55
|
+
this.#toggled.delete(id);
|
|
56
|
+
else
|
|
57
|
+
this.#toggled.add(id);
|
|
58
|
+
const { onexpand, oncollapse } = this.#sources.config;
|
|
59
|
+
(expanding ? onexpand : oncollapse)?.(id, node);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
/** The selection's group action, or null when it is neither a collapsed group nor a member. */
|
|
63
|
+
actionFor(selected) {
|
|
64
|
+
if (!selected)
|
|
65
|
+
return null;
|
|
66
|
+
const label = (group) => this.#node(group)?.label ?? group;
|
|
67
|
+
if (this.isGroup(selected) && this.isCollapsed(selected)) {
|
|
68
|
+
return { kind: 'expand', group: selected, label: label(selected) };
|
|
69
|
+
}
|
|
70
|
+
const group = this.groupOf(selected);
|
|
71
|
+
return group ? { kind: 'collapse', group, label: label(group) } : null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
2
|
+
import type { Relationship } from '../model/relationships.js';
|
|
3
|
+
import type { RoutedEdge } from '../layout/types.js';
|
|
4
|
+
import type { GraphModel, GraphNode } from '../types.js';
|
|
5
|
+
type Sources = {
|
|
6
|
+
/** The canonical model. */
|
|
7
|
+
model: () => GraphModel;
|
|
8
|
+
/** The structural edges the active layout routed — geometry for relationships. */
|
|
9
|
+
routedEdges: () => RoutedEdge[];
|
|
10
|
+
/** The consumer's selection callback, read at call time (it is config, and can change). */
|
|
11
|
+
onselect: () => ((id: string | null) => void) | undefined;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* What the reader has picked and opened: the selected node, what it touches, and which cards
|
|
15
|
+
* are expanded — plus the transitions that change them.
|
|
16
|
+
*/
|
|
17
|
+
export declare class GraphSelection {
|
|
18
|
+
#private;
|
|
19
|
+
constructor(sources: Sources);
|
|
20
|
+
get value(): string | null;
|
|
21
|
+
get related(): SvelteSet<string>;
|
|
22
|
+
get entity(): GraphNode | null;
|
|
23
|
+
get relationships(): Relationship[];
|
|
24
|
+
/** Take a value the CALLER supplied — no callback, it already knows. */
|
|
25
|
+
adopt(id: string | null): void;
|
|
26
|
+
select(id: string): void;
|
|
27
|
+
/**
|
|
28
|
+
* Drop the selection, and SAY SO.
|
|
29
|
+
*
|
|
30
|
+
* Found by dbd consuming the package: it owns `selected` in its route and branches on it
|
|
31
|
+
* to show the entity panel, so a silent clear left that panel open over nothing. A
|
|
32
|
+
* controlled consumer cannot observe an internal value — the callback is the only
|
|
33
|
+
* channel, which is why it carries `null` rather than being skipped.
|
|
34
|
+
*
|
|
35
|
+
* Guarded on an actual change, or a background click on an already-empty canvas
|
|
36
|
+
* round-trips through the consumer's setter on every stray click.
|
|
37
|
+
*/
|
|
38
|
+
clear(): void;
|
|
39
|
+
/**
|
|
40
|
+
* Show one node's rows in full, whatever the density says.
|
|
41
|
+
*
|
|
42
|
+
* Per NODE rather than a global density change: a reader who clicks "+3 more" on one
|
|
43
|
+
* card is asking about that card, and expanding all of them answers a question they did
|
|
44
|
+
* not ask — on a large diagram it also relayouts everything under them.
|
|
45
|
+
*/
|
|
46
|
+
toggleExpanded(id: string): void;
|
|
47
|
+
isExpanded(id: string): boolean;
|
|
48
|
+
/** The set the layouts read to size expanded cards. */
|
|
49
|
+
get expanded(): SvelteSet<string>;
|
|
50
|
+
nodeState(id: string): 'selected' | 'related' | 'dim' | null;
|
|
51
|
+
edgeState(edge: RoutedEdge): 'highlight' | 'dim' | null;
|
|
52
|
+
}
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
2
|
+
import { relationshipsOf } from '../model/relationships.js';
|
|
3
|
+
/**
|
|
4
|
+
* What the reader has picked and opened: the selected node, what it touches, and which cards
|
|
5
|
+
* are expanded — plus the transitions that change them.
|
|
6
|
+
*/
|
|
7
|
+
export class GraphSelection {
|
|
8
|
+
#sources;
|
|
9
|
+
#value = $state(null);
|
|
10
|
+
#expanded = new SvelteSet();
|
|
11
|
+
#related;
|
|
12
|
+
#entity;
|
|
13
|
+
/** From the canonical model, not the layout's filtered edges — see `relationshipsOf`. */
|
|
14
|
+
#relationships;
|
|
15
|
+
constructor(sources) {
|
|
16
|
+
this.#sources = sources;
|
|
17
|
+
// Derived in the constructor, after `#sources` exists — a field initializer would read
|
|
18
|
+
// it before it is assigned.
|
|
19
|
+
this.#related = $derived(this.#value
|
|
20
|
+
? new SvelteSet(sources.model().neighbors.get(this.#value) ?? [])
|
|
21
|
+
: new SvelteSet());
|
|
22
|
+
this.#entity = $derived(this.#value ? (sources.model().byId.get(this.#value) ?? null) : null);
|
|
23
|
+
this.#relationships = $derived(relationshipsOf(sources.model(), this.#value, sources.routedEdges()));
|
|
24
|
+
}
|
|
25
|
+
get value() {
|
|
26
|
+
return this.#value;
|
|
27
|
+
}
|
|
28
|
+
get related() {
|
|
29
|
+
return this.#related;
|
|
30
|
+
}
|
|
31
|
+
get entity() {
|
|
32
|
+
return this.#entity;
|
|
33
|
+
}
|
|
34
|
+
get relationships() {
|
|
35
|
+
return this.#relationships;
|
|
36
|
+
}
|
|
37
|
+
/** Take a value the CALLER supplied — no callback, it already knows. */
|
|
38
|
+
adopt(id) {
|
|
39
|
+
this.#value = id;
|
|
40
|
+
}
|
|
41
|
+
select(id) {
|
|
42
|
+
this.#value = id;
|
|
43
|
+
this.#sources.onselect()?.(id);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Drop the selection, and SAY SO.
|
|
47
|
+
*
|
|
48
|
+
* Found by dbd consuming the package: it owns `selected` in its route and branches on it
|
|
49
|
+
* to show the entity panel, so a silent clear left that panel open over nothing. A
|
|
50
|
+
* controlled consumer cannot observe an internal value — the callback is the only
|
|
51
|
+
* channel, which is why it carries `null` rather than being skipped.
|
|
52
|
+
*
|
|
53
|
+
* Guarded on an actual change, or a background click on an already-empty canvas
|
|
54
|
+
* round-trips through the consumer's setter on every stray click.
|
|
55
|
+
*/
|
|
56
|
+
clear() {
|
|
57
|
+
if (this.#value === null)
|
|
58
|
+
return;
|
|
59
|
+
this.#value = null;
|
|
60
|
+
this.#sources.onselect()?.(null);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Show one node's rows in full, whatever the density says.
|
|
64
|
+
*
|
|
65
|
+
* Per NODE rather than a global density change: a reader who clicks "+3 more" on one
|
|
66
|
+
* card is asking about that card, and expanding all of them answers a question they did
|
|
67
|
+
* not ask — on a large diagram it also relayouts everything under them.
|
|
68
|
+
*/
|
|
69
|
+
toggleExpanded(id) {
|
|
70
|
+
if (this.#expanded.has(id))
|
|
71
|
+
this.#expanded.delete(id);
|
|
72
|
+
else
|
|
73
|
+
this.#expanded.add(id);
|
|
74
|
+
}
|
|
75
|
+
isExpanded(id) {
|
|
76
|
+
return this.#expanded.has(id);
|
|
77
|
+
}
|
|
78
|
+
/** The set the layouts read to size expanded cards. */
|
|
79
|
+
get expanded() {
|
|
80
|
+
return this.#expanded;
|
|
81
|
+
}
|
|
82
|
+
nodeState(id) {
|
|
83
|
+
if (!this.#value)
|
|
84
|
+
return null;
|
|
85
|
+
if (id === this.#value)
|
|
86
|
+
return 'selected';
|
|
87
|
+
return this.#related.has(id) ? 'related' : 'dim';
|
|
88
|
+
}
|
|
89
|
+
edgeState(edge) {
|
|
90
|
+
if (!this.#value)
|
|
91
|
+
return null;
|
|
92
|
+
return edge.fromKey === this.#value || edge.toKey === this.#value ? 'highlight' : 'dim';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a press or a double-click on the graph does, as data.
|
|
3
|
+
*
|
|
4
|
+
* The state writes an intent onto each element (`data-graph-press`, `data-graph-open`, with
|
|
5
|
+
* `data-graph-key`); the `interactions` action reads it back and calls `GraphState.act`, which
|
|
6
|
+
* looks it up here. So the meaning of a click lives in one table — not in a handler on every
|
|
7
|
+
* element — and each entry is testable on a bare state.
|
|
8
|
+
*/
|
|
9
|
+
import type { Breadcrumb } from './GraphDrill.svelte.js';
|
|
10
|
+
import type { GroupAction } from './GraphGroups.svelte.js';
|
|
11
|
+
import type { Cluster } from '../layout/types.js';
|
|
12
|
+
export type Intent = 'select' | 'toggle' | 'drill' | 'group' | 'expand' | 'crumb' | 'open-selected' | 'group-selected' | 'clear';
|
|
13
|
+
/** What an intent may call — the slice of `GraphState` the table needs. */
|
|
14
|
+
export type Actor = {
|
|
15
|
+
select(id: string): void;
|
|
16
|
+
clear(): void;
|
|
17
|
+
readonly value: string | null;
|
|
18
|
+
drillInto(cluster: Cluster): unknown;
|
|
19
|
+
drillTo(path: string[]): unknown;
|
|
20
|
+
toggleGroup(id: string): void;
|
|
21
|
+
toggleExpanded(id: string): void;
|
|
22
|
+
isGroup(id: string): boolean;
|
|
23
|
+
groupOf(id: string): string | null;
|
|
24
|
+
boxFor(key: string): Cluster | undefined;
|
|
25
|
+
readonly breadcrumbs: Breadcrumb[];
|
|
26
|
+
readonly drillTarget: Cluster | null;
|
|
27
|
+
readonly groupAction: GroupAction | null;
|
|
28
|
+
};
|
|
29
|
+
type Perform = (graph: Actor, key: string) => void;
|
|
30
|
+
export declare const INTENTS: Record<Intent, Perform>;
|
|
31
|
+
/** Whether `value` names an intent — the action reads it from the DOM, where anything goes. */
|
|
32
|
+
export declare const isIntent: (value: string | null | undefined) => value is Intent;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** A group toggles itself; a member of an open group folds that group back. */
|
|
2
|
+
const groupFor = (graph, key) => (graph.isGroup(key) ? key : (graph.groupOf(key) ?? key));
|
|
3
|
+
export const INTENTS = {
|
|
4
|
+
select: (graph, key) => graph.select(key),
|
|
5
|
+
/** Select, or clear when it is already the selection — a matrix row's press. */
|
|
6
|
+
toggle: (graph, key) => (graph.value === key ? graph.clear() : graph.select(key)),
|
|
7
|
+
drill: (graph, key) => {
|
|
8
|
+
const cluster = graph.boxFor(key);
|
|
9
|
+
if (cluster)
|
|
10
|
+
graph.drillInto(cluster);
|
|
11
|
+
},
|
|
12
|
+
group: (graph, key) => graph.toggleGroup(groupFor(graph, key)),
|
|
13
|
+
expand: (graph, key) => graph.toggleExpanded(key),
|
|
14
|
+
crumb: (graph, key) => {
|
|
15
|
+
const crumb = graph.breadcrumbs[Number(key)];
|
|
16
|
+
if (crumb)
|
|
17
|
+
graph.drillTo(crumb.path);
|
|
18
|
+
},
|
|
19
|
+
'open-selected': (graph) => {
|
|
20
|
+
if (graph.drillTarget)
|
|
21
|
+
graph.drillInto(graph.drillTarget);
|
|
22
|
+
},
|
|
23
|
+
'group-selected': (graph) => {
|
|
24
|
+
if (graph.groupAction)
|
|
25
|
+
graph.toggleGroup(graph.groupAction.group);
|
|
26
|
+
},
|
|
27
|
+
clear: (graph) => graph.clear()
|
|
28
|
+
};
|
|
29
|
+
/** Whether `value` names an intent — the action reads it from the DOM, where anything goes. */
|
|
30
|
+
export const isIntent = (value) => value !== null && value !== undefined && Object.hasOwn(INTENTS, value);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Card, ChannelScale, Density, LayoutResult, PolymetricChannel } from '../layout/types.js';
|
|
2
|
+
import type { GraphEdge } from '../types.js';
|
|
3
|
+
/** The default accessible name: what the diagram holds. */
|
|
4
|
+
export declare function diagramLabel(nodes: number, edges: number): string;
|
|
5
|
+
/**
|
|
6
|
+
* The "more rows" control on a card: `show less` when expanded, `+ N more` when rows are
|
|
7
|
+
* hidden, and `no keys · N rows` when a key filter matched nothing (see `GraphState.moreLabel`).
|
|
8
|
+
*/
|
|
9
|
+
export declare function moreRowsLabel(expanded: boolean, card: Pick<Card, 'more' | 'vis'> | undefined, density: Density): string | null;
|
|
10
|
+
export type ChannelRow = {
|
|
11
|
+
key: PolymetricChannel;
|
|
12
|
+
label: string;
|
|
13
|
+
scale: ChannelScale;
|
|
14
|
+
};
|
|
15
|
+
/** One legend row per polymetric channel present, labelled from the locale (#168). */
|
|
16
|
+
export declare function channelRows(channels: LayoutResult['channels']): ChannelRow[];
|
|
17
|
+
/**
|
|
18
|
+
* What each side of an arc diagram shows (#169): the relations drawn there, from the data, or
|
|
19
|
+
* Declared / Observed from the locale when a side's edges name none.
|
|
20
|
+
*/
|
|
21
|
+
export declare function sideNames(edges: GraphEdge[], above: string | undefined): {
|
|
22
|
+
below: string;
|
|
23
|
+
above: string;
|
|
24
|
+
};
|
|
25
|
+
/** What a failed drill says: the error's own message, in the locale's sentence. */
|
|
26
|
+
export declare function drillErrorText(error: unknown): string | null;
|
|
27
|
+
/** Underscores are the WIRE format (dbd sends `materialized_view`), not something to read. */
|
|
28
|
+
export declare const readable: (value: string) => string;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The words the state produces, from `messages.graph` (see `messages.ts`). Pure functions of
|
|
3
|
+
* their inputs and the active locale, so the canvas has no English of its own and each is
|
|
4
|
+
* testable without a renderer.
|
|
5
|
+
*/
|
|
6
|
+
import { counted, say } from '../messages.js';
|
|
7
|
+
import { sideOf } from '../layout/arcs.js';
|
|
8
|
+
/** The default accessible name: what the diagram holds. */
|
|
9
|
+
export function diagramLabel(nodes, edges) {
|
|
10
|
+
return say('diagram', {
|
|
11
|
+
nodes: counted(nodes, 'nodeOne', 'nodeMany'),
|
|
12
|
+
edges: counted(edges, 'relationshipOne', 'relationshipMany')
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The "more rows" control on a card: `show less` when expanded, `+ N more` when rows are
|
|
17
|
+
* hidden, and `no keys · N rows` when a key filter matched nothing (see `GraphState.moreLabel`).
|
|
18
|
+
*/
|
|
19
|
+
export function moreRowsLabel(expanded, card, density) {
|
|
20
|
+
if (expanded)
|
|
21
|
+
return say('less');
|
|
22
|
+
if (!card || card.more <= 0)
|
|
23
|
+
return null;
|
|
24
|
+
// Only `keys` filters rows by a property — at `names` an empty card is what was asked for.
|
|
25
|
+
if (density !== 'keys' || card.vis.length > 0)
|
|
26
|
+
return say('more', { n: card.more });
|
|
27
|
+
return say('noKeys', { rows: counted(card.more, 'rowOne', 'rowMany') });
|
|
28
|
+
}
|
|
29
|
+
const CHANNEL_LABEL = { width: 'width', height: 'height', color: 'shade' };
|
|
30
|
+
/** One legend row per polymetric channel present, labelled from the locale (#168). */
|
|
31
|
+
export function channelRows(channels) {
|
|
32
|
+
if (!channels)
|
|
33
|
+
return [];
|
|
34
|
+
const keys = channels.color ? ['width', 'height', 'color'] : ['width', 'height'];
|
|
35
|
+
return keys.map((key) => ({ key, label: say(CHANNEL_LABEL[key]), scale: channels[key] }));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* What each side of an arc diagram shows (#169): the relations drawn there, from the data, or
|
|
39
|
+
* Declared / Observed from the locale when a side's edges name none.
|
|
40
|
+
*/
|
|
41
|
+
export function sideNames(edges, above) {
|
|
42
|
+
const named = { below: new Set(), above: new Set() };
|
|
43
|
+
for (const edge of edges)
|
|
44
|
+
if (edge.relation)
|
|
45
|
+
named[sideOf(edge, above)].add(edge.relation);
|
|
46
|
+
const name = (side, fallback) => named[side].size > 0 ? [...named[side]].join(' · ') : say(fallback);
|
|
47
|
+
return { below: name('below', 'sideBelow'), above: name('above', 'sideAbove') };
|
|
48
|
+
}
|
|
49
|
+
/** What a failed drill says: the error's own message, in the locale's sentence. */
|
|
50
|
+
export function drillErrorText(error) {
|
|
51
|
+
if (error === null || error === undefined)
|
|
52
|
+
return null;
|
|
53
|
+
return say('drillError', { message: error instanceof Error ? error.message : String(error) });
|
|
54
|
+
}
|
|
55
|
+
/** Underscores are the WIRE format (dbd sends `materialized_view`), not something to read. */
|
|
56
|
+
export const readable = (value) => value.replace(/_/g, ' ');
|