@likec4/core 1.19.1 → 1.20.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 (91) hide show
  1. package/dist/builder/index.cjs +881 -0
  2. package/dist/builder/index.d.cts +503 -0
  3. package/dist/builder/index.d.mts +5 -4
  4. package/dist/builder/index.d.ts +5 -4
  5. package/dist/builder/{index.js → index.mjs} +14 -4
  6. package/dist/compute-view/index.cjs +86 -0
  7. package/dist/compute-view/index.d.cts +23 -0
  8. package/dist/compute-view/index.d.mts +3 -2
  9. package/dist/compute-view/index.d.ts +3 -2
  10. package/dist/compute-view/{index.js → index.mjs} +6 -5
  11. package/dist/index.cjs +4320 -0
  12. package/dist/index.d.cts +270 -0
  13. package/dist/index.d.mts +130 -4
  14. package/dist/index.d.ts +130 -4
  15. package/dist/{index.js → index.mjs} +512 -7
  16. package/dist/model/index.cjs +40 -0
  17. package/dist/model/index.d.cts +259 -0
  18. package/dist/model/index.d.mts +8 -3
  19. package/dist/model/index.d.ts +8 -3
  20. package/dist/model/index.mjs +1 -0
  21. package/dist/shared/core.B2QYWlYe.cjs +435 -0
  22. package/dist/shared/core.B72vUaNB.d.cts +850 -0
  23. package/dist/shared/core.BGus30EM.cjs +8027 -0
  24. package/dist/shared/core.BRuJ3Wzf.d.mts +12 -0
  25. package/dist/shared/{core.BZ9Msq7w.d.mts → core.BdbFkE_p.d.cts} +14 -4
  26. package/dist/shared/core.BvdNMbcA.d.cts +12 -0
  27. package/dist/shared/{object_hash.CE_oF3I3.js → core.BxvDITEI.mjs} +26 -389
  28. package/dist/shared/core.ByNgh-mC.d.cts +127 -0
  29. package/dist/shared/core.C9DCnJh8.cjs +908 -0
  30. package/dist/shared/{index.DafVOuVd.js → core.CqCTxzMY.mjs} +1111 -572
  31. package/dist/shared/core.D74xkKkG.d.cts +148 -0
  32. package/dist/shared/{core.C05RDLhi.d.ts → core.D_Gc58Gt.d.ts} +23 -5
  33. package/dist/shared/core.Damrzla-.d.ts +12 -0
  34. package/dist/shared/{core.CmYMqgha.d.mts → core.Dfzrh1VA.d.mts} +23 -5
  35. package/dist/shared/core.DsoCu540.cjs +26 -0
  36. package/dist/shared/{view.CyZrG8BJ.js → core.DunIMHRf.mjs} +27 -1
  37. package/dist/shared/{core.BIfgabEw.d.ts → core.ZWiGANIJ.d.ts} +14 -4
  38. package/dist/shared/core.gv1bMwCC.cjs +39 -0
  39. package/dist/shared/core.jy6z2iRn.d.mts +729 -0
  40. package/dist/shared/core.m3rCQAv6.mjs +37 -0
  41. package/dist/types/index.cjs +107 -0
  42. package/dist/types/index.d.cts +72 -0
  43. package/dist/types/index.d.mts +2 -2
  44. package/dist/types/index.d.ts +2 -2
  45. package/dist/types/{index.js → index.mjs} +1 -1
  46. package/dist/utils/index.cjs +95 -0
  47. package/dist/utils/index.d.cts +356 -0
  48. package/dist/utils/index.d.mts +1 -64
  49. package/dist/utils/index.d.ts +1 -64
  50. package/dist/utils/index.mjs +56 -0
  51. package/package.json +52 -26
  52. package/src/builder/Builder.view-common.ts +21 -1
  53. package/src/compute-view/deployment-view/__test__/fixture.ts +31 -3
  54. package/src/compute-view/deployment-view/_types.ts +2 -1
  55. package/src/compute-view/deployment-view/compute.ts +4 -36
  56. package/src/compute-view/deployment-view/predicates/index.ts +8 -0
  57. package/src/compute-view/deployment-view/predicates/relation-direct.ts +125 -99
  58. package/src/compute-view/deployment-view/predicates/relation-in-out.ts +16 -8
  59. package/src/compute-view/deployment-view/predicates/relation-incoming.ts +18 -12
  60. package/src/compute-view/deployment-view/predicates/relation-outgoing.ts +16 -9
  61. package/src/compute-view/deployment-view/predicates/relation-where.ts +18 -0
  62. package/src/compute-view/deployment-view/predicates/utils.ts +214 -0
  63. package/src/compute-view/element-view/__test__/fixture.ts +17 -4
  64. package/src/compute-view/element-view/compute.ts +3 -2
  65. package/src/compute-view/element-view/predicates.ts +520 -0
  66. package/src/compute-view/element-view/utils.ts +11 -7
  67. package/src/compute-view/index.ts +1 -0
  68. package/src/compute-view/relationships-view/_types.ts +16 -0
  69. package/src/compute-view/relationships-view/compute.ts +74 -0
  70. package/src/compute-view/relationships-view/layout.ts +357 -0
  71. package/src/compute-view/relationships-view/utils.ts +40 -0
  72. package/src/compute-view/utils/relationExpressionToPredicates.ts +11 -4
  73. package/src/index.ts +16 -0
  74. package/src/model/DeploymentElementModel.ts +18 -1
  75. package/src/model/ElementModel.ts +13 -0
  76. package/src/model/LikeC4Model.ts +2 -2
  77. package/src/model/RelationModel.ts +9 -0
  78. package/src/model/connection/deployment/DeploymentConnectionModel.ts +11 -1
  79. package/src/model/guards.ts +17 -1
  80. package/src/model/view/LikeC4ViewModel.ts +7 -1
  81. package/src/model/view/NodeModel.ts +5 -0
  82. package/src/types/deployments.ts +1 -0
  83. package/src/types/expression-v2.ts +12 -0
  84. package/src/types/index.ts +1 -0
  85. package/src/types/operators.ts +46 -6
  86. package/src/types/view.ts +2 -2
  87. package/src/utils/index.ts +0 -5
  88. package/src/utils/iterable/unique.ts +1 -7
  89. package/dist/model/index.js +0 -1
  90. package/dist/utils/index.js +0 -347
  91. /package/dist/shared/{chunk-RAAYCPUM.M-JWF7SS.js → core.M-JWF7SS.mjs} +0 -0
