@life-cockpit/angular-ui-kit 2.15.0 → 2.16.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.
@@ -13581,7 +13581,7 @@ function layoutTreeH(measure, x, y, parentId, nodes, edges, allNodes, nodeMap) {
13581
13581
  path: `M ${sx} ${sy} C ${mx} ${sy}, ${mx} ${ty}, ${tx} ${ty}`,
13582
13582
  labelX: mx, labelY: (sy + ty) / 2,
13583
13583
  color: 'var(--color-neutral-300)', dashed: false, isCrossRef: false,
13584
- marker: 'depends',
13584
+ arrow: false, marker: 'depends',
13585
13585
  });
13586
13586
  }
13587
13587
  let childY = y;
@@ -13614,7 +13614,7 @@ function layoutTreeV(measure, x, y, parentId, nodes, edges, allNodes, nodeMap) {
13614
13614
  path: `M ${sx} ${sy} C ${sx} ${my}, ${tx} ${my}, ${tx} ${ty}`,
13615
13615
  labelX: (sx + tx) / 2, labelY: my,
13616
13616
  color: 'var(--color-neutral-300)', dashed: false, isCrossRef: false,
13617
- marker: 'depends',
13617
+ arrow: false, marker: 'depends',
13618
13618
  });
13619
13619
  }
13620
13620
  let childX = x;
@@ -13784,7 +13784,8 @@ function createCrossRefEdges(nodeMap, allOriginal, dir, backEdges) {
13784
13784
  path: geo.path, relation: dep.relation,
13785
13785
  label: dep.relationLabel ?? style.label,
13786
13786
  labelX: geo.labelX, labelY: geo.labelY,
13787
- color: style.color, dashed: style.dashed, isCrossRef: true, marker: relation,
13787
+ color: style.color, dashed: style.dashed, isCrossRef: true,
13788
+ arrow: true, marker: relation,
13788
13789
  });
13789
13790
  }
13790
13791
  }
@@ -13802,11 +13803,367 @@ function createCrossRefEdges(nodeMap, allOriginal, dir, backEdges) {
13802
13803
  edges.push({
13803
13804
  id: `${back.from}↺${back.to}`, sourceId: back.from, targetId: back.to,
13804
13805
  path: geo.path, labelX: geo.labelX, labelY: geo.labelY,
13805
- color: style.color, dashed: true, isCrossRef: true, marker: 'depends',
13806
+ color: style.color, dashed: true, isCrossRef: true,
13807
+ arrow: true, marker: 'depends',
13806
13808
  });
13807
13809
  }
13808
13810
  return edges;
13809
13811
  }
13812
+ const push = (map, key, value) => {
13813
+ const bucket = map.get(key);
13814
+ if (bucket)
13815
+ bucket.push(value);
13816
+ else
13817
+ map.set(key, [value]);
13818
+ };
13819
+ /**
13820
+ * Flattens the input into the node set and the precedence links the layered
13821
+ * layout works on.
13822
+ *
13823
+ * Both link kinds are precedence: `dependsOn` says "this comes after that", and a
13824
+ * `children` link says the same of a parent and its child. Layering over their
13825
+ * union is what keeps a `children` edge pointing *with* the flow instead of
13826
+ * doubling back across the graph, and means nothing in the input goes undrawn.
13827
+ * A pair joined both ways is one edge, styled by its `dependsOn` relation.
13828
+ *
13829
+ * A self-link is dropped: it constrains a node to sit after itself, which no
13830
+ * layering can satisfy, and it has nowhere to be drawn.
13831
+ */
13832
+ function collectGraph(roots) {
13833
+ const nodes = new Map();
13834
+ const order = [];
13835
+ const edges = new Map();
13836
+ const walk = (node) => {
13837
+ if (!nodes.has(node.id)) {
13838
+ nodes.set(node.id, node);
13839
+ order.push(node.id);
13840
+ }
13841
+ for (const child of node.children || []) {
13842
+ const fresh = !nodes.has(child.id);
13843
+ if (node.id !== child.id) {
13844
+ edges.set(`${node.id}→${child.id}`, {
13845
+ from: node.id, to: child.id, structural: true, back: false,
13846
+ });
13847
+ }
13848
+ // Guards the recursion: a cycle or a second parent revisits a node that is
13849
+ // already collected, and there is nothing left to collect from it.
13850
+ if (fresh)
13851
+ walk(child);
13852
+ }
13853
+ };
13854
+ for (const root of roots)
13855
+ walk(root);
13856
+ for (const id of order) {
13857
+ for (const dep of nodes.get(id)?.dependsOn || []) {
13858
+ if (dep.id === id || !nodes.has(dep.id))
13859
+ continue;
13860
+ const style = RELATION_STYLES[resolveRelation(dep.relation)];
13861
+ edges.set(`${dep.id}→${id}`, {
13862
+ from: dep.id, to: id, relation: dep.relation,
13863
+ label: dep.relationLabel ?? style.label,
13864
+ structural: false, back: false,
13865
+ });
13866
+ }
13867
+ }
13868
+ return { order, nodes, edges: [...edges.values()] };
13869
+ }
13870
+ /**
13871
+ * Longest-path layering: a node sits one layer past the deepest thing it depends
13872
+ * on, so every forward edge points strictly onwards and a node with several
13873
+ * predecessors clears *all* of them. Nodes with no predecessor land in layer 0.
13874
+ *
13875
+ * A cycle can't be layered — some link in the loop has to point backwards. A DFS
13876
+ * picks those out (a link to a node still on the stack) and marks them `back`;
13877
+ * the layering then runs on what's left, which is acyclic by construction. So a
13878
+ * cyclic input lays out fine, with the loop-closing links drawn as return edges.
13879
+ */
13880
+ function assignLayers(order, edges) {
13881
+ const out = new Map();
13882
+ for (const edge of edges)
13883
+ push(out, edge.from, edge);
13884
+ // Iterative DFS: a recursive one overflows the stack on a long chain, and a
13885
+ // process-order graph is mostly chain.
13886
+ const UNSEEN = 0, ON_STACK = 1, DONE = 2;
13887
+ const state = new Map();
13888
+ for (const start of order) {
13889
+ if (state.get(start))
13890
+ continue;
13891
+ state.set(start, ON_STACK);
13892
+ const stack = [{ id: start, next: 0 }];
13893
+ while (stack.length) {
13894
+ const frame = stack[stack.length - 1];
13895
+ const outgoing = out.get(frame.id) ?? [];
13896
+ if (frame.next >= outgoing.length) {
13897
+ state.set(frame.id, DONE);
13898
+ stack.pop();
13899
+ continue;
13900
+ }
13901
+ const edge = outgoing[frame.next++];
13902
+ const seen = state.get(edge.to) ?? UNSEEN;
13903
+ if (seen === ON_STACK)
13904
+ edge.back = true; // closes a loop
13905
+ if (seen !== UNSEEN)
13906
+ continue;
13907
+ state.set(edge.to, ON_STACK);
13908
+ stack.push({ id: edge.to, next: 0 });
13909
+ }
13910
+ }
13911
+ // Longest path over what's left, walked in topological order.
13912
+ const indegree = new Map(order.map(id => [id, 0]));
13913
+ for (const edge of edges) {
13914
+ if (!edge.back)
13915
+ indegree.set(edge.to, (indegree.get(edge.to) ?? 0) + 1);
13916
+ }
13917
+ const layer = new Map(order.map(id => [id, 0]));
13918
+ const queue = order.filter(id => !indegree.get(id));
13919
+ for (let i = 0; i < queue.length; i++) {
13920
+ for (const edge of out.get(queue[i]) ?? []) {
13921
+ if (edge.back)
13922
+ continue;
13923
+ layer.set(edge.to, Math.max(layer.get(edge.to) ?? 0, (layer.get(queue[i]) ?? 0) + 1));
13924
+ const remaining = (indegree.get(edge.to) ?? 0) - 1;
13925
+ indegree.set(edge.to, remaining);
13926
+ if (!remaining)
13927
+ queue.push(edge.to);
13928
+ }
13929
+ }
13930
+ return layer;
13931
+ }
13932
+ /** Median of the neighbour positions, or -1 when a node has no neighbours to follow. */
13933
+ function median(values) {
13934
+ if (!values.length)
13935
+ return -1;
13936
+ const sorted = [...values].sort((a, b) => a - b);
13937
+ const mid = sorted.length >> 1;
13938
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
13939
+ }
13940
+ /** Row index of every node within its own layer. */
13941
+ function positionIndex(layers) {
13942
+ const index = new Map();
13943
+ for (const layer of layers)
13944
+ layer.forEach((id, i) => index.set(id, i));
13945
+ return index;
13946
+ }
13947
+ /**
13948
+ * Edges cross when their endpoints are ordered oppositely in the two layers they
13949
+ * span. Counted per layer *pair* rather than per adjacent layer, so an edge
13950
+ * skipping a layer is scored too — this layout has no dummy nodes, and in a
13951
+ * dependency graph the long edges are exactly the ones that tangle.
13952
+ */
13953
+ function countCrossings(layers, edges, layerOf) {
13954
+ const index = positionIndex(layers);
13955
+ const groups = new Map();
13956
+ for (const edge of edges) {
13957
+ if (edge.back)
13958
+ continue;
13959
+ const from = index.get(edge.from);
13960
+ const to = index.get(edge.to);
13961
+ if (from === undefined || to === undefined)
13962
+ continue;
13963
+ push(groups, `${layerOf.get(edge.from)}:${layerOf.get(edge.to)}`, [from, to]);
13964
+ }
13965
+ let crossings = 0;
13966
+ for (const pairs of groups.values()) {
13967
+ for (let i = 0; i < pairs.length; i++) {
13968
+ for (let j = i + 1; j < pairs.length; j++) {
13969
+ if ((pairs[i][0] - pairs[j][0]) * (pairs[i][1] - pairs[j][1]) < 0)
13970
+ crossings++;
13971
+ }
13972
+ }
13973
+ }
13974
+ return crossings;
13975
+ }
13976
+ /** Sweeps to run over the layers; each one reorders against the previous result. */
13977
+ const ORDER_SWEEPS = 8;
13978
+ /**
13979
+ * Crossing minimisation, median heuristic (Sugiyama's second phase): a node wants
13980
+ * to sit level with the middle of its neighbours, and ordering every layer that
13981
+ * way repeatedly untangles the edges between them.
13982
+ *
13983
+ * Sweeps alternate direction — downward passes order a layer by its predecessors,
13984
+ * upward passes by its successors — because settling one side alone just moves the
13985
+ * tangle to the other. The heuristic isn't monotonic, so the best ordering seen is
13986
+ * kept rather than the last one.
13987
+ */
13988
+ function orderLayers(layers, edges, layerOf) {
13989
+ const predecessors = new Map();
13990
+ const successors = new Map();
13991
+ for (const edge of edges) {
13992
+ if (edge.back)
13993
+ continue;
13994
+ push(predecessors, edge.to, edge.from);
13995
+ push(successors, edge.from, edge.to);
13996
+ }
13997
+ const current = layers.map(layer => [...layer]);
13998
+ let best = current.map(layer => [...layer]);
13999
+ let bestCrossings = countCrossings(current, edges, layerOf);
14000
+ for (let sweep = 0; sweep < ORDER_SWEEPS && bestCrossings > 0; sweep++) {
14001
+ const downward = sweep % 2 === 0;
14002
+ const reference = downward ? predecessors : successors;
14003
+ const index = positionIndex(current);
14004
+ // The first layer of a downward sweep (and the last of an upward one) has no
14005
+ // reference layer, so it stays as it is and anchors the sweep.
14006
+ const targets = current.map((_, i) => i);
14007
+ for (const i of downward ? targets.slice(1) : targets.slice(0, -1).reverse()) {
14008
+ current[i] = [...current[i]]
14009
+ .map((id, row) => {
14010
+ const neighbours = (reference.get(id) ?? [])
14011
+ .map(n => index.get(n))
14012
+ .filter((p) => p !== undefined);
14013
+ const m = median(neighbours);
14014
+ // No neighbours to follow: hold the current row, so a free node doesn't
14015
+ // get swept to the top of the layer on every pass.
14016
+ return { id, row, key: m < 0 ? row : m };
14017
+ })
14018
+ .sort((a, b) => a.key - b.key || a.row - b.row)
14019
+ .map(entry => entry.id);
14020
+ }
14021
+ const crossings = countCrossings(current, edges, layerOf);
14022
+ if (crossings < bestCrossings) {
14023
+ bestCrossings = crossings;
14024
+ best = current.map(layer => [...layer]);
14025
+ }
14026
+ }
14027
+ return best;
14028
+ }
14029
+ /** Alignment passes run after ordering, to straighten the edges it left behind. */
14030
+ const ALIGN_PASSES = 4;
14031
+ /**
14032
+ * Secondary-axis coordinates: with the row *order* fixed, slide each node towards
14033
+ * the middle of its neighbours so chains run straight instead of stair-stepping.
14034
+ *
14035
+ * Order and spacing are invariants here — the ordering phase chose them, and this
14036
+ * must not undo its work. So each layer is placed by a forward cascade that can
14037
+ * only push nodes further along, then shifted back by the average push: a uniform
14038
+ * shift keeps every gap and every relative position, and cancels the drift the
14039
+ * cascade would otherwise accumulate down the layer.
14040
+ */
14041
+ function alignSecondary(layers, edges, step) {
14042
+ const predecessors = new Map();
14043
+ const successors = new Map();
14044
+ for (const edge of edges) {
14045
+ if (edge.back)
14046
+ continue;
14047
+ push(predecessors, edge.to, edge.from);
14048
+ push(successors, edge.from, edge.to);
14049
+ }
14050
+ const pos = new Map();
14051
+ for (const layer of layers)
14052
+ layer.forEach((id, i) => pos.set(id, i * step));
14053
+ for (let pass = 0; pass < ALIGN_PASSES; pass++) {
14054
+ const downward = pass % 2 === 0;
14055
+ const reference = downward ? predecessors : successors;
14056
+ const indices = layers.map((_, i) => i);
14057
+ for (const i of downward ? indices.slice(1) : indices.slice(0, -1).reverse()) {
14058
+ const layer = layers[i];
14059
+ if (!layer.length)
14060
+ continue;
14061
+ const wanted = layer.map(id => {
14062
+ const neighbours = (reference.get(id) ?? [])
14063
+ .map(n => pos.get(n))
14064
+ .filter((p) => p !== undefined);
14065
+ return neighbours.length ? median(neighbours) : (pos.get(id) ?? 0);
14066
+ });
14067
+ const placed = [];
14068
+ for (let k = 0; k < layer.length; k++) {
14069
+ placed[k] = k === 0 ? wanted[k] : Math.max(wanted[k], placed[k - 1] + step);
14070
+ }
14071
+ const drift = placed.reduce((sum, p, k) => sum + (p - wanted[k]), 0) / placed.length;
14072
+ layer.forEach((id, k) => pos.set(id, placed[k] - drift));
14073
+ }
14074
+ }
14075
+ // Normalise so the graph starts at the origin, like the tree layout does.
14076
+ const min = Math.min(...pos.values());
14077
+ for (const [id, value] of pos)
14078
+ pos.set(id, value - min);
14079
+ return pos;
14080
+ }
14081
+ /**
14082
+ * Places the nodes of a dependency graph in layers running left→right
14083
+ * (`horizontal`) or top→bottom (`vertical`).
14084
+ *
14085
+ * Unlike the tree layout, the column a node lands in is decided by the *graph*, not
14086
+ * by the shape of the `children` nesting it happened to arrive in: layer = longest
14087
+ * dependency path to it. Every link is drawn, including the ones a tree layout has
14088
+ * to discard to keep a single parent per node.
14089
+ */
14090
+ function layoutLayered(roots, dir, allNodes) {
14091
+ const { order, nodes: originals, edges } = collectGraph(roots);
14092
+ if (!order.length) {
14093
+ return { nodes: [], edges: [], nodeMap: new Map(), width: 0, height: 0 };
14094
+ }
14095
+ const layerOf = assignLayers(order, edges);
14096
+ const depth = Math.max(...layerOf.values());
14097
+ const layers = Array.from({ length: depth + 1 }, () => []);
14098
+ for (const id of order)
14099
+ layers[layerOf.get(id) ?? 0].push(id);
14100
+ const ordered = orderLayers(layers, edges, layerOf);
14101
+ const primarySize = dir === 'horizontal' ? NODE_WIDTH : NODE_HEIGHT;
14102
+ const secondarySize = dir === 'horizontal' ? NODE_HEIGHT : NODE_WIDTH;
14103
+ const secondary = alignSecondary(ordered, edges, secondarySize + V_GAP);
14104
+ const nodes = [];
14105
+ const nodeMap = new Map();
14106
+ for (const layer of ordered) {
14107
+ for (const id of layer) {
14108
+ const source = originals.get(id);
14109
+ if (!source)
14110
+ continue;
14111
+ const original = allNodes.get(id);
14112
+ const primary = (layerOf.get(id) ?? 0) * (primarySize + H_GAP);
14113
+ const across = secondary.get(id) ?? 0;
14114
+ const laid = {
14115
+ id, label: source.label, description: source.description, icon: source.icon,
14116
+ status: source.status || 'default', type: source.type, moreCount: source.moreCount,
14117
+ x: dir === 'horizontal' ? primary : across,
14118
+ y: dir === 'horizontal' ? across : primary,
14119
+ width: NODE_WIDTH, height: NODE_HEIGHT,
14120
+ depth: layerOf.get(id) ?? 0,
14121
+ // Layers come from the whole graph, not from one parent — there is no
14122
+ // single node this one hangs off, so nothing to report as its parent.
14123
+ parentId: null,
14124
+ incomingCount: original?.dependsOn?.length ?? 0,
14125
+ outgoingCount: original?.children?.length ?? 0,
14126
+ };
14127
+ nodes.push(laid);
14128
+ nodeMap.set(id, laid);
14129
+ }
14130
+ }
14131
+ const laidOut = [...nodeMap.values()];
14132
+ const layoutEdges = [];
14133
+ for (const edge of edges) {
14134
+ const source = nodeMap.get(edge.from);
14135
+ const target = nodeMap.get(edge.to);
14136
+ if (!source || !target)
14137
+ continue;
14138
+ const relation = resolveRelation(edge.relation);
14139
+ const style = RELATION_STYLES[relation];
14140
+ // A back edge runs against the flow by definition, so the direct route would
14141
+ // double back through its own layers — it always bows clear instead.
14142
+ const geo = routeCrossRef(source, target, dir, laidOut, /* allowDirect */ !edge.back);
14143
+ layoutEdges.push({
14144
+ id: `${edge.from}${edge.back ? '↺' : '⇢'}${edge.to}`,
14145
+ sourceId: edge.from, targetId: edge.to,
14146
+ path: geo.path, relation: edge.relation, label: edge.label,
14147
+ labelX: geo.labelX, labelY: geo.labelY,
14148
+ color: edge.structural ? 'var(--color-neutral-400)' : style.color,
14149
+ dashed: edge.back || (!edge.structural && style.dashed),
14150
+ // Everything here is a real dependency, so nothing is dimmed as secondary
14151
+ // except the return edges — which the layering had to break to lay out.
14152
+ isCrossRef: edge.back,
14153
+ arrow: true,
14154
+ marker: relation,
14155
+ });
14156
+ }
14157
+ const width = (depth + 1) * primarySize + depth * H_GAP;
14158
+ const across = Math.max(...laidOut.map(n => (dir === 'horizontal' ? n.y : n.x))) + secondarySize;
14159
+ return {
14160
+ nodes,
14161
+ edges: layoutEdges,
14162
+ nodeMap,
14163
+ width: dir === 'horizontal' ? width : across,
14164
+ height: dir === 'horizontal' ? across : width,
14165
+ };
14166
+ }
13810
14167
  // ── Status colors ────────────────────────────────────────────────────────────
