@diagc/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +709 -0
- package/README.md +27 -0
- package/dist/builder.d.ts +381 -0
- package/dist/builder.js +590 -0
- package/dist/children.d.ts +21 -0
- package/dist/children.js +45 -0
- package/dist/commands.d.ts +219 -0
- package/dist/commands.js +474 -0
- package/dist/compose.d.ts +19 -0
- package/dist/compose.js +246 -0
- package/dist/drawings.d.ts +13 -0
- package/dist/drawings.js +36 -0
- package/dist/eject.d.ts +20 -0
- package/dist/eject.js +260 -0
- package/dist/fishbone.d.ts +66 -0
- package/dist/fishbone.js +95 -0
- package/dist/git.d.ts +65 -0
- package/dist/git.js +159 -0
- package/dist/guards.d.ts +10 -0
- package/dist/guards.js +98 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +45 -0
- package/dist/labels.d.ts +5 -0
- package/dist/labels.js +10 -0
- package/dist/layout-defaults.d.ts +20 -0
- package/dist/layout-defaults.js +20 -0
- package/dist/mutate.d.ts +106 -0
- package/dist/mutate.js +547 -0
- package/dist/second-order.d.ts +39 -0
- package/dist/second-order.js +86 -0
- package/dist/text.d.ts +5 -0
- package/dist/text.js +25 -0
- package/dist/threat-model.d.ts +88 -0
- package/dist/threat-model.js +188 -0
- package/dist/types.d.ts +395 -0
- package/dist/types.js +27 -0
- package/dist/util.d.ts +11 -0
- package/dist/util.js +13 -0
- package/dist/validate.d.ts +19 -0
- package/dist/validate.js +736 -0
- package/dist/view/compile.d.ts +29 -0
- package/dist/view/compile.js +78 -0
- package/dist/view/edges.d.ts +4 -0
- package/dist/view/edges.js +118 -0
- package/dist/view/hierarchy.d.ts +41 -0
- package/dist/view/hierarchy.js +103 -0
- package/dist/view/layers.d.ts +7 -0
- package/dist/view/layers.js +17 -0
- package/dist/view/lod.d.ts +15 -0
- package/dist/view/lod.js +17 -0
- package/dist/view/scope.d.ts +23 -0
- package/dist/view/scope.js +106 -0
- package/dist/view/size.d.ts +8 -0
- package/dist/view/size.js +34 -0
- package/dist/view/tree.d.ts +12 -0
- package/dist/view/tree.js +147 -0
- package/dist/view/types.d.ts +68 -0
- package/dist/view/types.js +1 -0
- package/package.json +38 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type DiagramModel, type DiagramPlane, type NotationId } from '../types.js';
|
|
2
|
+
import type { CompiledView, ViewportState } from './types.js';
|
|
3
|
+
/** which plane's containment edges a view of `planeId` uses (resolves containmentOf).
|
|
4
|
+
* Kept here as the name the layout overlay is keyed by (see `layoutPlaneKey`);
|
|
5
|
+
* the resolution itself lives with the hierarchy, which also needs it. */
|
|
6
|
+
export declare function resolveContainmentPlane(m: DiagramModel, planeId?: string): string | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* The layers a host's own layer switch should START from: the plane's presets,
|
|
9
|
+
* with the plane resolved exactly as `compileView` resolves it (an absent id = the
|
|
10
|
+
* first-declared plane). Returns a fresh array, so host state never aliases the
|
|
11
|
+
* model's own `layers`.
|
|
12
|
+
*
|
|
13
|
+
* A host that shows layer toggles must seed with this — on load and on every
|
|
14
|
+
* plane change — and pass its state as `ViewportState.activeLayers` from then on.
|
|
15
|
+
* Seeding is what keeps "presets are the default" and "the user can turn a preset
|
|
16
|
+
* off" from being contradictory: the default arrives once, as state, instead of
|
|
17
|
+
* being re-applied underneath the user on every compile.
|
|
18
|
+
*/
|
|
19
|
+
export declare function presetLayers(planes: readonly DiagramPlane[], plane?: string): string[];
|
|
20
|
+
/**
|
|
21
|
+
* The visual language of the view being shown: the resolved plane's `notation`
|
|
22
|
+
* (an absent id = the first-declared plane, matching `compileView`). An
|
|
23
|
+
* unrecognized id falls back to the default look (`undefined`) instead of
|
|
24
|
+
* erroring — both the studio and the published viewer resolve it this way, so
|
|
25
|
+
* an editable diagram and its published page always agree. `fallback` is the
|
|
26
|
+
* model-level notation, used when the resolved plane declares none.
|
|
27
|
+
*/
|
|
28
|
+
export declare function activeNotation(planes: readonly DiagramPlane[], plane?: string, fallback?: string): NotationId | undefined;
|
|
29
|
+
export declare function compileView(m: DiagramModel, viewport: ViewportState): CompiledView;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { BUILTIN_NOTATIONS } from '../types.js';
|
|
2
|
+
import { buildHierarchy, containmentPlaneOf } from './hierarchy.js';
|
|
3
|
+
import { computeLod } from './lod.js';
|
|
4
|
+
import { buildViewTree } from './tree.js';
|
|
5
|
+
import { resolveEdges } from './edges.js';
|
|
6
|
+
import { scopeToRoot } from './scope.js';
|
|
7
|
+
/** which plane's containment edges a view of `planeId` uses (resolves containmentOf).
|
|
8
|
+
* Kept here as the name the layout overlay is keyed by (see `layoutPlaneKey`);
|
|
9
|
+
* the resolution itself lives with the hierarchy, which also needs it. */
|
|
10
|
+
export function resolveContainmentPlane(m, planeId) {
|
|
11
|
+
return containmentPlaneOf(m, planeId);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The layers a host's own layer switch should START from: the plane's presets,
|
|
15
|
+
* with the plane resolved exactly as `compileView` resolves it (an absent id = the
|
|
16
|
+
* first-declared plane). Returns a fresh array, so host state never aliases the
|
|
17
|
+
* model's own `layers`.
|
|
18
|
+
*
|
|
19
|
+
* A host that shows layer toggles must seed with this — on load and on every
|
|
20
|
+
* plane change — and pass its state as `ViewportState.activeLayers` from then on.
|
|
21
|
+
* Seeding is what keeps "presets are the default" and "the user can turn a preset
|
|
22
|
+
* off" from being contradictory: the default arrives once, as state, instead of
|
|
23
|
+
* being re-applied underneath the user on every compile.
|
|
24
|
+
*/
|
|
25
|
+
export function presetLayers(planes, plane) {
|
|
26
|
+
const p = plane !== undefined ? planes.find((x) => x.id === plane) : planes[0];
|
|
27
|
+
return [...(p?.layers ?? [])];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The visual language of the view being shown: the resolved plane's `notation`
|
|
31
|
+
* (an absent id = the first-declared plane, matching `compileView`). An
|
|
32
|
+
* unrecognized id falls back to the default look (`undefined`) instead of
|
|
33
|
+
* erroring — both the studio and the published viewer resolve it this way, so
|
|
34
|
+
* an editable diagram and its published page always agree. `fallback` is the
|
|
35
|
+
* model-level notation, used when the resolved plane declares none.
|
|
36
|
+
*/
|
|
37
|
+
export function activeNotation(planes, plane, fallback) {
|
|
38
|
+
const p = plane !== undefined ? planes.find((x) => x.id === plane) : planes[0];
|
|
39
|
+
const id = p?.notation ?? fallback;
|
|
40
|
+
return id !== undefined && BUILTIN_NOTATIONS.includes(id) ? id : undefined;
|
|
41
|
+
}
|
|
42
|
+
export function compileView(m, viewport) {
|
|
43
|
+
const planes = m.planes ?? [];
|
|
44
|
+
const plane = viewport.plane !== undefined ? planes.find((p) => p.id === viewport.plane) : planes[0];
|
|
45
|
+
// A plane's `layers` are the DEFAULT, not a floor: `activeLayers` undefined
|
|
46
|
+
// means the host has no opinion, so the plane's presets apply; an array — even
|
|
47
|
+
// an empty one — is the host's own choice and replaces them. Unioning the two
|
|
48
|
+
// (what this did until the layer switch existed) made a preset layer
|
|
49
|
+
// impossible to turn off, so a host with toggles seeds its state from
|
|
50
|
+
// `plane.layers` and owns it from then on.
|
|
51
|
+
const activeLayers = viewport.activeLayers ?? plane?.layers ?? [];
|
|
52
|
+
const activeLayerSet = new Set(activeLayers);
|
|
53
|
+
// The plane being viewed, not its containment donor: buildHierarchy resolves
|
|
54
|
+
// `containmentOf` itself, and needs the viewed plane to read its `hides`.
|
|
55
|
+
const hierarchy = buildHierarchy(m, viewport.plane, activeLayerSet);
|
|
56
|
+
// Isolated drill view: swap in a model scoped to root's interior (+ external
|
|
57
|
+
// stubs), then run the standard pipeline over it so LOD, promotion and edge
|
|
58
|
+
// aggregation all work unchanged.
|
|
59
|
+
if (viewport.root !== undefined && hierarchy.childrenOf.has(viewport.root)) {
|
|
60
|
+
const scoped = scopeToRoot(m, hierarchy, viewport.root);
|
|
61
|
+
const sh = buildHierarchy(scoped.model, undefined, activeLayerSet);
|
|
62
|
+
const lod = computeLod({ hierarchy: sh, focus: viewport.focus, pins: viewport.pins });
|
|
63
|
+
const tree = buildViewTree(scoped.model, sh, lod);
|
|
64
|
+
for (const [stubId, rep] of scoped.externals) {
|
|
65
|
+
const vn = tree.byId.get(stubId);
|
|
66
|
+
if (vn !== undefined)
|
|
67
|
+
vn.external = rep;
|
|
68
|
+
}
|
|
69
|
+
const edges = resolveEdges(scoped.model, tree, activeLayers, plane?.baseRelations ?? true);
|
|
70
|
+
const layoutEdges = resolveEdges(scoped.model, tree, scoped.model.layers.map((l) => l.id), true);
|
|
71
|
+
return { roots: tree.roots, edges, layoutEdges, lod, externals: scoped.externals };
|
|
72
|
+
}
|
|
73
|
+
const lod = computeLod({ hierarchy, focus: viewport.focus, pins: viewport.pins });
|
|
74
|
+
const tree = buildViewTree(m, hierarchy, lod);
|
|
75
|
+
const edges = resolveEdges(m, tree, activeLayers, plane?.baseRelations ?? true);
|
|
76
|
+
const layoutEdges = resolveEdges(m, tree, m.layers.map((l) => l.id), true);
|
|
77
|
+
return { roots: tree.roots, edges, layoutEdges, lod };
|
|
78
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { relationLabels } from '../labels.js';
|
|
2
|
+
import { relationLayer } from './layers.js';
|
|
3
|
+
/** the shared polarity of a set of relations: that sign iff every relation is
|
|
4
|
+
* defined and agrees; otherwise undefined (any missing or disagreeing sign) */
|
|
5
|
+
function combinePolarity(rels) {
|
|
6
|
+
let result;
|
|
7
|
+
for (const r of rels) {
|
|
8
|
+
if (r.polarity === undefined)
|
|
9
|
+
return undefined;
|
|
10
|
+
if (result === undefined)
|
|
11
|
+
result = r.polarity;
|
|
12
|
+
else if (result !== r.polarity)
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Width budget, in characters, for the joined labels of an AGGREGATE edge (one
|
|
19
|
+
* arrow standing for several relations). Above it the edge reports how many
|
|
20
|
+
* relations it rolled up instead of naming them.
|
|
21
|
+
*
|
|
22
|
+
* The budget is on the joined text, not on a label count, because what makes a
|
|
23
|
+
* folded view unreadable is text WIDTH on many edges at once: measured on
|
|
24
|
+
* a production C4 landscape (17 folded systems, ~400 relations), the
|
|
25
|
+
* old "first three joined, then +N" policy put 40-70 characters on hundreds of
|
|
26
|
+
* arrows. 32 characters is about two ordinary labels ("reads / writes",
|
|
27
|
+
* "publishes / consumes") — the case where naming both is genuinely more useful
|
|
28
|
+
* than counting them. Nothing is lost above it: the constituents are one fold, or
|
|
29
|
+
* one click on the edge, away.
|
|
30
|
+
*/
|
|
31
|
+
const AGG_LABEL_BUDGET = 32;
|
|
32
|
+
export function resolveEdges(m, tree, activeLayers, includeBase = true) {
|
|
33
|
+
const active = new Set(activeLayers ?? []);
|
|
34
|
+
const tintOf = new Map(m.layers.map((l) => [l.id, l.tint]));
|
|
35
|
+
const anchorFor = (id) => tree.byId.has(id) ? id : tree.anchorOf.get(id);
|
|
36
|
+
// Effective layer: the relation's own, else what `layerRules` assigns.
|
|
37
|
+
const layerOf = (r) => relationLayer(m, r);
|
|
38
|
+
const groups = new Map();
|
|
39
|
+
for (const r of m.relations) {
|
|
40
|
+
const layer = layerOf(r);
|
|
41
|
+
if (layer === undefined ? !includeBase : !active.has(layer))
|
|
42
|
+
continue;
|
|
43
|
+
const from = anchorFor(r.from);
|
|
44
|
+
const to = anchorFor(r.to);
|
|
45
|
+
if (from === undefined || to === undefined)
|
|
46
|
+
continue;
|
|
47
|
+
const isOriginalSelfLoop = r.from === r.to;
|
|
48
|
+
if (from === to && !(isOriginalSelfLoop && tree.byId.has(r.from)))
|
|
49
|
+
continue;
|
|
50
|
+
// Two arrows between the same pair pinned to *different* border sides are
|
|
51
|
+
// visually distinct, so keep them apart (like opposite directions already
|
|
52
|
+
// are). Pins only count when the relation attaches directly to the anchor —
|
|
53
|
+
// a relation rolled up to a container was pinned on its child, not the
|
|
54
|
+
// container, so it must still aggregate into the one boundary edge.
|
|
55
|
+
const direct = from === r.from && to === r.to;
|
|
56
|
+
const fromSide = direct ? r.style?.fromSide : undefined;
|
|
57
|
+
const toSide = direct ? r.style?.toSide : undefined;
|
|
58
|
+
const pinKey = fromSide !== undefined || toSide !== undefined ? `:${fromSide ?? ''}>${toSide ?? ''}` : '';
|
|
59
|
+
const key = `${from}=>${to}:${layer ?? ''}${pinKey}`;
|
|
60
|
+
const group = groups.get(key) ?? { from, to, layer, rels: [] };
|
|
61
|
+
group.rels.push(r);
|
|
62
|
+
groups.set(key, group);
|
|
63
|
+
}
|
|
64
|
+
return [...groups.entries()].map(([key, g]) => {
|
|
65
|
+
const kinds = new Set(g.rels.map((r) => r.kind));
|
|
66
|
+
const single = g.rels.length === 1 ? g.rels[0] : undefined;
|
|
67
|
+
const edge = {
|
|
68
|
+
id: key,
|
|
69
|
+
from: g.from,
|
|
70
|
+
to: g.to,
|
|
71
|
+
kind: kinds.size === 1 ? g.rels[0].kind : 'mixed',
|
|
72
|
+
constituents: g.rels,
|
|
73
|
+
};
|
|
74
|
+
if (single !== undefined) {
|
|
75
|
+
const labels = relationLabels(single);
|
|
76
|
+
if (labels.length > 0)
|
|
77
|
+
edge.labels = labels;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
// Multi-relation aggregate: name the constituents while that is still
|
|
81
|
+
// cheaper to read than counting them, and count them once it is not.
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
const distinct = [];
|
|
84
|
+
for (const r of g.rels) {
|
|
85
|
+
for (const l of relationLabels(r)) {
|
|
86
|
+
const t = l.text.trim();
|
|
87
|
+
if (t !== '' && !seen.has(t)) {
|
|
88
|
+
seen.add(t);
|
|
89
|
+
distinct.push(t);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const joined = distinct.join(' / ');
|
|
94
|
+
// ONE distinct label is the edge's own meaning however long it is (the
|
|
95
|
+
// renderer ellipsises it), so it always survives; several only earn their
|
|
96
|
+
// width while the join stays inside AGG_LABEL_BUDGET. An aggregate here
|
|
97
|
+
// always holds at least two relations, so the plural is always right.
|
|
98
|
+
edge.label =
|
|
99
|
+
distinct.length === 1 || (distinct.length > 1 && joined.length <= AGG_LABEL_BUDGET)
|
|
100
|
+
? joined
|
|
101
|
+
: `${g.rels.length} relations`;
|
|
102
|
+
}
|
|
103
|
+
if (single?.style !== undefined)
|
|
104
|
+
edge.style = single.style;
|
|
105
|
+
const polarity = combinePolarity(g.rels);
|
|
106
|
+
if (polarity !== undefined)
|
|
107
|
+
edge.polarity = polarity;
|
|
108
|
+
if (single?.delay !== undefined)
|
|
109
|
+
edge.delay = single.delay;
|
|
110
|
+
if (g.layer !== undefined) {
|
|
111
|
+
edge.layer = g.layer;
|
|
112
|
+
const tint = tintOf.get(g.layer);
|
|
113
|
+
if (tint !== undefined)
|
|
114
|
+
edge.tint = tint;
|
|
115
|
+
}
|
|
116
|
+
return edge;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { DiagramModel } from '../types.js';
|
|
2
|
+
export interface HierarchyIndex {
|
|
3
|
+
parentsOf: Map<string, string[]>;
|
|
4
|
+
childrenOf: Map<string, string[]>;
|
|
5
|
+
roots: string[];
|
|
6
|
+
}
|
|
7
|
+
/** Which plane's containment edges a view of `planeId` uses (resolves
|
|
8
|
+
* `containmentOf`, one hop — chains are a validation error). */
|
|
9
|
+
export declare function containmentPlaneOf(m: DiagramModel, planeId?: string): string | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* Containment indexes for one plane, over the plane's VISIBLE nodes only.
|
|
12
|
+
* Visibility is explicit: a node is shared (`node.plane` unset) and shown unless
|
|
13
|
+
* the plane hides it, or it is scoped to exactly this plane. Re-nesting via a
|
|
14
|
+
* plane-tagged containment edge never changes membership. `plane` = the plane
|
|
15
|
+
* being VIEWED; omitted = the model's default (first-declared) plane, or
|
|
16
|
+
* all-shared for models without planes.
|
|
17
|
+
*
|
|
18
|
+
* A plane with `containmentOf` borrows the donor's containment edges, and
|
|
19
|
+
* `node.plane` scopes are matched against the DONOR (a node pinned to the
|
|
20
|
+
* borrowing plane is not in the donor's hierarchy — see the how-to). `hides` and
|
|
21
|
+
* `hidesTree` are the exception: they are visibility choices, not structure, so
|
|
22
|
+
* each is the borrower's own when it declares that field, and the donor's when it
|
|
23
|
+
* does not (declare `hidesTree: []` to borrow a hierarchy and keep the detail the
|
|
24
|
+
* donor drops).
|
|
25
|
+
*
|
|
26
|
+
* The two hide lists differ in what happens to the contents:
|
|
27
|
+
* - `hides` promotes them. A hidden box's children take its place, each with
|
|
28
|
+
* its own nesting intact. Composition depends on this: an umbrella hides the
|
|
29
|
+
* `include` wrapper to lift a whole service model into position.
|
|
30
|
+
* - `hidesTree` takes them with it, however deep — unless another still-visible
|
|
31
|
+
* box also contains a child, which keeps that child (containment is a DAG).
|
|
32
|
+
* A node with no parent on this plane is a root and neither list can reach it
|
|
33
|
+
* except by naming it.
|
|
34
|
+
*
|
|
35
|
+
* A node tagged with a transparent-sheet `layer` is visible only while that layer
|
|
36
|
+
* is active; untagged nodes are the always-on base sheet. `activeLayers` omitted =
|
|
37
|
+
* no layer filtering (every layer treated as on). Layers deliberately do NOT
|
|
38
|
+
* cascade: a layer is an overlay you flip, and its children keep their own
|
|
39
|
+
* visibility, so a layered box still promotes its interior when it is off.
|
|
40
|
+
*/
|
|
41
|
+
export declare function buildHierarchy(m: DiagramModel, plane?: string, activeLayers?: ReadonlySet<string>): HierarchyIndex;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** Which plane's containment edges a view of `planeId` uses (resolves
|
|
2
|
+
* `containmentOf`, one hop — chains are a validation error). */
|
|
3
|
+
export function containmentPlaneOf(m, planeId) {
|
|
4
|
+
const planes = m.planes ?? [];
|
|
5
|
+
const plane = planeId !== undefined ? planes.find((p) => p.id === planeId) : planes[0];
|
|
6
|
+
return plane?.containmentOf ?? plane?.id;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Containment indexes for one plane, over the plane's VISIBLE nodes only.
|
|
10
|
+
* Visibility is explicit: a node is shared (`node.plane` unset) and shown unless
|
|
11
|
+
* the plane hides it, or it is scoped to exactly this plane. Re-nesting via a
|
|
12
|
+
* plane-tagged containment edge never changes membership. `plane` = the plane
|
|
13
|
+
* being VIEWED; omitted = the model's default (first-declared) plane, or
|
|
14
|
+
* all-shared for models without planes.
|
|
15
|
+
*
|
|
16
|
+
* A plane with `containmentOf` borrows the donor's containment edges, and
|
|
17
|
+
* `node.plane` scopes are matched against the DONOR (a node pinned to the
|
|
18
|
+
* borrowing plane is not in the donor's hierarchy — see the how-to). `hides` and
|
|
19
|
+
* `hidesTree` are the exception: they are visibility choices, not structure, so
|
|
20
|
+
* each is the borrower's own when it declares that field, and the donor's when it
|
|
21
|
+
* does not (declare `hidesTree: []` to borrow a hierarchy and keep the detail the
|
|
22
|
+
* donor drops).
|
|
23
|
+
*
|
|
24
|
+
* The two hide lists differ in what happens to the contents:
|
|
25
|
+
* - `hides` promotes them. A hidden box's children take its place, each with
|
|
26
|
+
* its own nesting intact. Composition depends on this: an umbrella hides the
|
|
27
|
+
* `include` wrapper to lift a whole service model into position.
|
|
28
|
+
* - `hidesTree` takes them with it, however deep — unless another still-visible
|
|
29
|
+
* box also contains a child, which keeps that child (containment is a DAG).
|
|
30
|
+
* A node with no parent on this plane is a root and neither list can reach it
|
|
31
|
+
* except by naming it.
|
|
32
|
+
*
|
|
33
|
+
* A node tagged with a transparent-sheet `layer` is visible only while that layer
|
|
34
|
+
* is active; untagged nodes are the always-on base sheet. `activeLayers` omitted =
|
|
35
|
+
* no layer filtering (every layer treated as on). Layers deliberately do NOT
|
|
36
|
+
* cascade: a layer is an overlay you flip, and its children keep their own
|
|
37
|
+
* visibility, so a layered box still promotes its interior when it is off.
|
|
38
|
+
*/
|
|
39
|
+
export function buildHierarchy(m, plane, activeLayers) {
|
|
40
|
+
const planes = m.planes ?? [];
|
|
41
|
+
const defaultPlane = planes[0]?.id;
|
|
42
|
+
// `?? plane` keeps an unknown plane id behaving as it always did (an empty
|
|
43
|
+
// view) instead of falling back to every containment edge in the model.
|
|
44
|
+
const active = containmentPlaneOf(m, plane) ?? plane ?? defaultPlane;
|
|
45
|
+
const viewDef = plane !== undefined ? planes.find((p) => p.id === plane) : planes[0];
|
|
46
|
+
const donorDef = active !== undefined ? planes.find((p) => p.id === active) : undefined;
|
|
47
|
+
const hides = new Set(viewDef?.hides ?? donorDef?.hides ?? []);
|
|
48
|
+
const hidesTree = new Set(viewDef?.hidesTree ?? donorDef?.hidesTree ?? []);
|
|
49
|
+
// This plane's containment BEFORE any visibility filtering: the cascade has to
|
|
50
|
+
// see the edges that point into a hidden box to know what it contained.
|
|
51
|
+
const planeEdges = active === undefined ? m.containment : m.containment.filter((e) => (e.plane ?? defaultPlane) === active);
|
|
52
|
+
// The `hidesTree` closure. Only `hidesTree` ids SEED it, but a parent hidden
|
|
53
|
+
// either way counts as hidden when deciding whether a child has any visible
|
|
54
|
+
// parent left. Ids of plane-scoped nodes are dropped: such a node ignores
|
|
55
|
+
// `hides` (validate.ts reports that as `redundant-hide`), so it must not seed
|
|
56
|
+
// the closure either. Derived hiding applies to every node, scoped ones
|
|
57
|
+
// included — "nothing visible contains me any more".
|
|
58
|
+
const scopedIds = new Set(m.nodes.filter((n) => n.plane !== undefined).map((n) => n.id));
|
|
59
|
+
const shared = (id) => !scopedIds.has(id);
|
|
60
|
+
const hidden = new Set([...hides, ...hidesTree].filter(shared));
|
|
61
|
+
const parentsAll = new Map();
|
|
62
|
+
const childrenAll = new Map();
|
|
63
|
+
const push = (map, key, value) => {
|
|
64
|
+
const list = map.get(key);
|
|
65
|
+
if (list === undefined)
|
|
66
|
+
map.set(key, [value]);
|
|
67
|
+
else
|
|
68
|
+
list.push(value);
|
|
69
|
+
};
|
|
70
|
+
for (const e of planeEdges) {
|
|
71
|
+
push(parentsAll, e.child, e.parent);
|
|
72
|
+
push(childrenAll, e.parent, e.child);
|
|
73
|
+
}
|
|
74
|
+
const queue = [...hidesTree].filter(shared);
|
|
75
|
+
for (let i = 0; i < queue.length; i++) {
|
|
76
|
+
for (const child of childrenAll.get(queue[i]) ?? []) {
|
|
77
|
+
if (hidden.has(child))
|
|
78
|
+
continue;
|
|
79
|
+
if ((parentsAll.get(child) ?? []).every((p) => hidden.has(p))) {
|
|
80
|
+
hidden.add(child);
|
|
81
|
+
queue.push(child);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const layerOn = (n) => n.layer === undefined || activeLayers === undefined || activeLayers.has(n.layer);
|
|
86
|
+
const isVisible = (n) => (n.plane === undefined || n.plane === active) && !hidden.has(n.id) && layerOn(n);
|
|
87
|
+
const visible = new Set(m.nodes.filter(isVisible).map((n) => n.id));
|
|
88
|
+
const edges = planeEdges.filter((e) => visible.has(e.parent) && visible.has(e.child));
|
|
89
|
+
const parentsOf = new Map();
|
|
90
|
+
const childrenOf = new Map();
|
|
91
|
+
for (const id of visible) {
|
|
92
|
+
parentsOf.set(id, []);
|
|
93
|
+
childrenOf.set(id, []);
|
|
94
|
+
}
|
|
95
|
+
for (const e of edges) {
|
|
96
|
+
childrenOf.get(e.parent)?.push(e.child);
|
|
97
|
+
parentsOf.get(e.child)?.push(e.parent);
|
|
98
|
+
}
|
|
99
|
+
const roots = m.nodes
|
|
100
|
+
.filter((n) => visible.has(n.id) && (parentsOf.get(n.id)?.length ?? 0) === 0)
|
|
101
|
+
.map((n) => n.id);
|
|
102
|
+
return { parentsOf, childrenOf, roots };
|
|
103
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { DiagramModel, DiagramRelation } from '../types.js';
|
|
2
|
+
/** The layer a relation is drawn on: its own `layer`, else the first
|
|
3
|
+
* `layerRules` entry whose every named field (`kind`, `style.color`) matches,
|
|
4
|
+
* else undefined (the base sheet). Rules exist so a host can layer relations
|
|
5
|
+
* that arrived through an include; they never override an explicit layer.
|
|
6
|
+
* One function, so the view compiler and the legend cannot disagree. */
|
|
7
|
+
export declare function relationLayer(m: DiagramModel, r: DiagramRelation): string | undefined;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** The layer a relation is drawn on: its own `layer`, else the first
|
|
2
|
+
* `layerRules` entry whose every named field (`kind`, `style.color`) matches,
|
|
3
|
+
* else undefined (the base sheet). Rules exist so a host can layer relations
|
|
4
|
+
* that arrived through an include; they never override an explicit layer.
|
|
5
|
+
* One function, so the view compiler and the legend cannot disagree. */
|
|
6
|
+
export function relationLayer(m, r) {
|
|
7
|
+
if (r.layer !== undefined)
|
|
8
|
+
return r.layer;
|
|
9
|
+
for (const rule of m.layerRules ?? []) {
|
|
10
|
+
if (rule.kind !== undefined && rule.kind !== r.kind)
|
|
11
|
+
continue;
|
|
12
|
+
if (rule.color !== undefined && rule.color !== r.style?.color)
|
|
13
|
+
continue;
|
|
14
|
+
return rule.layer;
|
|
15
|
+
}
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { HierarchyIndex } from './hierarchy.js';
|
|
2
|
+
import type { LodState } from './types.js';
|
|
3
|
+
export interface LodInput {
|
|
4
|
+
hierarchy: HierarchyIndex;
|
|
5
|
+
/** ids of the containers the user is zoomed into (an ancestor chain, but any set works) */
|
|
6
|
+
focus?: string[];
|
|
7
|
+
pins?: Record<string, 'expanded' | 'collapsed'>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Focus-driven LOD: the diagram rests fully folded — every container renders
|
|
11
|
+
* as a single box — and only pinned-expanded containers plus the focus chain
|
|
12
|
+
* (what the viewer is zoomed into, computed by the renderer from the actual
|
|
13
|
+
* viewport) expand. Pins always win over focus.
|
|
14
|
+
*/
|
|
15
|
+
export declare function computeLod(input: LodInput): LodState;
|
package/dist/view/lod.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focus-driven LOD: the diagram rests fully folded — every container renders
|
|
3
|
+
* as a single box — and only pinned-expanded containers plus the focus chain
|
|
4
|
+
* (what the viewer is zoomed into, computed by the renderer from the actual
|
|
5
|
+
* viewport) expand. Pins always win over focus.
|
|
6
|
+
*/
|
|
7
|
+
export function computeLod(input) {
|
|
8
|
+
const out = {};
|
|
9
|
+
const focus = new Set(input.focus ?? []);
|
|
10
|
+
for (const [id, kids] of input.hierarchy.childrenOf) {
|
|
11
|
+
if (kids.length === 0)
|
|
12
|
+
continue;
|
|
13
|
+
const pin = input.pins?.[id];
|
|
14
|
+
out[id] = pin ?? (focus.has(id) ? 'expanded' : 'collapsed');
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { DiagramModel } from '../types.js';
|
|
2
|
+
import type { HierarchyIndex } from './hierarchy.js';
|
|
3
|
+
/** Reserved id prefix for external stub nodes (the off-frame node a boundary-
|
|
4
|
+
* crossing edge points to in an isolated drill view). The `__ext__:` prefix keeps it from
|
|
5
|
+
* ever colliding with a real, author-authored node id. */
|
|
6
|
+
export declare const EXTERNAL_STUB_PREFIX = "__ext__:";
|
|
7
|
+
export interface ScopedModel {
|
|
8
|
+
/** synthetic model: the drill root's descendants + external stubs, with
|
|
9
|
+
* boundary-crossing relations re-pointed to those stubs */
|
|
10
|
+
model: DiagramModel;
|
|
11
|
+
/** stub node id -> the real off-frame node it stands in for */
|
|
12
|
+
externals: Map<string, string>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Scope a model to the INTERIOR of `root` for an isolated drill view: keep only
|
|
16
|
+
* root's descendants (root's own children become the parentless top level), drop
|
|
17
|
+
* relations wholly outside, keep internal ones, and re-point each boundary-crossing
|
|
18
|
+
* relation to a stub node standing in for the off-frame endpoint's outermost
|
|
19
|
+
* ancestor OUTSIDE the drill root's own ancestor chain (so "a class uses the
|
|
20
|
+
* database" stays visible as class → ⟨Database⟩, and a sibling of the root stands
|
|
21
|
+
* for itself rather than collapsing into the ancestor both sides share).
|
|
22
|
+
*/
|
|
23
|
+
export declare function scopeToRoot(m: DiagramModel, h: HierarchyIndex, root: string): ScopedModel;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** Reserved id prefix for external stub nodes (the off-frame node a boundary-
|
|
2
|
+
* crossing edge points to in an isolated drill view). The `__ext__:` prefix keeps it from
|
|
3
|
+
* ever colliding with a real, author-authored node id. */
|
|
4
|
+
export const EXTERNAL_STUB_PREFIX = '__ext__:';
|
|
5
|
+
/**
|
|
6
|
+
* Scope a model to the INTERIOR of `root` for an isolated drill view: keep only
|
|
7
|
+
* root's descendants (root's own children become the parentless top level), drop
|
|
8
|
+
* relations wholly outside, keep internal ones, and re-point each boundary-crossing
|
|
9
|
+
* relation to a stub node standing in for the off-frame endpoint's outermost
|
|
10
|
+
* ancestor OUTSIDE the drill root's own ancestor chain (so "a class uses the
|
|
11
|
+
* database" stays visible as class → ⟨Database⟩, and a sibling of the root stands
|
|
12
|
+
* for itself rather than collapsing into the ancestor both sides share).
|
|
13
|
+
*/
|
|
14
|
+
export function scopeToRoot(m, h, root) {
|
|
15
|
+
const sub = new Set();
|
|
16
|
+
const stack = [...(h.childrenOf.get(root) ?? [])];
|
|
17
|
+
while (stack.length > 0) {
|
|
18
|
+
const id = stack.pop();
|
|
19
|
+
if (sub.has(id))
|
|
20
|
+
continue;
|
|
21
|
+
sub.add(id);
|
|
22
|
+
stack.push(...(h.childrenOf.get(id) ?? []));
|
|
23
|
+
}
|
|
24
|
+
// the drill root and everything containing it (first-parent chain) — a stub
|
|
25
|
+
// representing a box that CONTAINS the frame would say nothing about the edge
|
|
26
|
+
const rootAncestors = new Set([root]);
|
|
27
|
+
for (let cur = root;;) {
|
|
28
|
+
const p = (h.parentsOf.get(cur) ?? [])[0];
|
|
29
|
+
if (p === undefined || rootAncestors.has(p))
|
|
30
|
+
break;
|
|
31
|
+
rootAncestors.add(p);
|
|
32
|
+
cur = p;
|
|
33
|
+
}
|
|
34
|
+
// outermost ancestor of `id` outside the root's ancestor chain (following the
|
|
35
|
+
// first-parent chain; containment is a DAG); undefined when `id` is itself on
|
|
36
|
+
// that chain — the relation touches the frame, not a peer
|
|
37
|
+
const repOf = (id) => {
|
|
38
|
+
if (rootAncestors.has(id))
|
|
39
|
+
return undefined;
|
|
40
|
+
const seen = new Set([id]);
|
|
41
|
+
let cur = id;
|
|
42
|
+
for (;;) {
|
|
43
|
+
const p = (h.parentsOf.get(cur) ?? [])[0];
|
|
44
|
+
if (p === undefined || rootAncestors.has(p) || seen.has(p))
|
|
45
|
+
return cur;
|
|
46
|
+
seen.add(p);
|
|
47
|
+
cur = p;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const nodeOf = new Map(m.nodes.map((n) => [n.id, n]));
|
|
51
|
+
const externals = new Map();
|
|
52
|
+
const stubFor = (offFrame) => {
|
|
53
|
+
const rep = repOf(offFrame);
|
|
54
|
+
if (rep === undefined || sub.has(rep))
|
|
55
|
+
return undefined; // a relation on the frame itself — skip
|
|
56
|
+
const id = `${EXTERNAL_STUB_PREFIX}${rep}`;
|
|
57
|
+
externals.set(id, rep);
|
|
58
|
+
return id;
|
|
59
|
+
};
|
|
60
|
+
const relations = [];
|
|
61
|
+
for (const r of m.relations) {
|
|
62
|
+
const fromIn = sub.has(r.from);
|
|
63
|
+
const toIn = sub.has(r.to);
|
|
64
|
+
if (fromIn && toIn) {
|
|
65
|
+
relations.push(r);
|
|
66
|
+
}
|
|
67
|
+
else if (fromIn) {
|
|
68
|
+
const stub = stubFor(r.to);
|
|
69
|
+
if (stub !== undefined)
|
|
70
|
+
relations.push({ ...r, to: stub });
|
|
71
|
+
}
|
|
72
|
+
else if (toIn) {
|
|
73
|
+
const stub = stubFor(r.from);
|
|
74
|
+
if (stub !== undefined)
|
|
75
|
+
relations.push({ ...r, from: stub });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// The subtree is already within one resolved plane; the synthetic model is
|
|
79
|
+
// plane-less, so strip each node's `plane` scope (else buildHierarchy would
|
|
80
|
+
// filter a plane-scoped node out when no plane is active).
|
|
81
|
+
const nodes = m.nodes
|
|
82
|
+
.filter((n) => sub.has(n.id))
|
|
83
|
+
.map(({ plane: _plane, ...rest }) => rest);
|
|
84
|
+
// a stub keeps the represented node's visual identity (type/shape/color/icon)
|
|
85
|
+
// so it renders like the original entity; the renderer adds the ghost look
|
|
86
|
+
for (const [stubId, rep] of externals) {
|
|
87
|
+
const src = nodeOf.get(rep);
|
|
88
|
+
nodes.push({
|
|
89
|
+
id: stubId,
|
|
90
|
+
name: src?.name ?? rep,
|
|
91
|
+
...(src?.type !== undefined ? { type: src.type } : {}),
|
|
92
|
+
...(src?.icon !== undefined ? { icon: src.icon } : {}),
|
|
93
|
+
...(src?.image !== undefined ? { image: src.image } : {}),
|
|
94
|
+
...(src?.shape !== undefined ? { shape: src.shape } : {}),
|
|
95
|
+
...(src?.color !== undefined ? { color: src.color } : {}),
|
|
96
|
+
...(src?.textColor !== undefined ? { textColor: src.textColor } : {}),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const containment = m.containment
|
|
100
|
+
.filter((e) => sub.has(e.parent) && sub.has(e.child))
|
|
101
|
+
.map(({ plane: _plane, ...rest }) => rest);
|
|
102
|
+
// Drill happens within one already-resolved plane; the synthetic model is
|
|
103
|
+
// plane-less so buildHierarchy uses its raw containment (root's children +
|
|
104
|
+
// stubs are the parentless roots).
|
|
105
|
+
return { model: { ...m, nodes, containment, relations, planes: [] }, externals };
|
|
106
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { HierarchyIndex } from './hierarchy.js';
|
|
2
|
+
import type { Size } from './types.js';
|
|
3
|
+
export declare const LEAF_SIZE: Size;
|
|
4
|
+
export declare const CONTAINER_PADDING = 24;
|
|
5
|
+
export declare const CONTAINER_HEADER = 32;
|
|
6
|
+
/** Rough intrinsic (world-unit) sizes for LOD decisions; Plan 3 swaps in
|
|
7
|
+
* renderer-measured sizes through the same Map shape. */
|
|
8
|
+
export declare function estimateSizes(h: HierarchyIndex): Map<string, Size>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const LEAF_SIZE = { width: 160, height: 80 };
|
|
2
|
+
export const CONTAINER_PADDING = 24;
|
|
3
|
+
export const CONTAINER_HEADER = 32;
|
|
4
|
+
/** Rough intrinsic (world-unit) sizes for LOD decisions; Plan 3 swaps in
|
|
5
|
+
* renderer-measured sizes through the same Map shape. */
|
|
6
|
+
export function estimateSizes(h) {
|
|
7
|
+
const sizes = new Map();
|
|
8
|
+
const visit = (id) => {
|
|
9
|
+
const memo = sizes.get(id);
|
|
10
|
+
if (memo)
|
|
11
|
+
return memo;
|
|
12
|
+
const kids = h.childrenOf.get(id) ?? [];
|
|
13
|
+
let size;
|
|
14
|
+
if (kids.length === 0) {
|
|
15
|
+
size = { ...LEAF_SIZE };
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
const kidSizes = kids.map(visit);
|
|
19
|
+
const cols = Math.ceil(Math.sqrt(kidSizes.length));
|
|
20
|
+
const rows = Math.ceil(kidSizes.length / cols);
|
|
21
|
+
const cellW = Math.max(...kidSizes.map((s) => s.width));
|
|
22
|
+
const cellH = Math.max(...kidSizes.map((s) => s.height));
|
|
23
|
+
size = {
|
|
24
|
+
width: cols * cellW + (cols + 1) * CONTAINER_PADDING,
|
|
25
|
+
height: rows * cellH + (rows + 1) * CONTAINER_PADDING + CONTAINER_HEADER,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
sizes.set(id, size);
|
|
29
|
+
return size;
|
|
30
|
+
};
|
|
31
|
+
for (const id of h.parentsOf.keys())
|
|
32
|
+
visit(id);
|
|
33
|
+
return sizes;
|
|
34
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DiagramModel } from '../types.js';
|
|
2
|
+
import type { HierarchyIndex } from './hierarchy.js';
|
|
3
|
+
import type { LodState, ViewNode } from './types.js';
|
|
4
|
+
export interface ViewTree {
|
|
5
|
+
roots: ViewNode[];
|
|
6
|
+
byId: Map<string, ViewNode>;
|
|
7
|
+
/** hidden node id -> its unique visible absorber */
|
|
8
|
+
anchorOf: Map<string, string>;
|
|
9
|
+
/** visible node id -> placement parent (null = top level) */
|
|
10
|
+
hostOf: Map<string, string | null>;
|
|
11
|
+
}
|
|
12
|
+
export declare function buildViewTree(m: DiagramModel, h: HierarchyIndex, lod: LodState): ViewTree;
|