@@ -0,0 +1,357 @@
1
+ import dagre, { type EdgeConfig, type GraphLabel } from '@dagrejs/dagre'
2
+ import DefaultMap from 'mnemonist/default-map'
3
+ import { concat, filter, forEachObj, groupBy, map, mapToObj, pipe, prop, reduce, tap } from 'remeda'
4
+ import { invariant } from '../../errors'
5
+ import type { ElementModel } from '../../model/ElementModel'
6
+ import type { RelationshipModel } from '../../model/RelationModel'
7
+ import type { DiagramNode, DiagramView, Fqn, NodeId } from '../../types'
8
+ import { sortParentsFirst } from '../../utils'
9
+ import { toArray } from '../../utils/iterable'
10
+ import type { RelationshipsViewData } from './_types'
11
+ import { treeFromElements } from './utils'
12
+
13
+ /**
14
+ * All constants related to the layout
15
+ */
16
+ const Sizes = {
17
+ dagre: {
18
+ ranksep: 60,
19
+ nodesep: 35,
20
+ edgesep: 25,
21
+ } satisfies GraphLabel,
22
+ edgeLabel: {
23
+ width: 120,
24
+ height: 10,
25
+ minlen: 1,
26
+ weight: 1,
27
+ } satisfies EdgeConfig,
28
+
29
+ emptyNodeOffset: 120,
30
+
31
+ nodeWidth: 320,
32
+ nodeHeight: 180,
33
+
34
+ // Spacer between elements in a compound node
35
+ // 0 means no spacer
36
+ spacerHeight: 0,
37
+
38
+ compound: {
39
+ labelHeight: 1,
40
+ paddingTop: 60,
41
+ paddingBottom: 32,
42
+ },
43
+ }
44
+ type NodeData = {
45
+ portId: string
46
+ element: ElementModel
47
+ isCompound: boolean
48
+ }
49
+ function createGraph() {
50
+ const g = new dagre.graphlib.Graph<NodeData>({
51
+ directed: true,
52
+ compound: true,
53
+ multigraph: true,
54
+ })
55
+ g.setGraph({
56
+ ...Sizes.dagre,
57
+ rankdir: 'LR',
58
+ })
59
+ g.setDefaultEdgeLabel(() => ({ ...Sizes.edgeLabel }))
60
+ g.setDefaultNodeLabel(() => ({}))
61
+
62
+ // columns.reduce((prev, column) => {
63
+ // const c = graphColumn(column)
64
+ // g.setNode(c.id, {})
65
+ // g.setNode(c.anchor, { width: 40, height: 2 })
66
+ // g.setParent(c.anchor, c.id)
67
+ // if (prev) {
68
+ // g.setEdge(prev, c.anchor, {
69
+ // width: 0
70
+ // })
71
+ // }
72
+ // return c.anchor
73
+ // }, null as string | null)
74
+
75
+ return g
76
+ }
77
+ type G = ReturnType<typeof createGraph>
78
+
79
+ const PortSuffix = '-port'
80
+
81
+ function createNodes(
82
+ prefix: string,
83
+ elements: ReadonlySet<ElementModel>,
84
+ g: G,
85
+ ) {
86
+ const graphNodes = new DefaultMap<Fqn, { id: string; portId: string }>(key => ({
87
+ id: `${prefix}-${key}`,
88
+ portId: `${prefix}-${key}`,
89
+ }))
90
+ const tree = treeFromElements(elements)
91
+ for (const element of tree.sorted) {
92
+ const isCompound = tree.children(element).length > 0
93
+ const fqn = element.id
94
+ const id = `${prefix}-${fqn}`
95
+ const portId = isCompound ? `${id}${PortSuffix}` : id
96
+ graphNodes.set(fqn, {
97
+ id,
98
+ portId,
99
+ })
100
+ g.setNode(id, {
101
+ element,
102
+ isCompound,
103
+ portId,
104
+ width: Sizes.nodeWidth,
105
+ height: Sizes.nodeHeight,
106
+ })
107
+
108
+ if (isCompound) {
109
+ g.setNode(portId, {
110
+ element,
111
+ portId,
112
+ isCompound,
113
+ width: Sizes.nodeWidth - Sizes.dagre.ranksep,
114
+ height: Sizes.compound.labelHeight,
115
+ })
116
+ g.setParent(portId, id)
117
+ // g.node(id).padding = 60
118
+ }
119
+
120
+ const parent = tree.parent(element)
121
+ if (parent) {
122
+ g.setParent(id, `${prefix}-${parent.id}`)
123
+ }
124
+ }
125
+ return {
126
+ ...tree,
127
+ byId: (id: string) => {
128
+ const element = tree.byId(id)
129
+ const graph = graphNodes.get(element.id)
130
+ return {
131
+ element,
132
+ graph,
133
+ }
134
+ },
135
+ graphNodes,
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Apply dagre layout to the graph
141
+ * And return a function to get node bounds for xyflow
142
+ */
143
+ function applyDagreLayout(g: G) {
144
+ dagre.layout(g, {
145
+ // disableOptimalOrderHeuristic: true,
146
+ })
147
+ return (nodeId: string) => {
148
+ const node = g.node(nodeId)
149
+ const { x, y, width, height } = node
150
+ return {
151
+ position: {
152
+ x: x - Math.round(width / 2),
153
+ y: y - Math.round(height / 2),
154
+ },
155
+ width,
156
+ height,
157
+ }
158
+ }
159
+ }
160
+
161
+ export function layoutRelationshipsView(data: RelationshipsViewData): Pick<DiagramView, 'nodes' | 'edges' | 'bounds'> {
162
+ const g = createGraph()
163
+
164
+ const incomers = createNodes('in', data.incomers, g),
165
+ subjects = createNodes('subject', data.subjects, g),
166
+ outgoers = createNodes('out', data.outgoers, g)
167
+
168
+ const edges = [] as Array<{
169
+ name: string
170
+ source: string // node id
171
+ target: string // node id
172
+ relations: RelationshipModel[]
173
+ }>
174
+
175
+ pipe(
176
+ concat(
177
+ pipe(
178
+ toArray(data.incoming),
179
+ map(r => ({
180
+ id: r.source.id,
181
+ source: incomers.byId(r.source.id).graph,
182
+ target: subjects.byId(r.target.id).graph,
183
+ relation: r,
184
+ })),
185
+ // Sort by source
186
+ sortParentsFirst,
187
+ ),
188
+ pipe(
189
+ toArray(data.outgoing),
190
+ map(r => ({
191
+ id: r.target.id,
192
+ source: subjects.byId(r.source.id).graph,
193
+ target: outgoers.byId(r.target.id).graph,
194
+ relation: r,
195
+ })),
196
+ // Sort by target
197
+ sortParentsFirst,
198
+ ),
199
+ ),
200
+ map(r => ({
201
+ ...r,
202
+ expr: `${r.source.id}->${r.target.id}`,
203
+ })),
204
+ // Group if same source and target
205
+ groupBy(prop('expr')),
206
+ forEachObj((grouped) => {
207
+ const source = grouped[0].source
208
+ const target = grouped[0].target
209
+ const name = grouped[0].expr
210
+
211
+ g.setEdge(source.portId, target.portId, {
212
+ ...Sizes.edgeLabel,
213
+ }, name)
214
+ edges.push({
215
+ name,
216
+ source: source.id,
217
+ target: target.id,
218
+ relations: map(grouped, prop('relation')),
219
+ })
220
+ }),
221
+ )
222
+
223
+ // Grow nodes with more than 2 ports
224
+ for (const subjectNode of subjects.graphNodes.values()) {
225
+ const nodeId = subjectNode.id
226
+ const node = g.node(nodeId)
227
+ if (node.isCompound) {
228
+ continue
229
+ }
230
+ const edgeCount = Math.max(g.inEdges(nodeId)?.length ?? 0, g.outEdges(nodeId)?.length ?? 0)
231
+ if (edgeCount > 2) {
232
+ node.height = node.height + (edgeCount - 3) * 14
233
+ }
234
+ }
235
+
236
+ const dagreBounds = applyDagreLayout(g)
237
+
238
+ const nodeIds = [
239
+ ...incomers.graphNodes.values(),
240
+ ...subjects.graphNodes.values(),
241
+ ...outgoers.graphNodes.values(),
242
+ ]
243
+
244
+ // Calculate bounds for all nodes except compounds
245
+ // We shrink compounds to fit their children
246
+ const _calculatedNodeBounds = pipe(
247
+ nodeIds,
248
+ // Compound nodes have different portId
249
+ filter(n => n.id === n.portId),
250
+ mapToObj(n => [n.id, dagreBounds(n.id)]),
251
+ )
252
+
253
+ function nodeBounds(nodeId: string): ReturnType<typeof dagreBounds> {
254
+ return _calculatedNodeBounds[nodeId] ??= pipe(
255
+ g.children(nodeId) as string[] | undefined ?? [],
256
+ filter(id => !id.endsWith(PortSuffix)),
257
+ map(id => nodeBounds(id)),
258
+ tap(bounds => {
259
+ invariant(bounds.length > 0, `Node ${nodeId} has no nested nodes`)
260
+ }),
261
+ reduce((acc, bounds) => {
262
+ return {
263
+ minY: Math.min(acc.minY, bounds.position.y),
264
+ maxY: Math.max(acc.maxY, bounds.position.y + bounds.height),
265
+ }
266
+ }, { minY: Infinity, maxY: -Infinity }),
267
+ ({ minY, maxY }) => {
268
+ const {
269
+ position: { x },
270
+ width,
271
+ } = dagreBounds(nodeId)
272
+ minY = minY - Sizes.compound.paddingTop
273
+ maxY = maxY + Sizes.compound.paddingBottom
274
+
275
+ return {
276
+ position: {
277
+ x,
278
+ y: minY,
279
+ },
280
+ width,
281
+ height: maxY - minY,
282
+ }
283
+ },
284
+ )
285
+ }
286
+
287
+ function nodeLevel(nodeId: string): number {
288
+ const parent = g.parent(nodeId)
289
+ if (parent) {
290
+ return nodeLevel(parent) + 1
291
+ }
292
+ return 0
293
+ }
294
+ function nodeDepth(nodeId: string): number {
295
+ const children = g.children(nodeId) as string[] | undefined ?? []
296
+ if (children.length === 0) {
297
+ return 0
298
+ }
299
+ return 1 + Math.max(...children.map(nodeDepth))
300
+ }
301
+
302
+ const nodes = nodeIds.map(({ id }): DiagramNode => {
303
+ const { element } = g.node(id)
304
+ let { position, width, height } = nodeBounds(id)
305
+ const parentId = g.parent(id)
306
+ // if (parentId) {
307
+ // const parentPos = nodeBounds(parentId).position
308
+ // position = {
309
+ // x: position.x - parentPos.x,
310
+ // y: position.y - parentPos.y,
311
+ // }
312
+ // }
313
+ const children = (g.children(id) as NodeId[] | undefined ?? []).filter(c => !c.endsWith(PortSuffix))
314
+
315
+ return {
316
+ id: id as NodeId,
317
+ parent: parentId as NodeId ?? null,
318
+ position: [position.x, position.y],
319
+ title: element.title,
320
+ description: element.description,
321
+ technology: element.technology,
322
+ tags: null,
323
+ links: null,
324
+ color: element.color,
325
+ shape: element.shape,
326
+ modelRef: element.id,
327
+ kind: element.kind,
328
+ level: nodeLevel(id),
329
+ labelBBox: {
330
+ x: position.x,
331
+ y: position.y,
332
+ width: width,
333
+ height: height,
334
+ },
335
+ style: {
336
+ ...element.$element.style,
337
+ },
338
+ inEdges: [],
339
+ outEdges: [],
340
+ ...(children.length > 0 && { depth: nodeDepth(id) }),
341
+ children,
342
+ width,
343
+ height,
344
+ }
345
+ })
346
+
347
+ return {
348
+ bounds: {
349
+ x: 0,
350
+ y: 0,
351
+ width: g.graph().width ?? 100,
352
+ height: g.graph().height ?? 100,
353
+ },
354
+ nodes,
355
+ edges: [],
356
+ }
357
+ }
@@ -0,0 +1,40 @@
1
+ import DefaultMap from 'mnemonist/default-map'
2
+ import { nonNullable } from '../../errors'
3
+ import type { ElementModel } from '../../model/ElementModel'
4
+ import type { Fqn } from '../../types'
5
+ import { isAncestor, isDescendantOf, sortParentsFirst } from '../../utils/fqn'
6
+
7
+ export function treeFromElements(elements: Iterable<ElementModel>) {
8
+ const sorted = sortParentsFirst([...elements]) as ReadonlyArray<ElementModel>
9
+ const root = new Set(sorted)
10
+ const map = new Map(sorted.map(e => [e.id, e]))
11
+ const parents = new DefaultMap<ElementModel, ElementModel | null>(() => null)
12
+ const children = sorted.reduce((acc, parent, index, all) => {
13
+ acc.set(
14
+ parent,
15
+ all
16
+ .slice(index + 1)
17
+ .filter(isDescendantOf(parent))
18
+ .map(e => {
19
+ root.delete(e)
20
+ return e
21
+ })
22
+ .reduce((acc, el) => {
23
+ if (!acc.some(isAncestor(el))) {
24
+ acc.push(el)
25
+ parents.set(el, parent)
26
+ }
27
+ return acc
28
+ }, [] as ElementModel[]),
29
+ )
30
+ return acc
31
+ }, new DefaultMap<ElementModel, ElementModel[]>(() => []))
32
+
33
+ return {
34
+ sorted,
35
+ byId: (id: string) => nonNullable(map.get(id as Fqn), `Element not found by id: ${id}`),
36
+ root: root as ReadonlySet<ElementModel>,
37
+ parent: (el: ElementModel) => parents.get(el),
38
+ children: (el: ElementModel): ReadonlyArray<ElementModel> => children.get(el),
39
+ }
40
+ }
@@ -1,13 +1,14 @@
1
+ import { pick } from 'remeda'
1
2
  import { nonexhaustive } from '../../errors'
2
3
  import type { Element } from '../../types/element'
3
4
  import {
5
+ type RelationExpression,
6
+ type RelationWhereExpr,
4
7
  isIncoming,
5
8
  isInOut,
6
9
  isOutgoing,
7
10
  isRelation,
8
11
  isRelationWhere,
9
- type RelationExpression,
10
- type RelationWhereExpr
11
12
  } from '../../types/expression'
12
13
  import { whereOperatorAsPredicate } from '../../types/operators'
13
14
  import type { ModelRelation } from '../../types/relation'
@@ -21,13 +22,19 @@ export type FilterableEdge = Pick<ModelRelation, 'kind' | 'tags'> & {
21
22
  }
22
23
 
23
24
  export function relationExpressionToPredicates<T extends FilterableEdge>(
24
- expr: RelationExpression | RelationWhereExpr
25
+ expr: RelationExpression | RelationWhereExpr,
25
26
  ): Predicate<T> {
26
27
  switch (true) {
27
28
  case isRelationWhere(expr): {
28
29
  const predicate = relationExpressionToPredicates(expr.where.expr)
29
30
  const where = whereOperatorAsPredicate(expr.where.condition)
30
- return e => predicate(e) && where(e)
31
+ return e =>
32
+ predicate(e) && where({
33
+ source: { tags: e.source.tags, kind: e.source.kind },
34
+ target: { tags: e.target.tags, kind: e.target.kind },
35
+ ...(e.tags && { tags: e.tags }),
36
+ ...(e.kind && { kind: e.kind }),
37
+ })
31
38
  }
32
39
  case isRelation(expr): {
33
40
  const isSource = elementExprToPredicate(expr.source)
package/src/index.ts CHANGED
@@ -1,3 +1,11 @@
1
+ import DefaultMap from 'mnemonist/default-map'
2
+ import LinkedList from 'mnemonist/linked-list'
3
+ import Queue from 'mnemonist/queue'
4
+ import Stack from 'mnemonist/stack'
5
+ // import dagre from '@dagrejs/dagre'
6
+
7
+ export { DefaultMap, LinkedList, Queue, Stack }
8
+
1
9
  export {
2
10
  computeColorValues,
3
11
  defaultTheme,
@@ -24,3 +32,11 @@ export * from './types'
24
32
  export * from './utils'
25
33
 
26
34
  export * as Expr from './types/expression'
35
+
36
+ export {
37
+ computeRelationshipsView,
38
+ computeView,
39
+ computeViews,
40
+ unsafeComputeView,
41
+ } from './compute-view'
42
+ export type { ComputeViewResult } from './compute-view'
@@ -11,6 +11,7 @@ import {
11
11
  type ElementShape as C4ElementShape,
12
12
  type IteratorLike,
13
13
  type Link,
14
+ type RelationshipKind,
14
15
  type Tag,
15
16
  type Tag as C4Tag,
16
17
  type ThemeColor,
@@ -62,6 +63,10 @@ abstract class AbstractDeploymentElementModel<M extends AnyAux = AnyAux> {
62
63
  return this.$node.tags ?? []
63
64
  }
64
65
 
66
+ get kind(): DeploymentNodeKind | string | null {
67
+ return this.$node.kind ?? null
68
+ }
69
+
65
70
  get description(): string | null {
66
71
  return this.$node.description ?? null
67
72
  }
@@ -223,7 +228,7 @@ export class DeploymentNodeModel<M extends AnyAux = AnyAux> extends AbstractDepl
223
228
  return this.$model.parent(this)
224
229
  }
225
230
 
226
- get kind(): DeploymentNodeKind {
231
+ override get kind(): DeploymentNodeKind {
227
232
  return this.$node.kind
228
233
  }
229
234
 
@@ -367,6 +372,10 @@ export class DeployedInstanceModel<M extends AnyAux = AnyAux> extends AbstractDe
367
372
  return this.$instance.tags ?? []
368
373
  }
369
374
 
375
+ override get kind(): string | null {
376
+ return this.$instance.kind ?? null
377
+ }
378
+
370
379
  override get description(): string | null {
371
380
  return this.$instance.description ?? this.element.description
372
381
  }
@@ -514,6 +523,10 @@ export class DeploymentRelationModel<M extends AnyAux = AnyAux> {
514
523
  return this.$relationship.tags ?? []
515
524
  }
516
525
 
526
+ get kind(): RelationshipKind | null {
527
+ return this.$relationship.kind ?? null
528
+ }
529
+
517
530
  get navigateTo(): LikeC4ViewModel<M> | null {
518
531
  return this.$relationship.navigateTo ? this.$model.$model.view(this.$relationship.navigateTo) : null
519
532
  }
@@ -530,6 +543,10 @@ export class DeploymentRelationModel<M extends AnyAux = AnyAux> {
530
543
  }
531
544
  return
532
545
  }
546
+
547
+ public isDeploymentRelation(): this is DeploymentRelationModel<M> {
548
+ return true
549
+ }
533
550
  }
534
551
 
535
552
  export class RelationshipsAccum<M extends AnyAux> {
@@ -3,6 +3,7 @@ import {
3
3
  type Element as C4Element,
4
4
  type ElementKind as C4ElementKind,
5
5
  type ElementShape as C4ElementShape,
6
+ type IconUrl,
6
7
  type IteratorLike,
7
8
  type Link,
8
9
  type Tag as C4Tag,
@@ -47,6 +48,10 @@ export class ElementModel<M extends AnyAux = AnyAux> {
47
48
  return this.$element.color as ThemeColor ?? DefaultThemeColor
48
49
  }
49
50
 
51
+ get icon(): IconUrl | null {
52
+ return this.$element.icon ?? null
53
+ }
54
+
50
55
  get tags(): ReadonlyArray<C4Tag> {
51
56
  return this.$element.tags ?? []
52
57
  }
@@ -71,6 +76,10 @@ export class ElementModel<M extends AnyAux = AnyAux> {
71
76
  return this.scopedViews().next().value ?? null
72
77
  }
73
78
 
79
+ get isRoot(): boolean {
80
+ return this.parent === null
81
+ }
82
+
74
83
  public isAncestorOf(another: ElementModel<M>): boolean {
75
84
  return isAncestor(this, another)
76
85
  }
@@ -219,3 +228,7 @@ export class ElementModel<M extends AnyAux = AnyAux> {
219
228
  return this.$model.deployment.instancesOf(this)
220
229
  }
221
230
  }
231
+
232
+ export function isElementModel<M extends AnyAux = AnyAux>(element: any): element is ElementModel<M> {
233
+ return element instanceof ElementModel
234
+ }
@@ -239,8 +239,8 @@ export class LikeC4Model<M extends AnyAux = LikeC4Model.Any> {
239
239
  public view(viewId: M['View']): LikeC4ViewModel<M> {
240
240
  return nonNullable(this.#views.get(viewId as any), `View ${viewId} not found`)
241
241
  }
242
- public findView(viewId: M['View']): LikeC4ViewModel<M> {
243
- return nonNullable(this.#views.get(viewId as M['ViewId']), `View ${viewId} not found`)
242
+ public findView(viewId: M['View']): LikeC4ViewModel<M> | null {
243
+ return this.#views.get(viewId as M['ViewId']) ?? null
244
244
  }
245
245
 
246
246
  /**
@@ -3,6 +3,7 @@ import type { IteratorLike } from '../types'
3
3
  import type { Link, Tag } from '../types/element'
4
4
  import type { ModelRelation } from '../types/relation'
5
5
  import { commonAncestor } from '../utils/fqn'
6
+ import type { DeploymentRelationModel } from './DeploymentElementModel'
6
7
  import type { ElementModel } from './ElementModel'
7
8
  import type { LikeC4Model } from './LikeC4Model'
8
9
  import type { AnyAux } from './types'
@@ -67,6 +68,10 @@ export class RelationshipModel<M extends AnyAux = AnyAux> {
67
68
  return this.$relationship.tags ?? []
68
69
  }
69
70
 
71
+ get kind(): string | null {
72
+ return this.$relationship.kind ?? null
73
+ }
74
+
70
75
  get links(): ReadonlyArray<Link> {
71
76
  return this.$relationship.links ?? []
72
77
  }
@@ -82,4 +87,8 @@ export class RelationshipModel<M extends AnyAux = AnyAux> {
82
87
  }
83
88
  return
84
89
  }
90
+
91
+ public isDeploymentRelation(): this is DeploymentRelationModel<M> {
92
+ return false
93
+ }
85
94
  }
@@ -110,11 +110,21 @@ export class DeploymentConnectionModel<M extends AnyAux = AnyAux>
110
110
  yield* this.relations.deployment
111
111
  }
112
112
 
113
+ /**
114
+ * Merge with another connections, if it has the same source and target.
115
+ * Returns new connection with union of relationships.
116
+ */
117
+ public mergeWith(others: DeploymentConnectionModel<M>[]): DeploymentConnectionModel<M>
113
118
  /**
114
119
  * Merge with another connection, if it has the same source and target.
115
120
  * Returns new connection with union of relationships.
116
121
  */
117
- public mergeWith(other: DeploymentConnectionModel<M>) {
122
+ public mergeWith(other: DeploymentConnectionModel<M>): DeploymentConnectionModel<M>
123
+ public mergeWith(other: DeploymentConnectionModel<M> | DeploymentConnectionModel<M>[]): DeploymentConnectionModel<M> {
124
+ if (Array.isArray(other)) {
125
+ return other.reduce((acc, o) => acc.mergeWith(o), this)
126
+ }
127
+
118
128
  invariant(this.source.id === other.source.id, 'Cannot merge connections with different sources')
119
129
  invariant(this.target.id === other.target.id, 'Cannot merge connections with different targets')
120
130
  return new DeploymentConnectionModel(
@@ -1,4 +1,10 @@
1
- import type { DeployedInstanceModel, DeploymentElementModel, DeploymentNodeModel } from './DeploymentElementModel'
1
+ import type {
2
+ DeploymentElementModel,
3
+ DeploymentRelationEndpoint,
4
+ NestedElementOfDeployedInstanceModel,
5
+ } from './DeploymentElementModel'
6
+ import { DeployedInstanceModel, DeploymentNodeModel } from './DeploymentElementModel'
7
+ import type { AnyAux } from './types'
2
8
 
3
9
  export function isDeploymentNode(model: DeploymentElementModel): model is DeploymentNodeModel {
4
10
  return model.isDeploymentNode()
@@ -7,3 +13,13 @@ export function isDeploymentNode(model: DeploymentElementModel): model is Deploy
7
13
  export function isDeployedInstance(model: DeploymentElementModel): model is DeployedInstanceModel {
8
14
  return model.isInstance()
9
15
  }
16
+
17
+ export function isNestedElementOfDeployedInstanceModel<M extends AnyAux = AnyAux>(
18
+ model: DeploymentRelationEndpoint<M>,
19
+ ): model is NestedElementOfDeployedInstanceModel<M> {
20
+ return !model.isInstance() && !model.isDeploymentNode()
21
+ }
22
+
23
+ export function isDeploymentElementModel(x: any): x is DeploymentElementModel {
24
+ return x instanceof DeploymentNodeModel || x instanceof DeployedInstanceModel
25
+ }
@@ -16,7 +16,7 @@ import {
16
16
  isElementView,
17
17
  isScopedElementView,
18
18
  } from '../../types/view'
19
- import { compareByFqnHierarchically, getOrCreate } from '../../utils'
19
+ import { compareByFqnHierarchically, getOrCreate, ifind } from '../../utils'
20
20
  import type { ElementModel } from '../ElementModel'
21
21
  import type { LikeC4Model } from '../LikeC4Model'
22
22
  import { type AnyAux, getId } from '../types'
@@ -138,6 +138,12 @@ export class LikeC4ViewModel<M extends AnyAux, V extends ComputedView | DiagramV
138
138
  public findNode(node: M['NodeOrId']): NodeModel<M, V> | null {
139
139
  return this.#nodes.get(getId(node) as C4NodeId) ?? null
140
140
  }
141
+
142
+ public findNodeWithElement(fqn: M['Element']): NodeModel.WithElement<M, V> | null {
143
+ const nd = ifind(this.#nodes.values(), node => node.element?.id === fqn) ?? null
144
+ return nd && nd.hasElement() ? nd : null
145
+ }
146
+
141
147
  /**
142
148
  * Iterate over all nodes.
143
149
  */
@@ -4,6 +4,7 @@ import {
4
4
  type DeploymentNodeKind,
5
5
  type DiagramView,
6
6
  type ElementShape as C4ElementShape,
7
+ type IconUrl,
7
8
  type IteratorLike,
8
9
  type Link,
9
10
  type NodeId,
@@ -97,6 +98,10 @@ export class NodeModel<M extends AnyAux, V extends ComputedView | DiagramView =
97
98
  return this.$node.color
98
99
  }
99
100
 
101
+ get icon(): IconUrl | null {
102
+ return this.$node.icon ?? null
103
+ }
104
+
100
105
  get tags(): ReadonlyArray<C4Tag> {
101
106
  return this.$node.tags ?? []
102
107
  }
@@ -42,6 +42,7 @@ export interface DeployedInstance {
42
42
  readonly title?: string
43
43
  readonly description?: string | null
44
44
  readonly technology?: string | null
45
+ readonly kind?: string | null // TODO: ensure the property is set
45
46
  readonly tags?: NonEmptyArray<Tag> | null
46
47
  readonly links?: NonEmptyArray<Link> | null
47
48
  readonly style?: DeploymentElementStyle