13811
14168
  // Labels read from the theme's semantic ink, not from a `--color-<status>-700`.
13812
14169
  // Those resolve to dark ink in both themes (error-700 is #6b0909), which on the
@@ -13861,6 +14218,8 @@ function readableInk(color) {
13861
14218
  * Dependency viewer component for visualizing hierarchical and cross-cutting relationships.
13862
14219
  *
13863
14220
  * Features:
14221
+ * - Tree layout (depth in the `children` nesting) and layered/DAG layout (depth in
14222
+ * the dependency graph), via `layout`
13864
14223
  * - Horizontal (left-to-right) and vertical (top-to-bottom) layout directions
13865
14224
  * - Bidirectional dependencies: children (right/down) and dependsOn (cross-references)
13866
14225
  * - Relationship types: depends, blocks, references, requires, extends, implements, uses
@@ -13869,7 +14228,8 @@ function readableInk(color) {
13869
14228
  * - Color-coded edges per relationship type
13870
14229
  * - Node status colors (default, active, success, warning, error, muted)
13871
14230
  * - Pan and zoom controls with mouse wheel support
13872
- * - Auto-fit (`autoFit`) and a static, non-navigable mode (`interactive: false`)
14231
+ * - Fitting (`fitMode` / `autoFit`, `minNodeSize`) and a static, non-navigable
14232
+ * mode (`interactive: false`)
13873
14233
  * - Collapsible sub-trees
13874
14234
  * - Interactive node selection with detail panel
13875
14235
  * - Legend showing active relationship types
@@ -13878,11 +14238,24 @@ function readableInk(color) {
13878
14238
  *
13879
14239
  * ## Feeding it a graph
13880
14240
  *
13881
- * `root` is a *tree*, but the input is treated as a graph: a link back to a node
13882
- * that already has a place in the layout a cycle, or a node reached from a
13883
- * second parent is drawn as a cross-reference arrow instead of being followed.
13884
- * Every node is laid out exactly once, and no input can make the layout recurse
13885
- * forever.
14241
+ * `root` takes a single node (a tree) or an array (a forest the shape to use for
14242
+ * a flat set of items related only by `dependsOn`). Either way the input is treated
14243
+ * as a graph: a link back to a node that already has a place in the layout — a
14244
+ * cycle, or a node reached from a second parent is drawn as a cross-reference
14245
+ * arrow instead of being followed. Every node is laid out exactly once, and no
14246
+ * input can make the layout recurse forever.
14247
+ *
14248
+ * ## Picking a layout
14249
+ *
14250
+ * `tree` (default) answers "what contains what": the column is the depth in the
14251
+ * `children` nesting, and `dependsOn` links are drawn over the top without moving
14252
+ * anything.
14253
+ *
14254
+ * `layered` answers "what has to happen first": the column is the depth in the
14255
+ * dependency graph, so a node always sits past everything it depends on. Reach for
14256
+ * it whenever the question is about order or impact rather than containment —
14257
+ * a tree layout can only approximate that by first reducing the graph to a
14258
+ * spanning tree, which throws away every link that doesn't fit one parent per node.
13886
14259
  *
13887
14260
  * For incremental exploration, hand in a wider `root` per step and let
13888
14261
  * `nodeExpand` drive the loading. Pan, zoom, collapse state and selection all
@@ -13894,6 +14267,17 @@ function readableInk(color) {
13894
14267
  * <lc-dependency-viewer [root]="specTree" direction="horizontal" />
13895
14268
  * ```
13896
14269
  *
14270
+ * @example Order of work — a flat set of items, laid out by their dependencies
14271
+ * ```html
14272
+ * <lc-dependency-viewer
14273
+ * [root]="items()"
14274
+ * layout="layered"
14275
+ * direction="horizontal"
14276
+ * fitMode="fit-width"
14277
+ * (nodeSelect)="openItem($event)"
14278
+ * />
14279
+ * ```
14280
+ *
13897
14281
  * @example Static overview — the whole graph at a glance, click-through only
13898
14282
  * ```html
13899
14283
  * <lc-dependency-viewer
@@ -13917,9 +14301,28 @@ function readableInk(color) {
13917
14301
  * ```
13918
14302
  */
13919
14303
  class DependencyViewerComponent {
14304
+ /**
14305
+ * The graph to draw. A single node is the root of a tree; an array is a forest,
14306
+ * which is how you feed a flat set of items that has no natural root — e.g. the
14307
+ * specs of a plan, related only by `dependsOn`.
14308
+ */
13920
14309
  root = input.required(...(ngDevMode ? [{ debugName: "root" }] : /* istanbul ignore next */ []));
13921
14310
  direction = input('horizontal', ...(ngDevMode ? [{ debugName: "direction" }] : /* istanbul ignore next */ []));
13922
14311
  height = input('500px', ...(ngDevMode ? [{ debugName: "height" }] : /* istanbul ignore next */ []));
14312
+ /**
14313
+ * `tree` (default) places nodes by their depth in the `children` nesting, and
14314
+ * draws `dependsOn` links over the top as cross-references.
14315
+ *
14316
+ * `layered` places them by their depth in the *dependency* graph: a node always
14317
+ * sits past everything it depends on, transitively, and nodes that depend on
14318
+ * nothing start the first layer. That makes `direction="horizontal"` read as an
14319
+ * order of work — left to right — which a tree layout can only approximate by
14320
+ * first throwing away every link that doesn't fit a single-parent hierarchy.
14321
+ *
14322
+ * Use `layered` for process, sequencing and impact views; `tree` for containment
14323
+ * hierarchies, where the nesting *is* the structure.
14324
+ */
14325
+ layout = input('tree', ...(ngDevMode ? [{ debugName: "layout" }] : /* istanbul ignore next */ []));
13923
14326
  showToolbar = input(true, ...(ngDevMode ? [{ debugName: "showToolbar" }] : /* istanbul ignore next */ []));
13924
14327
  showEdgeLabels = input(true, ...(ngDevMode ? [{ debugName: "showEdgeLabels" }] : /* istanbul ignore next */ []));
13925
14328
  edgeWidth = input(1.5, ...(ngDevMode ? [{ debugName: "edgeWidth" }] : /* istanbul ignore next */ []));
@@ -13932,8 +14335,29 @@ class DependencyViewerComponent {
13932
14335
  * natural size instead of ballooning to fill the frame.
13933
14336
  *
13934
14337
  * Pair with `interactive: false` for a purely static display.
14338
+ *
14339
+ * Shorthand for `fitMode="contain"`, which supersedes it.
13935
14340
  */
13936
14341
  autoFit = input(false, ...(ngDevMode ? [{ debugName: "autoFit" }] : /* istanbul ignore next */ []));
14342
+ /**
14343
+ * How the graph is scaled into the frame. Defaults to `autoFit`'s setting —
14344
+ * `contain` when it is on, `none` when it isn't — and overrides it when set.
14345
+ *
14346
+ * `contain` fits both axes, so everything is visible at once but a big graph
14347
+ * shrinks to get there. `fit-width` fits the width only and lets the viewer grow
14348
+ * as tall as the graph needs, so the nodes keep a readable size and the *page*
14349
+ * scrolls; the graph itself never scrolls internally. Past roughly 20 nodes a
14350
+ * process view generally wants `fit-width`.
14351
+ */
14352
+ fitMode = input(null, ...(ngDevMode ? [{ debugName: "fitMode" }] : /* istanbul ignore next */ []));
14353
+ /**
14354
+ * Smallest node width, in px, that fitting may shrink to. Below it the fit stops
14355
+ * scaling down and lets the graph overflow instead — which `fit-width` turns into
14356
+ * page scroll, and `contain` into clipping. Pair it with `fit-width`.
14357
+ *
14358
+ * Nodes are 160px wide unscaled, so e.g. `80` allows shrinking to half size.
14359
+ */
14360
+ minNodeSize = input(null, ...(ngDevMode ? [{ debugName: "minNodeSize" }] : /* istanbul ignore next */ []));
13937
14361
  /**
13938
14362
  * Viewport navigation: drag-to-pan, wheel-zoom and the toolbar's zoom buttons.
13939
14363
  * `false` freezes the viewport — node selection, expansion and collapse keep
@@ -13968,37 +14392,42 @@ class DependencyViewerComponent {
13968
14392
  canvasRef = viewChild('canvas', ...(ngDevMode ? [{ debugName: "canvasRef" }] : /* istanbul ignore next */ []));
13969
14393
  /** Last known layout position of the anchor, to compensate pan after a relayout. */
13970
14394
  anchorPos = null;
13971
- effectiveRoot = computed(() => this.pruneCollapsed(this.root(), this.collapsedIds(), new Set()), ...(ngDevMode ? [{ debugName: "effectiveRoot" }] : /* istanbul ignore next */ []));
14395
+ roots = computed(() => {
14396
+ const root = this.root();
14397
+ return Array.isArray(root) ? root : [root];
14398
+ }, ...(ngDevMode ? [{ debugName: "roots" }] : /* istanbul ignore next */ []));
14399
+ effectiveRoots = computed(() => {
14400
+ const collapsed = this.collapsedIds();
14401
+ // One `visited` set across the whole forest: a node reachable from two roots
14402
+ // is the same node, and must be pruned identically wherever it turns up.
14403
+ const visited = new Set();
14404
+ return this.roots().map(root => this.pruneCollapsed(root, collapsed, visited));
14405
+ }, ...(ngDevMode ? [{ debugName: "effectiveRoots" }] : /* istanbul ignore next */ []));
13972
14406
  allOriginalNodes = computed(() => {
13973
14407
  const map = new Map();
13974
- collectAllNodes(this.root(), map);
14408
+ for (const root of this.roots())
14409
+ collectAllNodes(root, map);
13975
14410
  return map;
13976
14411
  }, ...(ngDevMode ? [{ debugName: "allOriginalNodes" }] : /* istanbul ignore next */ []));
13977
- layout = computed(() => {
13978
- const root = this.effectiveRoot();
14412
+ /**
14413
+ * The laid-out graph. Named `graph` rather than `layout` because `layout` is the
14414
+ * input that picks *how* it gets laid out.
14415
+ */
14416
+ graph = computed(() => {
13979
14417
  const dir = this.direction();
13980
14418
  const hiddenTypes = new Set(this.hiddenTypes());
13981
14419
  const hiddenRelations = new Set(this.hiddenRelations());
13982
- const backEdges = [];
13983
- const measured = measureTree(root, 0, dir, new Set(), backEdges);
13984
- const nodes = [];
13985
- const nodeMap = new Map();
13986
- const treeEdges = [];
13987
14420
  const allNodes = this.allOriginalNodes();
13988
- if (dir === 'horizontal') {
13989
- layoutTreeH(measured, 0, 0, null, nodes, treeEdges, allNodes, nodeMap);
13990
- }
13991
- else {
13992
- layoutTreeV(measured, 0, 0, null, nodes, treeEdges, allNodes, nodeMap);
13993
- }
13994
- const crossEdges = createCrossRefEdges(nodeMap, allNodes, dir, backEdges);
13995
- const edges = [...treeEdges, ...crossEdges].filter(e => !(e.relation && hiddenRelations.has(e.relation)));
14421
+ const placed = this.layout() === 'layered'
14422
+ ? layoutLayered(this.effectiveRoots(), dir, allNodes)
14423
+ : this.layoutTree(dir, allNodes);
14424
+ const edges = placed.edges.filter(e => !(e.relation && hiddenRelations.has(e.relation)));
13996
14425
  // Type filtering hides nodes and anything still pointing at them. It runs
13997
14426
  // after layout so the remaining nodes keep the positions they'd have anyway —
13998
14427
  // toggling a filter must not reshuffle the whole graph.
13999
14428
  const visibleNodes = hiddenTypes.size
14000
- ? nodes.filter(n => !(n.type && hiddenTypes.has(n.type)))
14001
- : nodes;
14429
+ ? placed.nodes.filter(n => !(n.type && hiddenTypes.has(n.type)))
14430
+ : placed.nodes;
14002
14431
  const visibleIds = new Set(visibleNodes.map(n => n.id));
14003
14432
  const visibleEdges = hiddenTypes.size
14004
14433
  ? edges.filter(e => visibleIds.has(e.sourceId) && visibleIds.has(e.targetId))
@@ -14006,11 +14435,66 @@ class DependencyViewerComponent {
14006
14435
  return {
14007
14436
  nodes: visibleNodes,
14008
14437
  edges: visibleEdges,
14438
+ nodeMap: placed.nodeMap,
14439
+ width: placed.width,
14440
+ height: placed.height,
14441
+ };
14442
+ }, ...(ngDevMode ? [{ debugName: "graph" }] : /* istanbul ignore next */ []));
14443
+ /**
14444
+ * Tree layout over the forest: each root gets its own band along the secondary
14445
+ * axis. A `visited` set shared by all of them keeps a node that hangs off two
14446
+ * roots from being laid out twice — the second link becomes a cross-reference,
14447
+ * exactly as a second parent within one tree does.
14448
+ */
14449
+ layoutTree(dir, allNodes) {
14450
+ const backEdges = [];
14451
+ const visited = new Set();
14452
+ const measured = this.effectiveRoots().map(root => measureTree(root, 0, dir, visited, backEdges));
14453
+ const nodes = [];
14454
+ const nodeMap = new Map();
14455
+ const treeEdges = [];
14456
+ let offset = 0;
14457
+ for (const measure of measured) {
14458
+ if (dir === 'horizontal') {
14459
+ layoutTreeH(measure, 0, offset, null, nodes, treeEdges, allNodes, nodeMap);
14460
+ }
14461
+ else {
14462
+ layoutTreeV(measure, offset, 0, null, nodes, treeEdges, allNodes, nodeMap);
14463
+ }
14464
+ offset += measure.secondary + V_GAP;
14465
+ }
14466
+ const primary = measured.length ? Math.max(...measured.map(m => m.primary)) : 0;
14467
+ const secondary = Math.max(0, offset - V_GAP);
14468
+ return {
14469
+ nodes,
14470
+ edges: [...treeEdges, ...createCrossRefEdges(nodeMap, allNodes, dir, backEdges)],
14009
14471
  nodeMap,
14010
- width: dir === 'horizontal' ? measured.primary : measured.secondary,
14011
- height: dir === 'horizontal' ? measured.secondary : measured.primary,
14472
+ width: dir === 'horizontal' ? primary : secondary,
14473
+ height: dir === 'horizontal' ? secondary : primary,
14012
14474
  };
14013
- }, ...(ngDevMode ? [{ debugName: "layout" }] : /* istanbul ignore next */ []));
14475
+ }
14476
+ /**
14477
+ * Height `fit-width` derived from the graph, or `null` under any other mode.
14478
+ *
14479
+ * `fit-width` scales to the frame's width and lets the height follow the graph,
14480
+ * so a tall graph runs past the frame and the *page* scrolls instead of the nodes
14481
+ * shrinking out of legibility. The height is therefore always the graph's, never
14482
+ * the frame's — including when the graph is the shorter of the two. Deriving it
14483
+ * unconditionally is also what keeps the fit stable: this height becomes the
14484
+ * canvas's, so a fit that consulted the canvas height would be reading back its
14485
+ * own last output and could flip between two answers forever.
14486
+ */
14487
+ contentHeight = signal(null, ...(ngDevMode ? [{ debugName: "contentHeight" }] : /* istanbul ignore next */ []));
14488
+ effectiveFitMode = computed(() => this.fitMode() ?? (this.autoFit() ? 'contain' : 'none'), ...(ngDevMode ? [{ debugName: "effectiveFitMode" }] : /* istanbul ignore next */ []));
14489
+ /** The host only gives up its configured height when the fit made it grow. */
14490
+ hostHeight = computed(() => (this.contentHeight() === null ? this.height() : 'auto'), ...(ngDevMode ? [{ debugName: "hostHeight" }] : /* istanbul ignore next */ []));
14491
+ /** `minNodeSize` expressed as the scale it forbids fitting to go below. */
14492
+ minFitScale = computed(() => {
14493
+ const min = this.minNodeSize();
14494
+ if (!min || min <= 0)
14495
+ return MIN_FIT_SCALE;
14496
+ return Math.min(MAX_FIT_SCALE, Math.max(MIN_FIT_SCALE, min / NODE_WIDTH));
14497
+ }, ...(ngDevMode ? [{ debugName: "minFitScale" }] : /* istanbul ignore next */ []));
14014
14498
  constructor() {
14015
14499
  // Keep the anchor node pinned to its screen position across relayouts. A new
14016
14500
  // `root` (an expand step) re-runs the tree layout from scratch, which recentres
@@ -14018,11 +14502,11 @@ class DependencyViewerComponent {
14018
14502
  // graph slides underneath it. Compensating the pan by the anchor's own delta is
14019
14503
  // what makes incremental expansion feel stable.
14020
14504
  effect(() => {
14021
- const nodeMap = this.layout().nodeMap;
14505
+ const nodeMap = this.graph().nodeMap;
14022
14506
  const anchorId = this.anchorNodeId() ?? this.selectedNodeId();
14023
- // Auto-fit recentres the whole graph on every relayout, which subsumes
14507
+ // Fitting recentres the whole graph on every relayout, which subsumes
14024
14508
  // holding one node still. Running both would have them fight over the pan.
14025
- if (this.autoFit() || !anchorId) {
14509
+ if (this.effectiveFitMode() !== 'none' || !anchorId) {
14026
14510
  this.anchorPos = null;
14027
14511
  return;
14028
14512
  }
@@ -14050,24 +14534,31 @@ class DependencyViewerComponent {
14050
14534
  // resolves after the initial render, which re-triggers this effect at the
14051
14535
  // point the canvas finally has a measurable size.
14052
14536
  effect(() => {
14053
- if (!this.autoFit() || !this.canvasRef())
14537
+ if (this.effectiveFitMode() === 'none') {
14538
+ // Leaving fit-width behind: the grown height was this fit's doing, so it
14539
+ // goes with it, or the host keeps a size nothing is maintaining any more.
14540
+ untracked(() => this.contentHeight.set(null));
14541
+ return;
14542
+ }
14543
+ if (!this.canvasRef())
14054
14544
  return;
14055
- this.layout();
14545
+ this.graph();
14546
+ this.minFitScale();
14056
14547
  untracked(() => this.fit());
14057
14548
  });
14058
14549
  // …and when the container changes size. ResizeObserver delivers an initial
14059
14550
  // callback on observe, so this covers the first fit in a browser too.
14060
14551
  effect(onCleanup => {
14061
14552
  const el = this.canvasRef()?.nativeElement;
14062
- if (!this.autoFit() || !el || typeof ResizeObserver === 'undefined')
14553
+ if (this.effectiveFitMode() === 'none' || !el || typeof ResizeObserver === 'undefined')
14063
14554
  return;
14064
14555
  const observer = new ResizeObserver(() => this.fit());
14065
14556
  observer.observe(el);
14066
14557
  onCleanup(() => observer.disconnect());
14067
14558
  });
14068
14559
  }
14069
- svgWidth = computed(() => this.layout().width + 80, ...(ngDevMode ? [{ debugName: "svgWidth" }] : /* istanbul ignore next */ []));
14070
- svgHeight = computed(() => this.layout().height + 80, ...(ngDevMode ? [{ debugName: "svgHeight" }] : /* istanbul ignore next */ []));
14560
+ svgWidth = computed(() => this.graph().width + 80, ...(ngDevMode ? [{ debugName: "svgWidth" }] : /* istanbul ignore next */ []));
14561
+ svgHeight = computed(() => this.graph().height + 80, ...(ngDevMode ? [{ debugName: "svgHeight" }] : /* istanbul ignore next */ []));
14071
14562
  viewBox = computed(() => `0 0 ${this.svgWidth()} ${this.svgHeight()}`, ...(ngDevMode ? [{ debugName: "viewBox" }] : /* istanbul ignore next */ []));
14072
14563
  transform = computed(() => {
14073
14564
  const z = this.zoom() / 100;
@@ -14080,7 +14571,7 @@ class DependencyViewerComponent {
14080
14571
  const id = this.selectedNodeId();
14081
14572
  if (!id)
14082
14573
  return null;
14083
- return this.layout().nodes.find(n => n.id === id) ?? null;
14574
+ return this.graph().nodes.find(n => n.id === id) ?? null;
14084
14575
  }, ...(ngDevMode ? [{ debugName: "selectedNode" }] : /* istanbul ignore next */ []));
14085
14576
  selectedDependsOn = computed(() => {
14086
14577
  const id = this.selectedNodeId();
@@ -14093,8 +14584,10 @@ class DependencyViewerComponent {
14093
14584
  // of built-in relation types they borrow their colour from.
14094
14585
  legendItems = computed(() => {
14095
14586
  const seen = new Map();
14096
- for (const edge of this.layout().edges) {
14097
- if (!edge.isCrossRef || !edge.label || seen.has(edge.label))
14587
+ for (const edge of this.graph().edges) {
14588
+ // Keyed off the label, not `isCrossRef`: in the layered layout the labelled
14589
+ // dependency edges are the main flow, not cross-references.
14590
+ if (!edge.label || seen.has(edge.label))
14098
14591
  continue;
14099
14592
  seen.set(edge.label, { label: edge.label, color: edge.color, dashed: edge.dashed });
14100
14593
  }
@@ -14103,7 +14596,7 @@ class DependencyViewerComponent {
14103
14596
  typeLegendItems = computed(() => {
14104
14597
  const colors = this.typeColors();
14105
14598
  const seen = new Map();
14106
- for (const node of this.layout().nodes) {
14599
+ for (const node of this.graph().nodes) {
14107
14600
  if (!node.type || seen.has(node.type) || !colors[node.type])
14108
14601
  continue;
14109
14602
  seen.set(node.type, { type: node.type, color: colors[node.type] });
@@ -14171,7 +14664,7 @@ class DependencyViewerComponent {
14171
14664
  // expand/collapse. Mirrors lc-tree-view's expandAll()/collapseAll() surface.
14172
14665
  /** Centres the viewport on a node. No-op for an id that isn't laid out. */
14173
14666
  focusNode(id) {
14174
- const node = this.layout().nodeMap.get(id);
14667
+ const node = this.graph().nodeMap.get(id);
14175
14668
  if (!node)
14176
14669
  return;
14177
14670
  const z = this.zoom() / 100;
@@ -14184,13 +14677,16 @@ class DependencyViewerComponent {
14184
14677
  this.anchorPos = { id, x: node.x, y: node.y };
14185
14678
  }
14186
14679
  /**
14187
- * Scales and centres the graph so all of it fits the canvas. Called for you when
14188
- * `autoFit` is set; public so a caller can fit on demand without it.
14680
+ * Scales and centres the graph to fit the canvas, per `fitMode`. Called for you
14681
+ * when fitting is on; public so a caller can fit on demand without it.
14189
14682
  *
14190
14683
  * No-op while the canvas has no size (before the first render, or in a detached
14191
- * / `display: none` container) — the `autoFit` observers re-run once it does.
14684
+ * / `display: none` container) — the fit observers re-run once it does.
14192
14685
  */
14193
14686
  fit() {
14687
+ const mode = this.effectiveFitMode();
14688
+ if (mode === 'none')
14689
+ return;
14194
14690
  const el = this.canvasEl();
14195
14691
  const viewW = el?.clientWidth ?? 0;
14196
14692
  const viewH = el?.clientHeight ?? 0;
@@ -14199,12 +14695,30 @@ class DependencyViewerComponent {
14199
14695
  const box = this.contentBox();
14200
14696
  if (box.width <= 0 || box.height <= 0)
14201
14697
  return;
14202
- const scale = Math.min(MAX_FIT_SCALE, Math.max(MIN_FIT_SCALE, Math.min(viewW / box.width, viewH / box.height)));
14698
+ // `fit-width` deliberately ignores the height: it is the mode that lets the
14699
+ // graph be taller than the frame. That is also what keeps it stable — the
14700
+ // height it produces feeds back in as the canvas's own height, and a scale
14701
+ // derived from that height would chase itself.
14702
+ const natural = mode === 'fit-width'
14703
+ ? viewW / box.width
14704
+ : Math.min(viewW / box.width, viewH / box.height);
14705
+ const scale = Math.min(MAX_FIT_SCALE, Math.max(this.minFitScale(), natural));
14203
14706
  this.zoom.set(scale * 100);
14204
14707
  // Invert screen = pan + scale * layout for the box's top-left corner, so the
14205
14708
  // box lands centred in the canvas.
14206
14709
  this.panX.set((viewW - box.width * scale) / 2 - box.x * scale);
14207
- this.panY.set((viewH - box.height * scale) / 2 - box.y * scale);
14710
+ const scaledHeight = box.height * scale;
14711
+ if (mode === 'fit-width') {
14712
+ // The viewer takes the graph's height and pins it to the top. Past the frame
14713
+ // that means the page scrolls through a readable graph rather than the frame
14714
+ // clipping one that was shrunk to fit.
14715
+ this.contentHeight.set(Math.ceil(scaledHeight));
14716
+ this.panY.set(-box.y * scale);
14717
+ }
14718
+ else {
14719
+ this.contentHeight.set(null);
14720
+ this.panY.set((viewH - scaledHeight) / 2 - box.y * scale);
14721
+ }
14208
14722
  // The camera just moved deliberately; a stale anchor baseline would have the
14209
14723
  // next relayout "correct" a position this fit had already chosen.
14210
14724
  this.anchorPos = null;
@@ -14214,7 +14728,7 @@ class DependencyViewerComponent {
14214
14728
  * default zoom and pan.
14215
14729
  */
14216
14730
  resetView() {
14217
- if (this.autoFit()) {
14731
+ if (this.effectiveFitMode() !== 'none') {
14218
14732
  this.fit();
14219
14733
  return;
14220
14734
  }
@@ -14268,7 +14782,7 @@ class DependencyViewerComponent {
14268
14782
  * apex of the bow — the only part of it that reaches outside them.
14269
14783
  */
14270
14784
  contentBox() {
14271
- const { nodes, edges } = this.layout();
14785
+ const { nodes, edges } = this.graph();
14272
14786
  if (!nodes.length)
14273
14787
  return { x: 0, y: 0, width: 0, height: 0 };
14274
14788
  let minX = Infinity;
@@ -14335,12 +14849,12 @@ class DependencyViewerComponent {
14335
14849
  return typeColor ? readableInk(typeColor) : STATUS_COLORS[node.status].text;
14336
14850
  }
14337
14851
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.10", ngImport: i0, type: DependencyViewerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
14338
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.10", type: DependencyViewerComponent, isStandalone: true, selector: "lc-dependency-viewer", inputs: { root: { classPropertyName: "root", publicName: "root", isSignal: true, isRequired: true, transformFunction: null }, direction: { classPropertyName: "direction", publicName: "direction", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null }, showToolbar: { classPropertyName: "showToolbar", publicName: "showToolbar", isSignal: true, isRequired: false, transformFunction: null }, showEdgeLabels: { classPropertyName: "showEdgeLabels", publicName: "showEdgeLabels", isSignal: true, isRequired: false, transformFunction: null }, edgeWidth: { classPropertyName: "edgeWidth", publicName: "edgeWidth", isSignal: true, isRequired: false, transformFunction: null }, autoFit: { classPropertyName: "autoFit", publicName: "autoFit", isSignal: true, isRequired: false, transformFunction: null }, interactive: { classPropertyName: "interactive", publicName: "interactive", isSignal: true, isRequired: false, transformFunction: null }, anchorNodeId: { classPropertyName: "anchorNodeId", publicName: "anchorNodeId", isSignal: true, isRequired: false, transformFunction: null }, typeColors: { classPropertyName: "typeColors", publicName: "typeColors", isSignal: true, isRequired: false, transformFunction: null }, hiddenRelations: { classPropertyName: "hiddenRelations", publicName: "hiddenRelations", isSignal: true, isRequired: false, transformFunction: null }, hiddenTypes: { classPropertyName: "hiddenTypes", publicName: "hiddenTypes", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { nodeSelect: "nodeSelect", nodeExpand: "nodeExpand" }, host: { listeners: { "document:mousemove": "onMouseMove($event)", "document:mouseup": "onMouseUp()" }, properties: { "class": "\"dep-viewer\"", "style.height": "height()" } }, viewQueries: [{ propertyName: "canvasRef", first: true, predicate: ["canvas"], descendants: true, isSignal: true }], ngImport: i0, template: "<!-- Toolbar. The zoom cluster is navigation, so it goes with `interactive`; the\n direction indicator is a read-out and stays. -->\n@if (showToolbar()) {\n <div class=\"dep-viewer__toolbar\">\n @if (interactive()) {\n <button class=\"dep-viewer__btn\" (click)=\"zoomIn()\" title=\"Zoom in\">+</button>\n <span class=\"dep-viewer__zoom\">{{ zoomLabel() }}%</span>\n <button class=\"dep-viewer__btn\" (click)=\"zoomOut()\" title=\"Zoom out\">\u2212</button>\n <button class=\"dep-viewer__btn dep-viewer__btn--reset\" (click)=\"resetZoom()\" title=\"Reset\">\u27F2</button>\n }\n <span class=\"dep-viewer__direction-label\">{{ direction() === 'horizontal' ? '\u2192' : '\u2193' }}</span>\n </div>\n}\n\n<!-- Canvas -->\n<div\n #canvas\n class=\"dep-viewer__canvas\"\n [class.dep-viewer__canvas--static]=\"!interactive()\"\n (mousedown)=\"onMouseDown($event)\"\n (wheel)=\"onWheel($event)\"\n (click)=\"deselectNode()\"\n>\n <svg\n [attr.width]=\"svgWidth()\"\n [attr.height]=\"svgHeight()\"\n [attr.viewBox]=\"viewBox()\"\n [style.transform]=\"transform()\"\n class=\"dep-viewer__svg\"\n >\n <!-- Arrow markers for cross-reference edges -->\n <defs>\n <marker id=\"arrow-default\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-neutral-400)\"/></marker>\n <marker id=\"arrow-blocks\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-error-default)\"/></marker>\n <marker id=\"arrow-references\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-info-default, #3b82f6)\"/></marker>\n <marker id=\"arrow-requires\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-warning-default)\"/></marker>\n <marker id=\"arrow-extends\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-primary-400)\"/></marker>\n <marker id=\"arrow-implements\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-success-default)\"/></marker>\n <marker id=\"arrow-uses\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-neutral-300)\"/></marker>\n <marker id=\"arrow-depends\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-neutral-400)\"/></marker>\n </defs>\n\n <!-- Edges. Their labels are deliberately NOT drawn here \u2014 see below. -->\n @for (edge of layout().edges; track edge.id) {\n <path\n [attr.d]=\"edge.path\"\n [attr.stroke]=\"edge.color\"\n [attr.stroke-width]=\"edgeWidth()\"\n [attr.stroke-dasharray]=\"edge.dashed ? '6 3' : 'none'\"\n [attr.marker-end]=\"edge.isCrossRef ? 'url(#arrow-' + edge.marker + ')' : ''\"\n fill=\"none\"\n class=\"dep-viewer__edge\"\n [class.dep-viewer__edge--cross-ref]=\"edge.isCrossRef\"\n />\n }\n\n <!-- Nodes -->\n @for (node of layout().nodes; track node.id) {\n <g\n class=\"dep-viewer__node\"\n [class.dep-viewer__node--selected]=\"selectedNodeId() === node.id\"\n (click)=\"selectNode(node.id, $event)\"\n (dblclick)=\"expandNode(node.id, $event)\"\n >\n <!-- Opaque backdrop. The status fills are translucent tints (in the dark\n theme --color-success-50 & co. resolve to rgba(\u2026, 0.18)), so without\n something solid beneath them the edges routed under a node show\n straight through it. This keeps the tint compositing against the\n viewer's own surface instead of against whatever passes behind. -->\n <rect\n [attr.x]=\"node.x\"\n [attr.y]=\"node.y\"\n [attr.width]=\"node.width\"\n [attr.height]=\"node.height\"\n rx=\"6\" ry=\"6\"\n class=\"dep-viewer__node-backdrop\"\n />\n <rect\n class=\"dep-viewer__node-fill\"\n [attr.x]=\"node.x\"\n [attr.y]=\"node.y\"\n [attr.width]=\"node.width\"\n [attr.height]=\"node.height\"\n rx=\"6\" ry=\"6\"\n [attr.fill]=\"getNodeBg(node)\"\n [attr.stroke]=\"selectedNodeId() === node.id ? 'var(--color-primary-500)' : getNodeBorder(node)\"\n stroke-width=\"1.5\"\n />\n <text\n [attr.x]=\"node.x + node.width / 2\"\n [attr.y]=\"node.y + node.height / 2 + 4\"\n text-anchor=\"middle\"\n [attr.fill]=\"getNodeText(node)\"\n class=\"dep-viewer__node-label\"\n >{{ node.label }}</text>\n\n <!-- \"+N\" marker: this node has more neighbours than are shown -->\n @if (node.moreCount) {\n <g class=\"dep-viewer__more\">\n <rect\n [attr.x]=\"node.x + node.width - 26\"\n [attr.y]=\"node.y - 8\"\n width=\"32\" height=\"16\" rx=\"8\" ry=\"8\"\n />\n <text\n [attr.x]=\"node.x + node.width - 10\"\n [attr.y]=\"node.y + 3\"\n text-anchor=\"middle\"\n >+{{ node.moreCount }}</text>\n </g>\n }\n\n <!-- Collapse/expand toggle -->\n @if (hasChildren(node.id)) {\n <g\n class=\"dep-viewer__toggle\"\n (click)=\"toggleCollapse(node.id, $event)\"\n >\n @if (direction() === 'horizontal') {\n <circle\n [attr.cx]=\"node.x + node.width + 10\"\n [attr.cy]=\"node.y + node.height / 2\"\n r=\"8\"\n fill=\"var(--color-neutral-100)\" stroke=\"var(--color-neutral-300)\" stroke-width=\"1\"\n />\n <text\n [attr.x]=\"node.x + node.width + 10\"\n [attr.y]=\"node.y + node.height / 2 + 4\"\n text-anchor=\"middle\"\n class=\"dep-viewer__toggle-text\"\n >{{ isCollapsed(node.id) ? '+' : '\u2212' }}</text>\n } @else {\n <circle\n [attr.cx]=\"node.x + node.width / 2\"\n [attr.cy]=\"node.y + node.height + 10\"\n r=\"8\"\n fill=\"var(--color-neutral-100)\" stroke=\"var(--color-neutral-300)\" stroke-width=\"1\"\n />\n <text\n [attr.x]=\"node.x + node.width / 2\"\n [attr.y]=\"node.y + node.height + 14\"\n text-anchor=\"middle\"\n class=\"dep-viewer__toggle-text\"\n >{{ isCollapsed(node.id) ? '+' : '\u2212' }}</text>\n }\n </g>\n }\n </g>\n }\n\n <!-- Edge labels last: SVG paints in document order, so anything drawn before\n the nodes is covered by them. A label sits at the midpoint of its edge,\n which frequently lands on a node \u2014 it has to come after. The halo (see\n the stylesheet) keeps it legible over whatever it lands on. -->\n @if (showEdgeLabels()) {\n @for (edge of layout().edges; track edge.id) {\n @if (edge.isCrossRef && edge.label) {\n <text\n [attr.x]=\"edge.labelX\"\n [attr.y]=\"edge.labelY\"\n class=\"dep-viewer__edge-label\"\n [attr.fill]=\"edge.color\"\n text-anchor=\"middle\"\n >{{ edge.label }}</text>\n }\n }\n }\n </svg>\n</div>\n\n<!-- Legend -->\n@if (legendItems().length || typeLegendItems().length) {\n <div class=\"dep-viewer__legend\">\n @for (item of legendItems(); track item.label) {\n <span class=\"dep-viewer__legend-item\">\n <svg width=\"24\" height=\"10\">\n <line\n x1=\"0\" y1=\"5\" x2=\"24\" y2=\"5\"\n [attr.stroke]=\"item.color\"\n stroke-width=\"2\"\n [attr.stroke-dasharray]=\"item.dashed ? '4 2' : 'none'\"\n />\n </svg>\n <span class=\"dep-viewer__legend-text\">{{ item.label }}</span>\n </span>\n }\n @for (item of typeLegendItems(); track item.type) {\n <span class=\"dep-viewer__legend-item\">\n <svg width=\"10\" height=\"10\">\n <rect width=\"10\" height=\"10\" rx=\"2\" ry=\"2\" [attr.fill]=\"item.color\" />\n </svg>\n <span class=\"dep-viewer__legend-text\">{{ item.type }}</span>\n </span>\n }\n </div>\n}\n\n<!-- Detail panel -->\n@if (selectedNode(); as node) {\n <div class=\"dep-viewer__detail\">\n <div class=\"dep-viewer__detail-header\">\n <span class=\"dep-viewer__detail-status dep-viewer__detail-status--{{ node.status }}\">\u25CF</span>\n <strong>{{ node.label }}</strong>\n @if (node.type) {\n <span class=\"dep-viewer__detail-type\">{{ node.type }}</span>\n }\n </div>\n @if (node.description) {\n <p class=\"dep-viewer__detail-desc\">{{ node.description }}</p>\n }\n <div class=\"dep-viewer__detail-meta\">\n <span>Depth: {{ node.depth }}</span>\n <span>Children: {{ node.outgoingCount }}</span>\n <span>Depends on: {{ node.incomingCount }}</span>\n </div>\n @if (selectedDependsOn().length) {\n <div class=\"dep-viewer__detail-deps\">\n <strong>Dependencies:</strong>\n <ul>\n @for (dep of selectedDependsOn(); track dep.id) {\n <li>\n <span class=\"dep-viewer__dep-relation\">{{\n dep.relationLabel || getRelationLabel(dep.relation || 'depends')\n }}</span>\n \u2192 {{ dep.id }}\n </li>\n }\n </ul>\n </div>\n }\n </div>\n}\n", styles: [":host{display:flex;flex-direction:column;position:relative;overflow:hidden;border:1px solid var(--color-border);border-radius:8px;background:var(--color-surface)}.dep-viewer__toolbar{display:flex;align-items:center;gap:4px;padding:8px 12px;border-bottom:1px solid var(--color-border);background:var(--color-surface-2);-webkit-user-select:none;user-select:none}.dep-viewer__btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;border:1px solid var(--color-border);border-radius:4px;background:var(--color-surface);color:var(--color-text-secondary);cursor:pointer;font-size:14px;line-height:1}.dep-viewer__btn:hover{background:var(--color-surface-hover)}.dep-viewer__btn--reset{margin-left:4px}.dep-viewer__zoom{font-size:12px;min-width:40px;text-align:center;color:var(--color-text-secondary)}.dep-viewer__direction-label{margin-left:8px;font-size:14px;color:var(--color-text-tertiary)}.dep-viewer__canvas{cursor:grab;overflow:hidden;width:100%;flex:1 1 auto;min-height:0}.dep-viewer__canvas:active{cursor:grabbing}.dep-viewer__canvas--static,.dep-viewer__canvas--static:active{cursor:default}.dep-viewer__svg{transform-origin:0 0;transition:transform .1s ease-out}.dep-viewer__edge{pointer-events:none;transition:opacity .15s}.dep-viewer__edge--cross-ref{opacity:.8}.dep-viewer__edge-label{font-size:9px;font-weight:500;pointer-events:none;-webkit-user-select:none;user-select:none;dominant-baseline:central;paint-order:stroke;stroke:var(--color-surface);stroke-width:3px;stroke-linejoin:round}.dep-viewer__node{cursor:pointer;transition:filter .15s}.dep-viewer__node:hover{filter:brightness(.96)}.dep-viewer__node--selected rect{stroke-width:2.5}.dep-viewer__node-backdrop{fill:var(--color-surface)}.dep-viewer__node-label{font-size:12px;font-weight:500;pointer-events:none;-webkit-user-select:none;user-select:none}.dep-viewer__toggle{cursor:pointer}.dep-viewer__toggle:hover circle{fill:var(--color-surface-hover)}.dep-viewer__more{pointer-events:none}.dep-viewer__more rect{fill:var(--color-surface-2);stroke:var(--color-border-strong);stroke-width:1}.dep-viewer__more text{font-size:9px;font-weight:600;fill:var(--color-text-secondary);-webkit-user-select:none;user-select:none}.dep-viewer__toggle-text{font-size:11px;font-weight:600;fill:var(--color-text-secondary);pointer-events:none;-webkit-user-select:none;user-select:none}.dep-viewer__legend{display:flex;flex-wrap:wrap;gap:12px;padding:6px 12px;border-top:1px solid var(--color-border);background:var(--color-surface-2)}.dep-viewer__legend-item{display:flex;align-items:center;gap:4px}.dep-viewer__legend-text{font-size:11px;color:var(--color-text-secondary)}.dep-viewer__detail{position:absolute;bottom:12px;right:12px;width:240px;padding:12px;background:var(--color-surface-2);border:1px solid var(--color-border);border-radius:8px;box-shadow:var(--elevation-1);font-size:12px;z-index:10}.dep-viewer__detail-header{display:flex;align-items:center;gap:6px;margin-bottom:4px;font-size:13px}.dep-viewer__detail-type{margin-left:auto;padding:1px 6px;border-radius:3px;background:var(--color-surface-hover);color:var(--color-text-secondary);font-size:10px;font-weight:500}.dep-viewer__detail-status{font-size:10px}.dep-viewer__detail-status--default{color:var(--color-text-tertiary)}.dep-viewer__detail-status--active{color:var(--color-primary-400)}.dep-viewer__detail-status--success{color:var(--color-success-default)}.dep-viewer__detail-status--warning{color:var(--color-warning-default)}.dep-viewer__detail-status--error{color:var(--color-error-default)}.dep-viewer__detail-status--muted{color:var(--color-text-disabled)}.dep-viewer__detail-desc{color:var(--color-text-secondary);margin:0 0 6px;line-height:1.4}.dep-viewer__detail-meta{display:flex;flex-wrap:wrap;gap:8px;color:var(--color-text-tertiary);font-size:11px}.dep-viewer__detail-deps{margin-top:8px;padding-top:8px;border-top:1px solid var(--color-border)}.dep-viewer__detail-deps ul{list-style:none;margin:4px 0 0;padding:0}.dep-viewer__detail-deps li{padding:2px 0;color:var(--color-text-secondary)}.dep-viewer__dep-relation{display:inline-block;padding:1px 4px;background:var(--color-surface-hover);border-radius:3px;font-size:10px;font-weight:500;color:var(--color-text-secondary)}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
14852
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.10", type: DependencyViewerComponent, isStandalone: true, selector: "lc-dependency-viewer", inputs: { root: { classPropertyName: "root", publicName: "root", isSignal: true, isRequired: true, transformFunction: null }, direction: { classPropertyName: "direction", publicName: "direction", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null }, layout: { classPropertyName: "layout", publicName: "layout", isSignal: true, isRequired: false, transformFunction: null }, showToolbar: { classPropertyName: "showToolbar", publicName: "showToolbar", isSignal: true, isRequired: false, transformFunction: null }, showEdgeLabels: { classPropertyName: "showEdgeLabels", publicName: "showEdgeLabels", isSignal: true, isRequired: false, transformFunction: null }, edgeWidth: { classPropertyName: "edgeWidth", publicName: "edgeWidth", isSignal: true, isRequired: false, transformFunction: null }, autoFit: { classPropertyName: "autoFit", publicName: "autoFit", isSignal: true, isRequired: false, transformFunction: null }, fitMode: { classPropertyName: "fitMode", publicName: "fitMode", isSignal: true, isRequired: false, transformFunction: null }, minNodeSize: { classPropertyName: "minNodeSize", publicName: "minNodeSize", isSignal: true, isRequired: false, transformFunction: null }, interactive: { classPropertyName: "interactive", publicName: "interactive", isSignal: true, isRequired: false, transformFunction: null }, anchorNodeId: { classPropertyName: "anchorNodeId", publicName: "anchorNodeId", isSignal: true, isRequired: false, transformFunction: null }, typeColors: { classPropertyName: "typeColors", publicName: "typeColors", isSignal: true, isRequired: false, transformFunction: null }, hiddenRelations: { classPropertyName: "hiddenRelations", publicName: "hiddenRelations", isSignal: true, isRequired: false, transformFunction: null }, hiddenTypes: { classPropertyName: "hiddenTypes", publicName: "hiddenTypes", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { nodeSelect: "nodeSelect", nodeExpand: "nodeExpand" }, host: { listeners: { "document:mousemove": "onMouseMove($event)", "document:mouseup": "onMouseUp()" }, properties: { "class": "\"dep-viewer\"", "style.height": "hostHeight()" } }, viewQueries: [{ propertyName: "canvasRef", first: true, predicate: ["canvas"], descendants: true, isSignal: true }], ngImport: i0, template: "<!-- Toolbar. The zoom cluster is navigation, so it goes with `interactive`; the\n direction indicator is a read-out and stays. -->\n@if (showToolbar()) {\n <div class=\"dep-viewer__toolbar\">\n @if (interactive()) {\n <button class=\"dep-viewer__btn\" (click)=\"zoomIn()\" title=\"Zoom in\">+</button>\n <span class=\"dep-viewer__zoom\">{{ zoomLabel() }}%</span>\n <button class=\"dep-viewer__btn\" (click)=\"zoomOut()\" title=\"Zoom out\">\u2212</button>\n <button class=\"dep-viewer__btn dep-viewer__btn--reset\" (click)=\"resetZoom()\" title=\"Reset\">\u27F2</button>\n }\n <span class=\"dep-viewer__direction-label\">{{ direction() === 'horizontal' ? '\u2192' : '\u2193' }}</span>\n </div>\n}\n\n<!-- Canvas -->\n<div\n #canvas\n class=\"dep-viewer__canvas\"\n [class.dep-viewer__canvas--static]=\"!interactive()\"\n [class.dep-viewer__canvas--sized]=\"contentHeight() !== null\"\n [style.height.px]=\"contentHeight()\"\n (mousedown)=\"onMouseDown($event)\"\n (wheel)=\"onWheel($event)\"\n (click)=\"deselectNode()\"\n>\n <svg\n [attr.width]=\"svgWidth()\"\n [attr.height]=\"svgHeight()\"\n [attr.viewBox]=\"viewBox()\"\n [style.transform]=\"transform()\"\n class=\"dep-viewer__svg\"\n >\n <!-- Arrow markers for cross-reference edges -->\n <defs>\n <marker id=\"arrow-default\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-neutral-400)\"/></marker>\n <marker id=\"arrow-blocks\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-error-default)\"/></marker>\n <marker id=\"arrow-references\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-info-default, #3b82f6)\"/></marker>\n <marker id=\"arrow-requires\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-warning-default)\"/></marker>\n <marker id=\"arrow-extends\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-primary-400)\"/></marker>\n <marker id=\"arrow-implements\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-success-default)\"/></marker>\n <marker id=\"arrow-uses\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-neutral-300)\"/></marker>\n <marker id=\"arrow-depends\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-neutral-400)\"/></marker>\n </defs>\n\n <!-- Edges. Their labels are deliberately NOT drawn here \u2014 see below. -->\n @for (edge of graph().edges; track edge.id) {\n <path\n [attr.d]=\"edge.path\"\n [attr.stroke]=\"edge.color\"\n [attr.stroke-width]=\"edgeWidth()\"\n [attr.stroke-dasharray]=\"edge.dashed ? '6 3' : 'none'\"\n [attr.marker-end]=\"edge.arrow ? 'url(#arrow-' + edge.marker + ')' : ''\"\n fill=\"none\"\n class=\"dep-viewer__edge\"\n [class.dep-viewer__edge--cross-ref]=\"edge.isCrossRef\"\n />\n }\n\n <!-- Nodes -->\n @for (node of graph().nodes; track node.id) {\n <g\n class=\"dep-viewer__node\"\n [class.dep-viewer__node--selected]=\"selectedNodeId() === node.id\"\n (click)=\"selectNode(node.id, $event)\"\n (dblclick)=\"expandNode(node.id, $event)\"\n >\n <!-- Opaque backdrop. The status fills are translucent tints (in the dark\n theme --color-success-50 & co. resolve to rgba(\u2026, 0.18)), so without\n something solid beneath them the edges routed under a node show\n straight through it. This keeps the tint compositing against the\n viewer's own surface instead of against whatever passes behind. -->\n <rect\n [attr.x]=\"node.x\"\n [attr.y]=\"node.y\"\n [attr.width]=\"node.width\"\n [attr.height]=\"node.height\"\n rx=\"6\" ry=\"6\"\n class=\"dep-viewer__node-backdrop\"\n />\n <rect\n class=\"dep-viewer__node-fill\"\n [attr.x]=\"node.x\"\n [attr.y]=\"node.y\"\n [attr.width]=\"node.width\"\n [attr.height]=\"node.height\"\n rx=\"6\" ry=\"6\"\n [attr.fill]=\"getNodeBg(node)\"\n [attr.stroke]=\"selectedNodeId() === node.id ? 'var(--color-primary-500)' : getNodeBorder(node)\"\n stroke-width=\"1.5\"\n />\n <text\n [attr.x]=\"node.x + node.width / 2\"\n [attr.y]=\"node.y + node.height / 2 + 4\"\n text-anchor=\"middle\"\n [attr.fill]=\"getNodeText(node)\"\n class=\"dep-viewer__node-label\"\n >{{ node.label }}</text>\n\n <!-- \"+N\" marker: this node has more neighbours than are shown -->\n @if (node.moreCount) {\n <g class=\"dep-viewer__more\">\n <rect\n [attr.x]=\"node.x + node.width - 26\"\n [attr.y]=\"node.y - 8\"\n width=\"32\" height=\"16\" rx=\"8\" ry=\"8\"\n />\n <text\n [attr.x]=\"node.x + node.width - 10\"\n [attr.y]=\"node.y + 3\"\n text-anchor=\"middle\"\n >+{{ node.moreCount }}</text>\n </g>\n }\n\n <!-- Collapse/expand toggle -->\n @if (hasChildren(node.id)) {\n <g\n class=\"dep-viewer__toggle\"\n (click)=\"toggleCollapse(node.id, $event)\"\n >\n @if (direction() === 'horizontal') {\n <circle\n [attr.cx]=\"node.x + node.width + 10\"\n [attr.cy]=\"node.y + node.height / 2\"\n r=\"8\"\n fill=\"var(--color-neutral-100)\" stroke=\"var(--color-neutral-300)\" stroke-width=\"1\"\n />\n <text\n [attr.x]=\"node.x + node.width + 10\"\n [attr.y]=\"node.y + node.height / 2 + 4\"\n text-anchor=\"middle\"\n class=\"dep-viewer__toggle-text\"\n >{{ isCollapsed(node.id) ? '+' : '\u2212' }}</text>\n } @else {\n <circle\n [attr.cx]=\"node.x + node.width / 2\"\n [attr.cy]=\"node.y + node.height + 10\"\n r=\"8\"\n fill=\"var(--color-neutral-100)\" stroke=\"var(--color-neutral-300)\" stroke-width=\"1\"\n />\n <text\n [attr.x]=\"node.x + node.width / 2\"\n [attr.y]=\"node.y + node.height + 14\"\n text-anchor=\"middle\"\n class=\"dep-viewer__toggle-text\"\n >{{ isCollapsed(node.id) ? '+' : '\u2212' }}</text>\n }\n </g>\n }\n </g>\n }\n\n <!-- Edge labels last: SVG paints in document order, so anything drawn before\n the nodes is covered by them. A label sits at the midpoint of its edge,\n which frequently lands on a node \u2014 it has to come after. The halo (see\n the stylesheet) keeps it legible over whatever it lands on. -->\n @if (showEdgeLabels()) {\n @for (edge of graph().edges; track edge.id) {\n @if (edge.label) {\n <text\n [attr.x]=\"edge.labelX\"\n [attr.y]=\"edge.labelY\"\n class=\"dep-viewer__edge-label\"\n [attr.fill]=\"edge.color\"\n text-anchor=\"middle\"\n >{{ edge.label }}</text>\n }\n }\n }\n </svg>\n</div>\n\n<!-- Legend -->\n@if (legendItems().length || typeLegendItems().length) {\n <div class=\"dep-viewer__legend\">\n @for (item of legendItems(); track item.label) {\n <span class=\"dep-viewer__legend-item\">\n <svg width=\"24\" height=\"10\">\n <line\n x1=\"0\" y1=\"5\" x2=\"24\" y2=\"5\"\n [attr.stroke]=\"item.color\"\n stroke-width=\"2\"\n [attr.stroke-dasharray]=\"item.dashed ? '4 2' : 'none'\"\n />\n </svg>\n <span class=\"dep-viewer__legend-text\">{{ item.label }}</span>\n </span>\n }\n @for (item of typeLegendItems(); track item.type) {\n <span class=\"dep-viewer__legend-item\">\n <svg width=\"10\" height=\"10\">\n <rect width=\"10\" height=\"10\" rx=\"2\" ry=\"2\" [attr.fill]=\"item.color\" />\n </svg>\n <span class=\"dep-viewer__legend-text\">{{ item.type }}</span>\n </span>\n }\n </div>\n}\n\n<!-- Detail panel -->\n@if (selectedNode(); as node) {\n <div class=\"dep-viewer__detail\">\n <div class=\"dep-viewer__detail-header\">\n <span class=\"dep-viewer__detail-status dep-viewer__detail-status--{{ node.status }}\">\u25CF</span>\n <strong>{{ node.label }}</strong>\n @if (node.type) {\n <span class=\"dep-viewer__detail-type\">{{ node.type }}</span>\n }\n </div>\n @if (node.description) {\n <p class=\"dep-viewer__detail-desc\">{{ node.description }}</p>\n }\n <div class=\"dep-viewer__detail-meta\">\n <span>Depth: {{ node.depth }}</span>\n <span>Children: {{ node.outgoingCount }}</span>\n <span>Depends on: {{ node.incomingCount }}</span>\n </div>\n @if (selectedDependsOn().length) {\n <div class=\"dep-viewer__detail-deps\">\n <strong>Dependencies:</strong>\n <ul>\n @for (dep of selectedDependsOn(); track dep.id) {\n <li>\n <span class=\"dep-viewer__dep-relation\">{{\n dep.relationLabel || getRelationLabel(dep.relation || 'depends')\n }}</span>\n \u2192 {{ dep.id }}\n </li>\n }\n </ul>\n </div>\n }\n </div>\n}\n", styles: [":host{display:flex;flex-direction:column;position:relative;overflow:hidden;border:1px solid var(--color-border);border-radius:8px;background:var(--color-surface)}.dep-viewer__toolbar{display:flex;align-items:center;gap:4px;padding:8px 12px;border-bottom:1px solid var(--color-border);background:var(--color-surface-2);-webkit-user-select:none;user-select:none}.dep-viewer__btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;border:1px solid var(--color-border);border-radius:4px;background:var(--color-surface);color:var(--color-text-secondary);cursor:pointer;font-size:14px;line-height:1}.dep-viewer__btn:hover{background:var(--color-surface-hover)}.dep-viewer__btn--reset{margin-left:4px}.dep-viewer__zoom{font-size:12px;min-width:40px;text-align:center;color:var(--color-text-secondary)}.dep-viewer__direction-label{margin-left:8px;font-size:14px;color:var(--color-text-tertiary)}.dep-viewer__canvas{cursor:grab;overflow:hidden;width:100%;flex:1 1 auto;min-height:0}.dep-viewer__canvas:active{cursor:grabbing}.dep-viewer__canvas--static,.dep-viewer__canvas--static:active{cursor:default}.dep-viewer__canvas--sized{flex:0 0 auto}.dep-viewer__svg{transform-origin:0 0;transition:transform .1s ease-out}.dep-viewer__edge{pointer-events:none;transition:opacity .15s}.dep-viewer__edge--cross-ref{opacity:.8}.dep-viewer__edge-label{font-size:9px;font-weight:500;pointer-events:none;-webkit-user-select:none;user-select:none;dominant-baseline:central;paint-order:stroke;stroke:var(--color-surface);stroke-width:3px;stroke-linejoin:round}.dep-viewer__node{cursor:pointer;transition:filter .15s}.dep-viewer__node:hover{filter:brightness(.96)}.dep-viewer__node--selected rect{stroke-width:2.5}.dep-viewer__node-backdrop{fill:var(--color-surface)}.dep-viewer__node-label{font-size:12px;font-weight:500;pointer-events:none;-webkit-user-select:none;user-select:none}.dep-viewer__toggle{cursor:pointer}.dep-viewer__toggle:hover circle{fill:var(--color-surface-hover)}.dep-viewer__more{pointer-events:none}.dep-viewer__more rect{fill:var(--color-surface-2);stroke:var(--color-border-strong);stroke-width:1}.dep-viewer__more text{font-size:9px;font-weight:600;fill:var(--color-text-secondary);-webkit-user-select:none;user-select:none}.dep-viewer__toggle-text{font-size:11px;font-weight:600;fill:var(--color-text-secondary);pointer-events:none;-webkit-user-select:none;user-select:none}.dep-viewer__legend{display:flex;flex-wrap:wrap;gap:12px;padding:6px 12px;border-top:1px solid var(--color-border);background:var(--color-surface-2)}.dep-viewer__legend-item{display:flex;align-items:center;gap:4px}.dep-viewer__legend-text{font-size:11px;color:var(--color-text-secondary)}.dep-viewer__detail{position:absolute;bottom:12px;right:12px;width:240px;padding:12px;background:var(--color-surface-2);border:1px solid var(--color-border);border-radius:8px;box-shadow:var(--elevation-1);font-size:12px;z-index:10}.dep-viewer__detail-header{display:flex;align-items:center;gap:6px;margin-bottom:4px;font-size:13px}.dep-viewer__detail-type{margin-left:auto;padding:1px 6px;border-radius:3px;background:var(--color-surface-hover);color:var(--color-text-secondary);font-size:10px;font-weight:500}.dep-viewer__detail-status{font-size:10px}.dep-viewer__detail-status--default{color:var(--color-text-tertiary)}.dep-viewer__detail-status--active{color:var(--color-primary-400)}.dep-viewer__detail-status--success{color:var(--color-success-default)}.dep-viewer__detail-status--warning{color:var(--color-warning-default)}.dep-viewer__detail-status--error{color:var(--color-error-default)}.dep-viewer__detail-status--muted{color:var(--color-text-disabled)}.dep-viewer__detail-desc{color:var(--color-text-secondary);margin:0 0 6px;line-height:1.4}.dep-viewer__detail-meta{display:flex;flex-wrap:wrap;gap:8px;color:var(--color-text-tertiary);font-size:11px}.dep-viewer__detail-deps{margin-top:8px;padding-top:8px;border-top:1px solid var(--color-border)}.dep-viewer__detail-deps ul{list-style:none;margin:4px 0 0;padding:0}.dep-viewer__detail-deps li{padding:2px 0;color:var(--color-text-secondary)}.dep-viewer__dep-relation{display:inline-block;padding:1px 4px;background:var(--color-surface-hover);border-radius:3px;font-size:10px;font-weight:500;color:var(--color-text-secondary)}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
14339
14853
  }
14340
14854
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.10", ngImport: i0, type: DependencyViewerComponent, decorators: [{
14341
14855
  type: Component,
14342
- args: [{ selector: 'lc-dependency-viewer', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, host: { '[class]': '"dep-viewer"', '[style.height]': 'height()' }, template: "<!-- Toolbar. The zoom cluster is navigation, so it goes with `interactive`; the\n direction indicator is a read-out and stays. -->\n@if (showToolbar()) {\n <div class=\"dep-viewer__toolbar\">\n @if (interactive()) {\n <button class=\"dep-viewer__btn\" (click)=\"zoomIn()\" title=\"Zoom in\">+</button>\n <span class=\"dep-viewer__zoom\">{{ zoomLabel() }}%</span>\n <button class=\"dep-viewer__btn\" (click)=\"zoomOut()\" title=\"Zoom out\">\u2212</button>\n <button class=\"dep-viewer__btn dep-viewer__btn--reset\" (click)=\"resetZoom()\" title=\"Reset\">\u27F2</button>\n }\n <span class=\"dep-viewer__direction-label\">{{ direction() === 'horizontal' ? '\u2192' : '\u2193' }}</span>\n </div>\n}\n\n<!-- Canvas -->\n<div\n #canvas\n class=\"dep-viewer__canvas\"\n [class.dep-viewer__canvas--static]=\"!interactive()\"\n (mousedown)=\"onMouseDown($event)\"\n (wheel)=\"onWheel($event)\"\n (click)=\"deselectNode()\"\n>\n <svg\n [attr.width]=\"svgWidth()\"\n [attr.height]=\"svgHeight()\"\n [attr.viewBox]=\"viewBox()\"\n [style.transform]=\"transform()\"\n class=\"dep-viewer__svg\"\n >\n <!-- Arrow markers for cross-reference edges -->\n <defs>\n <marker id=\"arrow-default\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-neutral-400)\"/></marker>\n <marker id=\"arrow-blocks\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-error-default)\"/></marker>\n <marker id=\"arrow-references\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-info-default, #3b82f6)\"/></marker>\n <marker id=\"arrow-requires\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-warning-default)\"/></marker>\n <marker id=\"arrow-extends\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-primary-400)\"/></marker>\n <marker id=\"arrow-implements\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-success-default)\"/></marker>\n <marker id=\"arrow-uses\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-neutral-300)\"/></marker>\n <marker id=\"arrow-depends\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-neutral-400)\"/></marker>\n </defs>\n\n <!-- Edges. Their labels are deliberately NOT drawn here \u2014 see below. -->\n @for (edge of layout().edges; track edge.id) {\n <path\n [attr.d]=\"edge.path\"\n [attr.stroke]=\"edge.color\"\n [attr.stroke-width]=\"edgeWidth()\"\n [attr.stroke-dasharray]=\"edge.dashed ? '6 3' : 'none'\"\n [attr.marker-end]=\"edge.isCrossRef ? 'url(#arrow-' + edge.marker + ')' : ''\"\n fill=\"none\"\n class=\"dep-viewer__edge\"\n [class.dep-viewer__edge--cross-ref]=\"edge.isCrossRef\"\n />\n }\n\n <!-- Nodes -->\n @for (node of layout().nodes; track node.id) {\n <g\n class=\"dep-viewer__node\"\n [class.dep-viewer__node--selected]=\"selectedNodeId() === node.id\"\n (click)=\"selectNode(node.id, $event)\"\n (dblclick)=\"expandNode(node.id, $event)\"\n >\n <!-- Opaque backdrop. The status fills are translucent tints (in the dark\n theme --color-success-50 & co. resolve to rgba(\u2026, 0.18)), so without\n something solid beneath them the edges routed under a node show\n straight through it. This keeps the tint compositing against the\n viewer's own surface instead of against whatever passes behind. -->\n <rect\n [attr.x]=\"node.x\"\n [attr.y]=\"node.y\"\n [attr.width]=\"node.width\"\n [attr.height]=\"node.height\"\n rx=\"6\" ry=\"6\"\n class=\"dep-viewer__node-backdrop\"\n />\n <rect\n class=\"dep-viewer__node-fill\"\n [attr.x]=\"node.x\"\n [attr.y]=\"node.y\"\n [attr.width]=\"node.width\"\n [attr.height]=\"node.height\"\n rx=\"6\" ry=\"6\"\n [attr.fill]=\"getNodeBg(node)\"\n [attr.stroke]=\"selectedNodeId() === node.id ? 'var(--color-primary-500)' : getNodeBorder(node)\"\n stroke-width=\"1.5\"\n />\n <text\n [attr.x]=\"node.x + node.width / 2\"\n [attr.y]=\"node.y + node.height / 2 + 4\"\n text-anchor=\"middle\"\n [attr.fill]=\"getNodeText(node)\"\n class=\"dep-viewer__node-label\"\n >{{ node.label }}</text>\n\n <!-- \"+N\" marker: this node has more neighbours than are shown -->\n @if (node.moreCount) {\n <g class=\"dep-viewer__more\">\n <rect\n [attr.x]=\"node.x + node.width - 26\"\n [attr.y]=\"node.y - 8\"\n width=\"32\" height=\"16\" rx=\"8\" ry=\"8\"\n />\n <text\n [attr.x]=\"node.x + node.width - 10\"\n [attr.y]=\"node.y + 3\"\n text-anchor=\"middle\"\n >+{{ node.moreCount }}</text>\n </g>\n }\n\n <!-- Collapse/expand toggle -->\n @if (hasChildren(node.id)) {\n <g\n class=\"dep-viewer__toggle\"\n (click)=\"toggleCollapse(node.id, $event)\"\n >\n @if (direction() === 'horizontal') {\n <circle\n [attr.cx]=\"node.x + node.width + 10\"\n [attr.cy]=\"node.y + node.height / 2\"\n r=\"8\"\n fill=\"var(--color-neutral-100)\" stroke=\"var(--color-neutral-300)\" stroke-width=\"1\"\n />\n <text\n [attr.x]=\"node.x + node.width + 10\"\n [attr.y]=\"node.y + node.height / 2 + 4\"\n text-anchor=\"middle\"\n class=\"dep-viewer__toggle-text\"\n >{{ isCollapsed(node.id) ? '+' : '\u2212' }}</text>\n } @else {\n <circle\n [attr.cx]=\"node.x + node.width / 2\"\n [attr.cy]=\"node.y + node.height + 10\"\n r=\"8\"\n fill=\"var(--color-neutral-100)\" stroke=\"var(--color-neutral-300)\" stroke-width=\"1\"\n />\n <text\n [attr.x]=\"node.x + node.width / 2\"\n [attr.y]=\"node.y + node.height + 14\"\n text-anchor=\"middle\"\n class=\"dep-viewer__toggle-text\"\n >{{ isCollapsed(node.id) ? '+' : '\u2212' }}</text>\n }\n </g>\n }\n </g>\n }\n\n <!-- Edge labels last: SVG paints in document order, so anything drawn before\n the nodes is covered by them. A label sits at the midpoint of its edge,\n which frequently lands on a node \u2014 it has to come after. The halo (see\n the stylesheet) keeps it legible over whatever it lands on. -->\n @if (showEdgeLabels()) {\n @for (edge of layout().edges; track edge.id) {\n @if (edge.isCrossRef && edge.label) {\n <text\n [attr.x]=\"edge.labelX\"\n [attr.y]=\"edge.labelY\"\n class=\"dep-viewer__edge-label\"\n [attr.fill]=\"edge.color\"\n text-anchor=\"middle\"\n >{{ edge.label }}</text>\n }\n }\n }\n </svg>\n</div>\n\n<!-- Legend -->\n@if (legendItems().length || typeLegendItems().length) {\n <div class=\"dep-viewer__legend\">\n @for (item of legendItems(); track item.label) {\n <span class=\"dep-viewer__legend-item\">\n <svg width=\"24\" height=\"10\">\n <line\n x1=\"0\" y1=\"5\" x2=\"24\" y2=\"5\"\n [attr.stroke]=\"item.color\"\n stroke-width=\"2\"\n [attr.stroke-dasharray]=\"item.dashed ? '4 2' : 'none'\"\n />\n </svg>\n <span class=\"dep-viewer__legend-text\">{{ item.label }}</span>\n </span>\n }\n @for (item of typeLegendItems(); track item.type) {\n <span class=\"dep-viewer__legend-item\">\n <svg width=\"10\" height=\"10\">\n <rect width=\"10\" height=\"10\" rx=\"2\" ry=\"2\" [attr.fill]=\"item.color\" />\n </svg>\n <span class=\"dep-viewer__legend-text\">{{ item.type }}</span>\n </span>\n }\n </div>\n}\n\n<!-- Detail panel -->\n@if (selectedNode(); as node) {\n <div class=\"dep-viewer__detail\">\n <div class=\"dep-viewer__detail-header\">\n <span class=\"dep-viewer__detail-status dep-viewer__detail-status--{{ node.status }}\">\u25CF</span>\n <strong>{{ node.label }}</strong>\n @if (node.type) {\n <span class=\"dep-viewer__detail-type\">{{ node.type }}</span>\n }\n </div>\n @if (node.description) {\n <p class=\"dep-viewer__detail-desc\">{{ node.description }}</p>\n }\n <div class=\"dep-viewer__detail-meta\">\n <span>Depth: {{ node.depth }}</span>\n <span>Children: {{ node.outgoingCount }}</span>\n <span>Depends on: {{ node.incomingCount }}</span>\n </div>\n @if (selectedDependsOn().length) {\n <div class=\"dep-viewer__detail-deps\">\n <strong>Dependencies:</strong>\n <ul>\n @for (dep of selectedDependsOn(); track dep.id) {\n <li>\n <span class=\"dep-viewer__dep-relation\">{{\n dep.relationLabel || getRelationLabel(dep.relation || 'depends')\n }}</span>\n \u2192 {{ dep.id }}\n </li>\n }\n </ul>\n </div>\n }\n </div>\n}\n", styles: [":host{display:flex;flex-direction:column;position:relative;overflow:hidden;border:1px solid var(--color-border);border-radius:8px;background:var(--color-surface)}.dep-viewer__toolbar{display:flex;align-items:center;gap:4px;padding:8px 12px;border-bottom:1px solid var(--color-border);background:var(--color-surface-2);-webkit-user-select:none;user-select:none}.dep-viewer__btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;border:1px solid var(--color-border);border-radius:4px;background:var(--color-surface);color:var(--color-text-secondary);cursor:pointer;font-size:14px;line-height:1}.dep-viewer__btn:hover{background:var(--color-surface-hover)}.dep-viewer__btn--reset{margin-left:4px}.dep-viewer__zoom{font-size:12px;min-width:40px;text-align:center;color:var(--color-text-secondary)}.dep-viewer__direction-label{margin-left:8px;font-size:14px;color:var(--color-text-tertiary)}.dep-viewer__canvas{cursor:grab;overflow:hidden;width:100%;flex:1 1 auto;min-height:0}.dep-viewer__canvas:active{cursor:grabbing}.dep-viewer__canvas--static,.dep-viewer__canvas--static:active{cursor:default}.dep-viewer__svg{transform-origin:0 0;transition:transform .1s ease-out}.dep-viewer__edge{pointer-events:none;transition:opacity .15s}.dep-viewer__edge--cross-ref{opacity:.8}.dep-viewer__edge-label{font-size:9px;font-weight:500;pointer-events:none;-webkit-user-select:none;user-select:none;dominant-baseline:central;paint-order:stroke;stroke:var(--color-surface);stroke-width:3px;stroke-linejoin:round}.dep-viewer__node{cursor:pointer;transition:filter .15s}.dep-viewer__node:hover{filter:brightness(.96)}.dep-viewer__node--selected rect{stroke-width:2.5}.dep-viewer__node-backdrop{fill:var(--color-surface)}.dep-viewer__node-label{font-size:12px;font-weight:500;pointer-events:none;-webkit-user-select:none;user-select:none}.dep-viewer__toggle{cursor:pointer}.dep-viewer__toggle:hover circle{fill:var(--color-surface-hover)}.dep-viewer__more{pointer-events:none}.dep-viewer__more rect{fill:var(--color-surface-2);stroke:var(--color-border-strong);stroke-width:1}.dep-viewer__more text{font-size:9px;font-weight:600;fill:var(--color-text-secondary);-webkit-user-select:none;user-select:none}.dep-viewer__toggle-text{font-size:11px;font-weight:600;fill:var(--color-text-secondary);pointer-events:none;-webkit-user-select:none;user-select:none}.dep-viewer__legend{display:flex;flex-wrap:wrap;gap:12px;padding:6px 12px;border-top:1px solid var(--color-border);background:var(--color-surface-2)}.dep-viewer__legend-item{display:flex;align-items:center;gap:4px}.dep-viewer__legend-text{font-size:11px;color:var(--color-text-secondary)}.dep-viewer__detail{position:absolute;bottom:12px;right:12px;width:240px;padding:12px;background:var(--color-surface-2);border:1px solid var(--color-border);border-radius:8px;box-shadow:var(--elevation-1);font-size:12px;z-index:10}.dep-viewer__detail-header{display:flex;align-items:center;gap:6px;margin-bottom:4px;font-size:13px}.dep-viewer__detail-type{margin-left:auto;padding:1px 6px;border-radius:3px;background:var(--color-surface-hover);color:var(--color-text-secondary);font-size:10px;font-weight:500}.dep-viewer__detail-status{font-size:10px}.dep-viewer__detail-status--default{color:var(--color-text-tertiary)}.dep-viewer__detail-status--active{color:var(--color-primary-400)}.dep-viewer__detail-status--success{color:var(--color-success-default)}.dep-viewer__detail-status--warning{color:var(--color-warning-default)}.dep-viewer__detail-status--error{color:var(--color-error-default)}.dep-viewer__detail-status--muted{color:var(--color-text-disabled)}.dep-viewer__detail-desc{color:var(--color-text-secondary);margin:0 0 6px;line-height:1.4}.dep-viewer__detail-meta{display:flex;flex-wrap:wrap;gap:8px;color:var(--color-text-tertiary);font-size:11px}.dep-viewer__detail-deps{margin-top:8px;padding-top:8px;border-top:1px solid var(--color-border)}.dep-viewer__detail-deps ul{list-style:none;margin:4px 0 0;padding:0}.dep-viewer__detail-deps li{padding:2px 0;color:var(--color-text-secondary)}.dep-viewer__dep-relation{display:inline-block;padding:1px 4px;background:var(--color-surface-hover);border-radius:3px;font-size:10px;font-weight:500;color:var(--color-text-secondary)}\n"] }]
14343
- }], ctorParameters: () => [], propDecorators: { root: [{ type: i0.Input, args: [{ isSignal: true, alias: "root", required: true }] }], direction: [{ type: i0.Input, args: [{ isSignal: true, alias: "direction", required: false }] }], height: [{ type: i0.Input, args: [{ isSignal: true, alias: "height", required: false }] }], showToolbar: [{ type: i0.Input, args: [{ isSignal: true, alias: "showToolbar", required: false }] }], showEdgeLabels: [{ type: i0.Input, args: [{ isSignal: true, alias: "showEdgeLabels", required: false }] }], edgeWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "edgeWidth", required: false }] }], autoFit: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoFit", required: false }] }], interactive: [{ type: i0.Input, args: [{ isSignal: true, alias: "interactive", required: false }] }], anchorNodeId: [{ type: i0.Input, args: [{ isSignal: true, alias: "anchorNodeId", required: false }] }], typeColors: [{ type: i0.Input, args: [{ isSignal: true, alias: "typeColors", required: false }] }], hiddenRelations: [{ type: i0.Input, args: [{ isSignal: true, alias: "hiddenRelations", required: false }] }], hiddenTypes: [{ type: i0.Input, args: [{ isSignal: true, alias: "hiddenTypes", required: false }] }], nodeSelect: [{ type: i0.Output, args: ["nodeSelect"] }], nodeExpand: [{ type: i0.Output, args: ["nodeExpand"] }], canvasRef: [{ type: i0.ViewChild, args: ['canvas', { isSignal: true }] }], onMouseMove: [{
14856
+ args: [{ selector: 'lc-dependency-viewer', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, host: { '[class]': '"dep-viewer"', '[style.height]': 'hostHeight()' }, template: "<!-- Toolbar. The zoom cluster is navigation, so it goes with `interactive`; the\n direction indicator is a read-out and stays. -->\n@if (showToolbar()) {\n <div class=\"dep-viewer__toolbar\">\n @if (interactive()) {\n <button class=\"dep-viewer__btn\" (click)=\"zoomIn()\" title=\"Zoom in\">+</button>\n <span class=\"dep-viewer__zoom\">{{ zoomLabel() }}%</span>\n <button class=\"dep-viewer__btn\" (click)=\"zoomOut()\" title=\"Zoom out\">\u2212</button>\n <button class=\"dep-viewer__btn dep-viewer__btn--reset\" (click)=\"resetZoom()\" title=\"Reset\">\u27F2</button>\n }\n <span class=\"dep-viewer__direction-label\">{{ direction() === 'horizontal' ? '\u2192' : '\u2193' }}</span>\n </div>\n}\n\n<!-- Canvas -->\n<div\n #canvas\n class=\"dep-viewer__canvas\"\n [class.dep-viewer__canvas--static]=\"!interactive()\"\n [class.dep-viewer__canvas--sized]=\"contentHeight() !== null\"\n [style.height.px]=\"contentHeight()\"\n (mousedown)=\"onMouseDown($event)\"\n (wheel)=\"onWheel($event)\"\n (click)=\"deselectNode()\"\n>\n <svg\n [attr.width]=\"svgWidth()\"\n [attr.height]=\"svgHeight()\"\n [attr.viewBox]=\"viewBox()\"\n [style.transform]=\"transform()\"\n class=\"dep-viewer__svg\"\n >\n <!-- Arrow markers for cross-reference edges -->\n <defs>\n <marker id=\"arrow-default\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-neutral-400)\"/></marker>\n <marker id=\"arrow-blocks\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-error-default)\"/></marker>\n <marker id=\"arrow-references\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-info-default, #3b82f6)\"/></marker>\n <marker id=\"arrow-requires\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-warning-default)\"/></marker>\n <marker id=\"arrow-extends\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-primary-400)\"/></marker>\n <marker id=\"arrow-implements\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-success-default)\"/></marker>\n <marker id=\"arrow-uses\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-neutral-300)\"/></marker>\n <marker id=\"arrow-depends\" markerWidth=\"8\" markerHeight=\"6\" refX=\"8\" refY=\"3\" orient=\"auto\"><path d=\"M0,0 L8,3 L0,6 Z\" fill=\"var(--color-neutral-400)\"/></marker>\n </defs>\n\n <!-- Edges. Their labels are deliberately NOT drawn here \u2014 see below. -->\n @for (edge of graph().edges; track edge.id) {\n <path\n [attr.d]=\"edge.path\"\n [attr.stroke]=\"edge.color\"\n [attr.stroke-width]=\"edgeWidth()\"\n [attr.stroke-dasharray]=\"edge.dashed ? '6 3' : 'none'\"\n [attr.marker-end]=\"edge.arrow ? 'url(#arrow-' + edge.marker + ')' : ''\"\n fill=\"none\"\n class=\"dep-viewer__edge\"\n [class.dep-viewer__edge--cross-ref]=\"edge.isCrossRef\"\n />\n }\n\n <!-- Nodes -->\n @for (node of graph().nodes; track node.id) {\n <g\n class=\"dep-viewer__node\"\n [class.dep-viewer__node--selected]=\"selectedNodeId() === node.id\"\n (click)=\"selectNode(node.id, $event)\"\n (dblclick)=\"expandNode(node.id, $event)\"\n >\n <!-- Opaque backdrop. The status fills are translucent tints (in the dark\n theme --color-success-50 & co. resolve to rgba(\u2026, 0.18)), so without\n something solid beneath them the edges routed under a node show\n straight through it. This keeps the tint compositing against the\n viewer's own surface instead of against whatever passes behind. -->\n <rect\n [attr.x]=\"node.x\"\n [attr.y]=\"node.y\"\n [attr.width]=\"node.width\"\n [attr.height]=\"node.height\"\n rx=\"6\" ry=\"6\"\n class=\"dep-viewer__node-backdrop\"\n />\n <rect\n class=\"dep-viewer__node-fill\"\n [attr.x]=\"node.x\"\n [attr.y]=\"node.y\"\n [attr.width]=\"node.width\"\n [attr.height]=\"node.height\"\n rx=\"6\" ry=\"6\"\n [attr.fill]=\"getNodeBg(node)\"\n [attr.stroke]=\"selectedNodeId() === node.id ? 'var(--color-primary-500)' : getNodeBorder(node)\"\n stroke-width=\"1.5\"\n />\n <text\n [attr.x]=\"node.x + node.width / 2\"\n [attr.y]=\"node.y + node.height / 2 + 4\"\n text-anchor=\"middle\"\n [attr.fill]=\"getNodeText(node)\"\n class=\"dep-viewer__node-label\"\n >{{ node.label }}</text>\n\n <!-- \"+N\" marker: this node has more neighbours than are shown -->\n @if (node.moreCount) {\n <g class=\"dep-viewer__more\">\n <rect\n [attr.x]=\"node.x + node.width - 26\"\n [attr.y]=\"node.y - 8\"\n width=\"32\" height=\"16\" rx=\"8\" ry=\"8\"\n />\n <text\n [attr.x]=\"node.x + node.width - 10\"\n [attr.y]=\"node.y + 3\"\n text-anchor=\"middle\"\n >+{{ node.moreCount }}</text>\n </g>\n }\n\n <!-- Collapse/expand toggle -->\n @if (hasChildren(node.id)) {\n <g\n class=\"dep-viewer__toggle\"\n (click)=\"toggleCollapse(node.id, $event)\"\n >\n @if (direction() === 'horizontal') {\n <circle\n [attr.cx]=\"node.x + node.width + 10\"\n [attr.cy]=\"node.y + node.height / 2\"\n r=\"8\"\n fill=\"var(--color-neutral-100)\" stroke=\"var(--color-neutral-300)\" stroke-width=\"1\"\n />\n <text\n [attr.x]=\"node.x + node.width + 10\"\n [attr.y]=\"node.y + node.height / 2 + 4\"\n text-anchor=\"middle\"\n class=\"dep-viewer__toggle-text\"\n >{{ isCollapsed(node.id) ? '+' : '\u2212' }}</text>\n } @else {\n <circle\n [attr.cx]=\"node.x + node.width / 2\"\n [attr.cy]=\"node.y + node.height + 10\"\n r=\"8\"\n fill=\"var(--color-neutral-100)\" stroke=\"var(--color-neutral-300)\" stroke-width=\"1\"\n />\n <text\n [attr.x]=\"node.x + node.width / 2\"\n [attr.y]=\"node.y + node.height + 14\"\n text-anchor=\"middle\"\n class=\"dep-viewer__toggle-text\"\n >{{ isCollapsed(node.id) ? '+' : '\u2212' }}</text>\n }\n </g>\n }\n </g>\n }\n\n <!-- Edge labels last: SVG paints in document order, so anything drawn before\n the nodes is covered by them. A label sits at the midpoint of its edge,\n which frequently lands on a node \u2014 it has to come after. The halo (see\n the stylesheet) keeps it legible over whatever it lands on. -->\n @if (showEdgeLabels()) {\n @for (edge of graph().edges; track edge.id) {\n @if (edge.label) {\n <text\n [attr.x]=\"edge.labelX\"\n [attr.y]=\"edge.labelY\"\n class=\"dep-viewer__edge-label\"\n [attr.fill]=\"edge.color\"\n text-anchor=\"middle\"\n >{{ edge.label }}</text>\n }\n }\n }\n </svg>\n</div>\n\n<!-- Legend -->\n@if (legendItems().length || typeLegendItems().length) {\n <div class=\"dep-viewer__legend\">\n @for (item of legendItems(); track item.label) {\n <span class=\"dep-viewer__legend-item\">\n <svg width=\"24\" height=\"10\">\n <line\n x1=\"0\" y1=\"5\" x2=\"24\" y2=\"5\"\n [attr.stroke]=\"item.color\"\n stroke-width=\"2\"\n [attr.stroke-dasharray]=\"item.dashed ? '4 2' : 'none'\"\n />\n </svg>\n <span class=\"dep-viewer__legend-text\">{{ item.label }}</span>\n </span>\n }\n @for (item of typeLegendItems(); track item.type) {\n <span class=\"dep-viewer__legend-item\">\n <svg width=\"10\" height=\"10\">\n <rect width=\"10\" height=\"10\" rx=\"2\" ry=\"2\" [attr.fill]=\"item.color\" />\n </svg>\n <span class=\"dep-viewer__legend-text\">{{ item.type }}</span>\n </span>\n }\n </div>\n}\n\n<!-- Detail panel -->\n@if (selectedNode(); as node) {\n <div class=\"dep-viewer__detail\">\n <div class=\"dep-viewer__detail-header\">\n <span class=\"dep-viewer__detail-status dep-viewer__detail-status--{{ node.status }}\">\u25CF</span>\n <strong>{{ node.label }}</strong>\n @if (node.type) {\n <span class=\"dep-viewer__detail-type\">{{ node.type }}</span>\n }\n </div>\n @if (node.description) {\n <p class=\"dep-viewer__detail-desc\">{{ node.description }}</p>\n }\n <div class=\"dep-viewer__detail-meta\">\n <span>Depth: {{ node.depth }}</span>\n <span>Children: {{ node.outgoingCount }}</span>\n <span>Depends on: {{ node.incomingCount }}</span>\n </div>\n @if (selectedDependsOn().length) {\n <div class=\"dep-viewer__detail-deps\">\n <strong>Dependencies:</strong>\n <ul>\n @for (dep of selectedDependsOn(); track dep.id) {\n <li>\n <span class=\"dep-viewer__dep-relation\">{{\n dep.relationLabel || getRelationLabel(dep.relation || 'depends')\n }}</span>\n \u2192 {{ dep.id }}\n </li>\n }\n </ul>\n </div>\n }\n </div>\n}\n", styles: [":host{display:flex;flex-direction:column;position:relative;overflow:hidden;border:1px solid var(--color-border);border-radius:8px;background:var(--color-surface)}.dep-viewer__toolbar{display:flex;align-items:center;gap:4px;padding:8px 12px;border-bottom:1px solid var(--color-border);background:var(--color-surface-2);-webkit-user-select:none;user-select:none}.dep-viewer__btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;border:1px solid var(--color-border);border-radius:4px;background:var(--color-surface);color:var(--color-text-secondary);cursor:pointer;font-size:14px;line-height:1}.dep-viewer__btn:hover{background:var(--color-surface-hover)}.dep-viewer__btn--reset{margin-left:4px}.dep-viewer__zoom{font-size:12px;min-width:40px;text-align:center;color:var(--color-text-secondary)}.dep-viewer__direction-label{margin-left:8px;font-size:14px;color:var(--color-text-tertiary)}.dep-viewer__canvas{cursor:grab;overflow:hidden;width:100%;flex:1 1 auto;min-height:0}.dep-viewer__canvas:active{cursor:grabbing}.dep-viewer__canvas--static,.dep-viewer__canvas--static:active{cursor:default}.dep-viewer__canvas--sized{flex:0 0 auto}.dep-viewer__svg{transform-origin:0 0;transition:transform .1s ease-out}.dep-viewer__edge{pointer-events:none;transition:opacity .15s}.dep-viewer__edge--cross-ref{opacity:.8}.dep-viewer__edge-label{font-size:9px;font-weight:500;pointer-events:none;-webkit-user-select:none;user-select:none;dominant-baseline:central;paint-order:stroke;stroke:var(--color-surface);stroke-width:3px;stroke-linejoin:round}.dep-viewer__node{cursor:pointer;transition:filter .15s}.dep-viewer__node:hover{filter:brightness(.96)}.dep-viewer__node--selected rect{stroke-width:2.5}.dep-viewer__node-backdrop{fill:var(--color-surface)}.dep-viewer__node-label{font-size:12px;font-weight:500;pointer-events:none;-webkit-user-select:none;user-select:none}.dep-viewer__toggle{cursor:pointer}.dep-viewer__toggle:hover circle{fill:var(--color-surface-hover)}.dep-viewer__more{pointer-events:none}.dep-viewer__more rect{fill:var(--color-surface-2);stroke:var(--color-border-strong);stroke-width:1}.dep-viewer__more text{font-size:9px;font-weight:600;fill:var(--color-text-secondary);-webkit-user-select:none;user-select:none}.dep-viewer__toggle-text{font-size:11px;font-weight:600;fill:var(--color-text-secondary);pointer-events:none;-webkit-user-select:none;user-select:none}.dep-viewer__legend{display:flex;flex-wrap:wrap;gap:12px;padding:6px 12px;border-top:1px solid var(--color-border);background:var(--color-surface-2)}.dep-viewer__legend-item{display:flex;align-items:center;gap:4px}.dep-viewer__legend-text{font-size:11px;color:var(--color-text-secondary)}.dep-viewer__detail{position:absolute;bottom:12px;right:12px;width:240px;padding:12px;background:var(--color-surface-2);border:1px solid var(--color-border);border-radius:8px;box-shadow:var(--elevation-1);font-size:12px;z-index:10}.dep-viewer__detail-header{display:flex;align-items:center;gap:6px;margin-bottom:4px;font-size:13px}.dep-viewer__detail-type{margin-left:auto;padding:1px 6px;border-radius:3px;background:var(--color-surface-hover);color:var(--color-text-secondary);font-size:10px;font-weight:500}.dep-viewer__detail-status{font-size:10px}.dep-viewer__detail-status--default{color:var(--color-text-tertiary)}.dep-viewer__detail-status--active{color:var(--color-primary-400)}.dep-viewer__detail-status--success{color:var(--color-success-default)}.dep-viewer__detail-status--warning{color:var(--color-warning-default)}.dep-viewer__detail-status--error{color:var(--color-error-default)}.dep-viewer__detail-status--muted{color:var(--color-text-disabled)}.dep-viewer__detail-desc{color:var(--color-text-secondary);margin:0 0 6px;line-height:1.4}.dep-viewer__detail-meta{display:flex;flex-wrap:wrap;gap:8px;color:var(--color-text-tertiary);font-size:11px}.dep-viewer__detail-deps{margin-top:8px;padding-top:8px;border-top:1px solid var(--color-border)}.dep-viewer__detail-deps ul{list-style:none;margin:4px 0 0;padding:0}.dep-viewer__detail-deps li{padding:2px 0;color:var(--color-text-secondary)}.dep-viewer__dep-relation{display:inline-block;padding:1px 4px;background:var(--color-surface-hover);border-radius:3px;font-size:10px;font-weight:500;color:var(--color-text-secondary)}\n"] }]
14857
+ }], ctorParameters: () => [], propDecorators: { root: [{ type: i0.Input, args: [{ isSignal: true, alias: "root", required: true }] }], direction: [{ type: i0.Input, args: [{ isSignal: true, alias: "direction", required: false }] }], height: [{ type: i0.Input, args: [{ isSignal: true, alias: "height", required: false }] }], layout: [{ type: i0.Input, args: [{ isSignal: true, alias: "layout", required: false }] }], showToolbar: [{ type: i0.Input, args: [{ isSignal: true, alias: "showToolbar", required: false }] }], showEdgeLabels: [{ type: i0.Input, args: [{ isSignal: true, alias: "showEdgeLabels", required: false }] }], edgeWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "edgeWidth", required: false }] }], autoFit: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoFit", required: false }] }], fitMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "fitMode", required: false }] }], minNodeSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "minNodeSize", required: false }] }], interactive: [{ type: i0.Input, args: [{ isSignal: true, alias: "interactive", required: false }] }], anchorNodeId: [{ type: i0.Input, args: [{ isSignal: true, alias: "anchorNodeId", required: false }] }], typeColors: [{ type: i0.Input, args: [{ isSignal: true, alias: "typeColors", required: false }] }], hiddenRelations: [{ type: i0.Input, args: [{ isSignal: true, alias: "hiddenRelations", required: false }] }], hiddenTypes: [{ type: i0.Input, args: [{ isSignal: true, alias: "hiddenTypes", required: false }] }], nodeSelect: [{ type: i0.Output, args: ["nodeSelect"] }], nodeExpand: [{ type: i0.Output, args: ["nodeExpand"] }], canvasRef: [{ type: i0.ViewChild, args: ['canvas', { isSignal: true }] }], onMouseMove: [{
14344
14858
  type: HostListener,
14345
14859
  args: ['document:mousemove', ['$event']]
14346
14860
  }], onMouseUp: [{