@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.
Files changed (59) hide show
  1. package/LICENSE +709 -0
  2. package/README.md +27 -0
  3. package/dist/builder.d.ts +381 -0
  4. package/dist/builder.js +590 -0
  5. package/dist/children.d.ts +21 -0
  6. package/dist/children.js +45 -0
  7. package/dist/commands.d.ts +219 -0
  8. package/dist/commands.js +474 -0
  9. package/dist/compose.d.ts +19 -0
  10. package/dist/compose.js +246 -0
  11. package/dist/drawings.d.ts +13 -0
  12. package/dist/drawings.js +36 -0
  13. package/dist/eject.d.ts +20 -0
  14. package/dist/eject.js +260 -0
  15. package/dist/fishbone.d.ts +66 -0
  16. package/dist/fishbone.js +95 -0
  17. package/dist/git.d.ts +65 -0
  18. package/dist/git.js +159 -0
  19. package/dist/guards.d.ts +10 -0
  20. package/dist/guards.js +98 -0
  21. package/dist/index.d.ts +25 -0
  22. package/dist/index.js +45 -0
  23. package/dist/labels.d.ts +5 -0
  24. package/dist/labels.js +10 -0
  25. package/dist/layout-defaults.d.ts +20 -0
  26. package/dist/layout-defaults.js +20 -0
  27. package/dist/mutate.d.ts +106 -0
  28. package/dist/mutate.js +547 -0
  29. package/dist/second-order.d.ts +39 -0
  30. package/dist/second-order.js +86 -0
  31. package/dist/text.d.ts +5 -0
  32. package/dist/text.js +25 -0
  33. package/dist/threat-model.d.ts +88 -0
  34. package/dist/threat-model.js +188 -0
  35. package/dist/types.d.ts +395 -0
  36. package/dist/types.js +27 -0
  37. package/dist/util.d.ts +11 -0
  38. package/dist/util.js +13 -0
  39. package/dist/validate.d.ts +19 -0
  40. package/dist/validate.js +736 -0
  41. package/dist/view/compile.d.ts +29 -0
  42. package/dist/view/compile.js +78 -0
  43. package/dist/view/edges.d.ts +4 -0
  44. package/dist/view/edges.js +118 -0
  45. package/dist/view/hierarchy.d.ts +41 -0
  46. package/dist/view/hierarchy.js +103 -0
  47. package/dist/view/layers.d.ts +7 -0
  48. package/dist/view/layers.js +17 -0
  49. package/dist/view/lod.d.ts +15 -0
  50. package/dist/view/lod.js +17 -0
  51. package/dist/view/scope.d.ts +23 -0
  52. package/dist/view/scope.js +106 -0
  53. package/dist/view/size.d.ts +8 -0
  54. package/dist/view/size.js +34 -0
  55. package/dist/view/tree.d.ts +12 -0
  56. package/dist/view/tree.js +147 -0
  57. package/dist/view/types.d.ts +68 -0
  58. package/dist/view/types.js +1 -0
  59. 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,4 @@
1
+ import type { DiagramModel } from '../types.js';
2
+ import type { ViewTree } from './tree.js';
3
+ import type { ViewEdge } from './types.js';
4
+ export declare function resolveEdges(m: DiagramModel, tree: ViewTree, activeLayers?: string[], includeBase?: boolean): ViewEdge[];
@@ -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;
@@ -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;