yarramate 1.40.0 → 1.41.1

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.
@@ -2,3 +2,4 @@ export { projectGraphForCanvas, type CanvasGraph, type CanvasNode, type CanvasEd
2
2
  export { foldTree, foldGraph, nestingTree, liftedEdgeId, NESTING_KIND_IDS, type FoldInput, type FoldNode, type FoldEdge, type FoldMembership, type FoldTree, type LiftedEdge, type NestingConflict, type SlotWiring, } from '../fold-tree.js';
3
3
  export { edgeLabelText, type EdgeLabelData } from '../edge-label.js';
4
4
  export { DEFAULT_LAYOUT, LAYOUT_MODES, type LayoutMode } from '../layout-mode.js';
5
+ export { canvasSceneInput, drawnCanvasEdges, resolveCanvasScene, type CanvasScene, type CanvasSceneEdge, type CanvasSceneFold, type CanvasSceneInput, type CanvasSceneNode, } from '../canvas-scene.js';
@@ -11,3 +11,9 @@ export { foldTree, foldGraph, nestingTree, liftedEdgeId, NESTING_KIND_IDS, } fro
11
11
  // cytoscape too. `edge-label.ts` and `layout-mode.ts` reach no package.
12
12
  export { edgeLabelText } from '../edge-label.js';
13
13
  export { DEFAULT_LAYOUT, LAYOUT_MODES } from '../layout-mode.js';
14
+ // What the canvas puts on screen for a view, as plain data (#577): visibility,
15
+ // containment as drawn, fold chips, which edges draw and what a lifted edge
16
+ // counts. The canvas runs these two functions itself, so a host drawing the
17
+ // same picture elsewhere reads the same decisions. No positions: those are
18
+ // ELK's and the saved layout's. `canvas-scene.ts` reaches no package.
19
+ export { canvasSceneInput, drawnCanvasEdges, resolveCanvasScene, } from '../canvas-scene.js';
@@ -0,0 +1,116 @@
1
+ /**
2
+ * What the canvas draws, as plain data (#577).
3
+ *
4
+ * Two steps, both pure, and the canvas itself runs both:
5
+ *
6
+ * 1. {@link canvasSceneInput} builds the scene's elements from the model:
7
+ * which box holds what, which folded box stands for what, which edges are
8
+ * drawn at all, and the edges lifted onto a folded box. `graphToElements`
9
+ * turns exactly this into cytoscape elements.
10
+ * 2. {@link resolveCanvasScene} decides, for one view and one quick-filter
11
+ * text, what is on screen: the nodes, the boxes pulled in to hold them, the
12
+ * containment that holds while both ends are visible, what a folded box's
13
+ * chip counts, which edges draw and what a lifted edge's count says.
14
+ * `applyFilter` applies exactly this to the live canvas.
15
+ *
16
+ * So a host that draws the canvas's picture somewhere else, a server-side
17
+ * figure renderer, reads the same decisions rather than restating them. A
18
+ * restated copy was measured wrong twice in its edge labels alone (#576, #587).
19
+ *
20
+ * Positions are deliberately NOT here. Every rebuild, view switch and fold runs
21
+ * ELK over what is visible, and a saved layout is pinned over the result
22
+ * afterwards, so a subject the saved layout does not name is wherever ELK put
23
+ * it; a box's bounds are cytoscape's, derived from its members. Neither can be
24
+ * restated without the layout engine, and a host drawing an unsaved view
25
+ * places it by its own rules.
26
+ *
27
+ * Imports nothing with a runtime of its own, so it ships on the
28
+ * runtime-neutral `yarramate/adapter/visual-graph` subpath.
29
+ */
30
+ import type { CanvasEdge, CanvasGraph } from './graph-projection.js';
31
+ import { type FoldMembership, type FoldTree } from './fold-tree.js';
32
+ import type { NestingKind } from './nesting.js';
33
+ /** One node of the scene, carrying what {@link resolveCanvasScene} reads. */
34
+ export interface CanvasSceneNode {
35
+ readonly id: string;
36
+ /** Matched by the quick filter, with `id` and `kindLabel`. */
37
+ readonly name?: string;
38
+ readonly kindLabel?: string;
39
+ /** The box the model nests this in, whether or not it is drawn nested. */
40
+ readonly parent?: string;
41
+ /** Drawn folded: everything under it is hidden and its edges lifted to it. */
42
+ readonly folded: boolean;
43
+ /** Everything nested under it, at any depth, over the whole model. */
44
+ readonly insideIds: readonly string[];
45
+ }
46
+ /** One edge of the scene: a drawn relationship, or one lifted onto a fold. */
47
+ export interface CanvasSceneEdge {
48
+ readonly id: string;
49
+ readonly from: string;
50
+ readonly to: string;
51
+ /**
52
+ * Present only on a lifted edge (`lift:` ids): the relationships it stands
53
+ * for. Such an edge draws while the view selected any of them.
54
+ */
55
+ readonly relationshipIds?: readonly string[];
56
+ /** A lifted edge's kind, as its label names it. */
57
+ readonly liftedKind?: string;
58
+ }
59
+ export interface CanvasSceneInput {
60
+ readonly nodes: readonly CanvasSceneNode[];
61
+ /**
62
+ * The drawn relationships (nesting-consumed and, unless shown,
63
+ * responsibility edges already left out), then the lifted edges.
64
+ */
65
+ readonly edges: readonly CanvasSceneEdge[];
66
+ /** For the caller's own warnings: nesting conflicts and cycles. */
67
+ readonly tree: FoldTree;
68
+ }
69
+ export interface CanvasSceneFold {
70
+ /** Which instances draw folded. Empty or absent draws everything. */
71
+ readonly folded?: ReadonlySet<string>;
72
+ /** From the model frame; without them only view nesting contains anything. */
73
+ readonly memberships?: readonly FoldMembership[];
74
+ /** Whether responsibility edges are drawn (#557, ADR 0159). */
75
+ readonly showResponsibility?: boolean;
76
+ }
77
+ /** The drawn relationships: what nesting did not consume, and RACI only if shown. */
78
+ export declare function drawnCanvasEdges(graph: Pick<CanvasGraph, 'edges'>, tree: Pick<FoldTree, 'consumedEdgeIds'>, showResponsibility: boolean): CanvasEdge[];
79
+ /**
80
+ * The scene's elements for a model, before any view narrows them. What
81
+ * `graphToElements` builds cytoscape elements from.
82
+ */
83
+ export declare function canvasSceneInput(graph: CanvasGraph, nesting: readonly NestingKind[], fold?: CanvasSceneFold): CanvasSceneInput;
84
+ /** What one view, with one quick-filter text, puts on screen. */
85
+ export interface CanvasScene {
86
+ /** The nodes drawn: matched, surviving the filter, pulled in, not folded away. */
87
+ readonly visibleNodeIds: ReadonlySet<string>;
88
+ /** Of those, the boxes drawn only to hold something the view matched. */
89
+ readonly contextNodeIds: ReadonlySet<string>;
90
+ /** Containment as drawn: a node sits in its box only while both are visible. */
91
+ readonly parentOf: ReadonlyMap<string, string>;
92
+ /** For each folded node: how many of what it holds this view selected. */
93
+ readonly insideCount: ReadonlyMap<string, number>;
94
+ /** The edges drawn, lifted ones included. */
95
+ readonly visibleEdgeIds: ReadonlySet<string>;
96
+ /** For each lifted edge: how many of its relationships this view selected. */
97
+ readonly liftedCount: ReadonlyMap<string, number>;
98
+ /**
99
+ * Edges between a box and something nested in it, left undrawn: the nesting
100
+ * already says it (ADR 0147). Whether or not they would otherwise draw.
101
+ */
102
+ readonly impliedEdgeIds: ReadonlySet<string>;
103
+ /**
104
+ * What a saved layout names for this view (#578): the matched subjects and
105
+ * the boxes that hold them, before the filter or a fold hides any. Null
106
+ * when no view narrows the canvas.
107
+ */
108
+ readonly viewNodeIds: ReadonlySet<string> | null;
109
+ }
110
+ /**
111
+ * What the canvas shows for a view. `matchedIds` is the view's match set, or
112
+ * null when no view narrows the canvas; it may name relationships as well as
113
+ * subjects. An edge draws only where the view selected it AND both its ends
114
+ * are drawn (#579, ADR 0164).
115
+ */
116
+ export declare function resolveCanvasScene(input: Pick<CanvasSceneInput, 'nodes' | 'edges'>, matchedIds: readonly string[] | null, quickFilterText: string): CanvasScene;
@@ -0,0 +1,175 @@
1
+ import { foldGraph, foldTree } from './fold-tree.js';
2
+ import { spansNesting } from './nesting-span.js';
3
+ import { subjectMatchesQuickFilter } from './subject-filter.js';
4
+ /** The drawn relationships: what nesting did not consume, and RACI only if shown. */
5
+ export function drawnCanvasEdges(graph, tree, showResponsibility) {
6
+ return graph.edges.filter((edge) => !tree.consumedEdgeIds.has(edge.id) &&
7
+ (showResponsibility || (edge.responsibility ?? null) === null));
8
+ }
9
+ /**
10
+ * The scene's elements for a model, before any view narrows them. What
11
+ * `graphToElements` builds cytoscape elements from.
12
+ */
13
+ export function canvasSceneInput(graph, nesting, fold = {}) {
14
+ // CORE kinds, not the authored ones (#473): the rule that decides whether
15
+ // an assignment may nest reads what a subject IS.
16
+ const tree = foldTree({
17
+ nodes: graph.nodes.map((node) => ({
18
+ id: node.id,
19
+ kind: node.kind,
20
+ coreKind: node.coreKindLabel,
21
+ })),
22
+ edges: graph.edges,
23
+ memberships: fold.memberships ?? [],
24
+ nesting,
25
+ });
26
+ // What each box stands for, over the WHOLE tree rather than what a view
27
+ // shows; `resolveCanvasScene` narrows the chip to the view.
28
+ const insideIds = new Map();
29
+ for (const id of tree.parentOf.keys()) {
30
+ let ancestor = tree.parentOf.get(id);
31
+ const seen = new Set([id]);
32
+ while (ancestor !== undefined && !seen.has(ancestor)) {
33
+ seen.add(ancestor);
34
+ const held = insideIds.get(ancestor);
35
+ if (held === undefined)
36
+ insideIds.set(ancestor, [id]);
37
+ else
38
+ held.push(id);
39
+ ancestor = tree.parentOf.get(ancestor);
40
+ }
41
+ }
42
+ const folded = fold.folded ?? new Set();
43
+ const nodes = graph.nodes.map((node) => {
44
+ const parent = tree.parentOf.get(node.id);
45
+ return {
46
+ id: node.id,
47
+ name: node.name,
48
+ kindLabel: node.kindLabel,
49
+ ...(parent === undefined ? {} : { parent }),
50
+ folded: folded.has(node.id),
51
+ insideIds: insideIds.get(node.id) ?? [],
52
+ };
53
+ });
54
+ const drawn = drawnCanvasEdges(graph, tree, fold.showResponsibility === true);
55
+ // Every relationship with an end inside a shut box, lifted onto the box
56
+ // (#473). The originals stay: the view hides them, so opening the box has
57
+ // nothing to rebuild.
58
+ const lifted = folded.size === 0
59
+ ? []
60
+ : foldGraph({ nodes: graph.nodes, edges: drawn }, tree, folded).edges.flatMap((edge) => 'count' in edge
61
+ ? [
62
+ {
63
+ id: edge.id,
64
+ from: edge.from,
65
+ to: edge.to,
66
+ relationshipIds: edge.relationshipIds,
67
+ liftedKind: edge.kind,
68
+ },
69
+ ]
70
+ : []);
71
+ return {
72
+ nodes,
73
+ edges: [...drawn.map((edge) => ({ id: edge.id, from: edge.from, to: edge.to })), ...lifted],
74
+ tree,
75
+ };
76
+ }
77
+ /**
78
+ * What the canvas shows for a view. `matchedIds` is the view's match set, or
79
+ * null when no view narrows the canvas; it may name relationships as well as
80
+ * subjects. An edge draws only where the view selected it AND both its ends
81
+ * are drawn (#579, ADR 0164).
82
+ */
83
+ export function resolveCanvasScene(input, matchedIds, quickFilterText) {
84
+ const nodeById = new Map(input.nodes.map((node) => [node.id, node]));
85
+ const parentOfModel = (id) => nodeById.get(id)?.parent;
86
+ const filter = quickFilterText.trim().toLowerCase();
87
+ // A match set names relationships too; only its subjects seed the nodes.
88
+ const baseIds = matchedIds === null ? input.nodes.map((node) => node.id) : matchedIds.filter((id) => nodeById.has(id));
89
+ const baseVisible = new Set(baseIds.filter((id) => {
90
+ const node = nodeById.get(id);
91
+ return subjectMatchesQuickFilter(filter, id, node.name, node.kindLabel);
92
+ }));
93
+ // A nested part is drawn in its box, so every visible node's ancestor chain
94
+ // is pulled in.
95
+ const visible = new Set(baseVisible);
96
+ for (const id of baseVisible) {
97
+ const seen = new Set([id]);
98
+ let ancestor = parentOfModel(id);
99
+ while (ancestor !== undefined && !seen.has(ancestor)) {
100
+ seen.add(ancestor);
101
+ visible.add(ancestor);
102
+ ancestor = parentOfModel(ancestor);
103
+ }
104
+ }
105
+ // A FOLDED ancestor hides everything under it, whatever the view said
106
+ // (#473): a reader who shut a box asked not to see inside it.
107
+ for (const id of [...visible]) {
108
+ const seen = new Set([id]);
109
+ let ancestor = parentOfModel(id);
110
+ while (ancestor !== undefined && !seen.has(ancestor)) {
111
+ seen.add(ancestor);
112
+ if (nodeById.get(ancestor)?.folded === true) {
113
+ visible.delete(id);
114
+ break;
115
+ }
116
+ ancestor = parentOfModel(ancestor);
117
+ }
118
+ }
119
+ // Containment is a rendering device: it holds only while both ends are drawn.
120
+ const parentOf = new Map();
121
+ for (const node of input.nodes) {
122
+ if (node.parent !== undefined && visible.has(node.id) && visible.has(node.parent)) {
123
+ parentOf.set(node.id, node.parent);
124
+ }
125
+ }
126
+ // The chip counts what THIS VIEW shows: a member hidden by its own box still
127
+ // counts, one the view never selected does not.
128
+ const insideCount = new Map();
129
+ for (const node of input.nodes) {
130
+ if (!node.folded)
131
+ continue;
132
+ insideCount.set(node.id, node.insideIds.filter((id) => nodeById.has(id) && baseVisible.has(id)).length);
133
+ }
134
+ const selected = matchedIds === null ? null : new Set(matchedIds);
135
+ const liftedCount = new Map();
136
+ for (const edge of input.edges) {
137
+ if (edge.relationshipIds === undefined)
138
+ continue;
139
+ liftedCount.set(edge.id, selected === null
140
+ ? edge.relationshipIds.length
141
+ : edge.relationshipIds.filter((id) => selected.has(id)).length);
142
+ }
143
+ const viewSelected = (edge) => selected === null ||
144
+ (edge.relationshipIds !== undefined
145
+ ? edge.relationshipIds.some((id) => selected.has(id))
146
+ : selected.has(edge.id));
147
+ const impliedEdgeIds = new Set(input.edges.filter((edge) => spansNesting(edge.from, edge.to, parentOf)).map((edge) => edge.id));
148
+ const visibleEdgeIds = new Set(input.edges
149
+ .filter((edge) => visible.has(edge.from) &&
150
+ visible.has(edge.to) &&
151
+ viewSelected(edge) &&
152
+ !impliedEdgeIds.has(edge.id))
153
+ .map((edge) => edge.id));
154
+ let viewNodeIds = null;
155
+ if (matchedIds !== null) {
156
+ viewNodeIds = new Set(baseIds);
157
+ for (const id of baseIds) {
158
+ let ancestor = parentOfModel(id);
159
+ while (ancestor !== undefined && !viewNodeIds.has(ancestor)) {
160
+ viewNodeIds.add(ancestor);
161
+ ancestor = parentOfModel(ancestor);
162
+ }
163
+ }
164
+ }
165
+ return {
166
+ visibleNodeIds: visible,
167
+ contextNodeIds: new Set([...visible].filter((id) => !baseVisible.has(id))),
168
+ parentOf,
169
+ insideCount,
170
+ visibleEdgeIds,
171
+ liftedCount,
172
+ impliedEdgeIds,
173
+ viewNodeIds,
174
+ };
175
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Whether an edge's two ends are nested one inside the other (#439, ADR 0139).
3
+ *
4
+ * Composition maps onto cytoscape's compound `parent`, so a pair that also
5
+ * carries any OTHER relationship produces an edge from a container to its own
6
+ * child. ELK cannot lay that out: measured on a five-concept model, the
7
+ * container and its child render and every unrelated node loses its geometry.
8
+ *
9
+ * Its own module rather than a helper inside `graph-canvas.tsx`, following
10
+ * `subject-filter.ts`: the canvas is type-checked with JSX and a test that
11
+ * imports it is not, so pure rules live where a test can reach them.
12
+ */
13
+ export function spansNesting(from, to, parentOf) {
14
+ const climbs = (start, target) => {
15
+ // `resolveNestingParents` already refuses to nest a composition cycle, so
16
+ // a cycle should be unreachable here. Guarded anyway: the cost of being
17
+ // wrong is a frozen canvas rather than a bad picture.
18
+ const seen = new Set();
19
+ let at = parentOf.get(start);
20
+ while (at !== undefined && !seen.has(at)) {
21
+ if (at === target)
22
+ return true;
23
+ seen.add(at);
24
+ at = parentOf.get(at);
25
+ }
26
+ return false;
27
+ };
28
+ return climbs(from, to) || climbs(to, from);
29
+ }
@@ -10,7 +10,7 @@
10
10
  * the canvas module registers cytoscape-elk. So the predicate lives in this
11
11
  * small pure module and both surfaces import it from here.
12
12
  */
13
- import type { CanvasNode } from "../graph-projection.js";
13
+ import type { CanvasNode } from "./graph-projection.js";
14
14
  /**
15
15
  * The slice of a subject the predicate reads — the render data both the
16
16
  * canvas and the rail already hold.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Typed filter text made comparable: trimmed and lowercased, once, so every
3
+ * surface trims the same way before matching.
4
+ */
5
+ export const normalizeFilterText = (text) => text.trim().toLowerCase();
6
+ /**
7
+ * Whether a subject survives the typed text: case-insensitive, against its
8
+ * id, name, and kind label. `name` and `kindLabel` are `unknown` because the
9
+ * canvas reads them out of cytoscape data, which types nothing.
10
+ */
11
+ export function subjectMatchesQuickFilter(trimmedLowerFilter, id, name, kindLabel) {
12
+ if (trimmedLowerFilter === "")
13
+ return true;
14
+ if (id.toLowerCase().includes(trimmedLowerFilter))
15
+ return true;
16
+ if (typeof name === "string" &&
17
+ name.toLowerCase().includes(trimmedLowerFilter)) {
18
+ return true;
19
+ }
20
+ return (typeof kindLabel === "string" &&
21
+ kindLabel.toLowerCase().includes(trimmedLowerFilter));
22
+ }
23
+ /**
24
+ * How many of these subjects survive the typed text — the number a tree row
25
+ * shows while the filter narrows (#317). With no text every subject
26
+ * survives, so this is also the plain length.
27
+ */
28
+ export const countMatchingSubjects = (subjects, filterText) => {
29
+ const needle = normalizeFilterText(filterText);
30
+ return subjects.filter((subject) => subjectMatchesQuickFilter(needle, subject.id, subject.name, subject.kindLabel)).length;
31
+ };