@life-cockpit/angular-ui-kit 2.14.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.
@@ -13503,6 +13503,15 @@ const BACK_EDGE_BOW = 36;
13503
13503
  const ROUTE_SAMPLES = 24;
13504
13504
  /** Ignore grazing the very edge of a box — only a real incursion counts. */
13505
13505
  const ROUTE_INSET = 3;
13506
+ /** Breathing room around the graph's bounding box when auto-fitting, in layout units. */
13507
+ const FIT_PADDING = 24;
13508
+ /**
13509
+ * Auto-fit shrinks to fit but never enlarges: blowing a two-node graph up to fill
13510
+ * a 600px frame reads as broken, not as "fitted".
13511
+ */
13512
+ const MAX_FIT_SCALE = 1;
13513
+ /** Floor for the fit scale, so a huge graph in a tiny box stays a graph, not a smear. */
13514
+ const MIN_FIT_SCALE = 0.05;
13506
13515
  const RELATION_STYLES = {
13507
13516
  depends: { color: 'var(--color-neutral-400)', dashed: false, label: 'depends on' },
13508
13517
  blocks: { color: 'var(--color-error-default)', dashed: false, label: 'blocks' },
@@ -13572,7 +13581,7 @@ function layoutTreeH(measure, x, y, parentId, nodes, edges, allNodes, nodeMap) {
13572
13581
  path: `M ${sx} ${sy} C ${mx} ${sy}, ${mx} ${ty}, ${tx} ${ty}`,
13573
13582
  labelX: mx, labelY: (sy + ty) / 2,
13574
13583
  color: 'var(--color-neutral-300)', dashed: false, isCrossRef: false,
13575
- marker: 'depends',
13584
+ arrow: false, marker: 'depends',
13576
13585
  });
13577
13586
  }
13578
13587
  let childY = y;
@@ -13605,7 +13614,7 @@ function layoutTreeV(measure, x, y, parentId, nodes, edges, allNodes, nodeMap) {
13605
13614
  path: `M ${sx} ${sy} C ${sx} ${my}, ${tx} ${my}, ${tx} ${ty}`,
13606
13615
  labelX: (sx + tx) / 2, labelY: my,
13607
13616
  color: 'var(--color-neutral-300)', dashed: false, isCrossRef: false,
13608
- marker: 'depends',
13617
+ arrow: false, marker: 'depends',
13609
13618
  });
13610
13619
  }
13611
13620
  let childX = x;
@@ -13775,7 +13784,8 @@ function createCrossRefEdges(nodeMap, allOriginal, dir, backEdges) {
13775
13784
  path: geo.path, relation: dep.relation,
13776
13785
  label: dep.relationLabel ?? style.label,
13777
13786
  labelX: geo.labelX, labelY: geo.labelY,
13778
- color: style.color, dashed: style.dashed, isCrossRef: true, marker: relation,
13787
+ color: style.color, dashed: style.dashed, isCrossRef: true,
13788
+ arrow: true, marker: relation,
13779
13789
  });
13780
13790
  }
13781
13791
  }
@@ -13793,11 +13803,367 @@ function createCrossRefEdges(nodeMap, allOriginal, dir, backEdges) {
13793
13803
  edges.push({
13794
13804
  id: `${back.from}↺${back.to}`, sourceId: back.from, targetId: back.to,
13795
13805
  path: geo.path, labelX: geo.labelX, labelY: geo.labelY,
13796
- color: style.color, dashed: true, isCrossRef: true, marker: 'depends',
13806
+ color: style.color, dashed: true, isCrossRef: true,
13807
+ arrow: true, marker: 'depends',
13797
13808
  });
13798
13809
  }
13799
13810
  return edges;
13800
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
+ }
13801
14167
  // ── Status colors ────────────────────────────────────────────────────────────
13802
14168
  // Labels read from the theme's semantic ink, not from a `--color-<status>-700`.
13803
14169
  // Those resolve to dark ink in both themes (error-700 is #6b0909), which on the
@@ -13852,6 +14218,8 @@ function readableInk(color) {
13852
14218
  * Dependency viewer component for visualizing hierarchical and cross-cutting relationships.
13853
14219
  *
13854
14220
  * Features:
14221
+ * - Tree layout (depth in the `children` nesting) and layered/DAG layout (depth in
14222
+ * the dependency graph), via `layout`
13855
14223
  * - Horizontal (left-to-right) and vertical (top-to-bottom) layout directions
13856
14224
  * - Bidirectional dependencies: children (right/down) and dependsOn (cross-references)
13857
14225
  * - Relationship types: depends, blocks, references, requires, extends, implements, uses
@@ -13860,6 +14228,8 @@ function readableInk(color) {
13860
14228
  * - Color-coded edges per relationship type
13861
14229
  * - Node status colors (default, active, success, warning, error, muted)
13862
14230
  * - Pan and zoom controls with mouse wheel support
14231
+ * - Fitting (`fitMode` / `autoFit`, `minNodeSize`) and a static, non-navigable
14232
+ * mode (`interactive: false`)
13863
14233
  * - Collapsible sub-trees
13864
14234
  * - Interactive node selection with detail panel
13865
14235
  * - Legend showing active relationship types
@@ -13868,11 +14238,24 @@ function readableInk(color) {
13868
14238
  *
13869
14239
  * ## Feeding it a graph
13870
14240
  *
13871
- * `root` is a *tree*, but the input is treated as a graph: a link back to a node
13872
- * that already has a place in the layout a cycle, or a node reached from a
13873
- * second parent is drawn as a cross-reference arrow instead of being followed.
13874
- * Every node is laid out exactly once, and no input can make the layout recurse
13875
- * 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.
13876
14259
  *
13877
14260
  * For incremental exploration, hand in a wider `root` per step and let
13878
14261
  * `nodeExpand` drive the loading. Pan, zoom, collapse state and selection all
@@ -13884,6 +14267,28 @@ function readableInk(color) {
13884
14267
  * <lc-dependency-viewer [root]="specTree" direction="horizontal" />
13885
14268
  * ```
13886
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
+ *
14281
+ * @example Static overview — the whole graph at a glance, click-through only
14282
+ * ```html
14283
+ * <lc-dependency-viewer
14284
+ * [root]="graph()"
14285
+ * [autoFit]="true"
14286
+ * [interactive]="false"
14287
+ * height="360px"
14288
+ * (nodeSelect)="openDetail($event)"
14289
+ * />
14290
+ * ```
14291
+ *
13887
14292
  * @example Incremental graph exploration
13888
14293
  * ```html
13889
14294
  * <lc-dependency-viewer
@@ -13896,12 +14301,69 @@ function readableInk(color) {
13896
14301
  * ```
13897
14302
  */
13898
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
+ */
13899
14309
  root = input.required(...(ngDevMode ? [{ debugName: "root" }] : /* istanbul ignore next */ []));
13900
14310
  direction = input('horizontal', ...(ngDevMode ? [{ debugName: "direction" }] : /* istanbul ignore next */ []));
13901
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 */ []));
13902
14326
  showToolbar = input(true, ...(ngDevMode ? [{ debugName: "showToolbar" }] : /* istanbul ignore next */ []));
13903
14327
  showEdgeLabels = input(true, ...(ngDevMode ? [{ debugName: "showEdgeLabels" }] : /* istanbul ignore next */ []));
13904
14328
  edgeWidth = input(1.5, ...(ngDevMode ? [{ debugName: "edgeWidth" }] : /* istanbul ignore next */ []));
14329
+ /**
14330
+ * Scales and centres the graph so that all of it fits the canvas, and re-fits
14331
+ * whenever the container or the graph changes size. Turns the viewer into an
14332
+ * overview: nothing to pan or zoom to, everything visible at once.
14333
+ *
14334
+ * Shrinks only — the fit never enlarges past 100%, so a two-node graph keeps its
14335
+ * natural size instead of ballooning to fill the frame.
14336
+ *
14337
+ * Pair with `interactive: false` for a purely static display.
14338
+ *
14339
+ * Shorthand for `fitMode="contain"`, which supersedes it.
14340
+ */
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 */ []));
14361
+ /**
14362
+ * Viewport navigation: drag-to-pan, wheel-zoom and the toolbar's zoom buttons.
14363
+ * `false` freezes the viewport — node selection, expansion and collapse keep
14364
+ * working, so `nodeSelect` still fires in a static viewer.
14365
+ */
14366
+ interactive = input(true, ...(ngDevMode ? [{ debugName: "interactive" }] : /* istanbul ignore next */ []));
13905
14367
  /**
13906
14368
  * Node the viewport holds still across `root` updates. Defaults to the node the
13907
14369
  * user last selected, which keeps click-to-expand steady without any wiring.
@@ -13927,39 +14389,45 @@ class DependencyViewerComponent {
13927
14389
  lastMouseX = 0;
13928
14390
  lastMouseY = 0;
13929
14391
  hostEl = inject((ElementRef));
14392
+ canvasRef = viewChild('canvas', ...(ngDevMode ? [{ debugName: "canvasRef" }] : /* istanbul ignore next */ []));
13930
14393
  /** Last known layout position of the anchor, to compensate pan after a relayout. */
13931
14394
  anchorPos = null;
13932
- 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 */ []));
13933
14406
  allOriginalNodes = computed(() => {
13934
14407
  const map = new Map();
13935
- collectAllNodes(this.root(), map);
14408
+ for (const root of this.roots())
14409
+ collectAllNodes(root, map);
13936
14410
  return map;
13937
14411
  }, ...(ngDevMode ? [{ debugName: "allOriginalNodes" }] : /* istanbul ignore next */ []));
13938
- layout = computed(() => {
13939
- 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(() => {
13940
14417
  const dir = this.direction();
13941
14418
  const hiddenTypes = new Set(this.hiddenTypes());
13942
14419
  const hiddenRelations = new Set(this.hiddenRelations());
13943
- const backEdges = [];
13944
- const measured = measureTree(root, 0, dir, new Set(), backEdges);
13945
- const nodes = [];
13946
- const nodeMap = new Map();
13947
- const treeEdges = [];
13948
14420
  const allNodes = this.allOriginalNodes();
13949
- if (dir === 'horizontal') {
13950
- layoutTreeH(measured, 0, 0, null, nodes, treeEdges, allNodes, nodeMap);
13951
- }
13952
- else {
13953
- layoutTreeV(measured, 0, 0, null, nodes, treeEdges, allNodes, nodeMap);
13954
- }
13955
- const crossEdges = createCrossRefEdges(nodeMap, allNodes, dir, backEdges);
13956
- 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)));
13957
14425
  // Type filtering hides nodes and anything still pointing at them. It runs
13958
14426
  // after layout so the remaining nodes keep the positions they'd have anyway —
13959
14427
  // toggling a filter must not reshuffle the whole graph.
13960
14428
  const visibleNodes = hiddenTypes.size
13961
- ? nodes.filter(n => !(n.type && hiddenTypes.has(n.type)))
13962
- : nodes;
14429
+ ? placed.nodes.filter(n => !(n.type && hiddenTypes.has(n.type)))
14430
+ : placed.nodes;
13963
14431
  const visibleIds = new Set(visibleNodes.map(n => n.id));
13964
14432
  const visibleEdges = hiddenTypes.size
13965
14433
  ? edges.filter(e => visibleIds.has(e.sourceId) && visibleIds.has(e.targetId))
@@ -13967,11 +14435,66 @@ class DependencyViewerComponent {
13967
14435
  return {
13968
14436
  nodes: visibleNodes,
13969
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)],
13970
14471
  nodeMap,
13971
- width: dir === 'horizontal' ? measured.primary : measured.secondary,
13972
- height: dir === 'horizontal' ? measured.secondary : measured.primary,
14472
+ width: dir === 'horizontal' ? primary : secondary,
14473
+ height: dir === 'horizontal' ? secondary : primary,
13973
14474
  };
13974
- }, ...(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 */ []));
13975
14498
  constructor() {
13976
14499
  // Keep the anchor node pinned to its screen position across relayouts. A new
13977
14500
  // `root` (an expand step) re-runs the tree layout from scratch, which recentres
@@ -13979,9 +14502,11 @@ class DependencyViewerComponent {
13979
14502
  // graph slides underneath it. Compensating the pan by the anchor's own delta is
13980
14503
  // what makes incremental expansion feel stable.
13981
14504
  effect(() => {
13982
- const nodeMap = this.layout().nodeMap;
14505
+ const nodeMap = this.graph().nodeMap;
13983
14506
  const anchorId = this.anchorNodeId() ?? this.selectedNodeId();
13984
- if (!anchorId) {
14507
+ // Fitting recentres the whole graph on every relayout, which subsumes
14508
+ // holding one node still. Running both would have them fight over the pan.
14509
+ if (this.effectiveFitMode() !== 'none' || !anchorId) {
13985
14510
  this.anchorPos = null;
13986
14511
  return;
13987
14512
  }
@@ -14004,19 +14529,49 @@ class DependencyViewerComponent {
14004
14529
  }
14005
14530
  this.anchorPos = { id: anchorId, x: node.x, y: node.y };
14006
14531
  });
14532
+ // Re-fit when the graph changes shape. Reading `canvasRef` here (rather than
14533
+ // only inside `fit`) is what gets the *first* fit to run: the view child
14534
+ // resolves after the initial render, which re-triggers this effect at the
14535
+ // point the canvas finally has a measurable size.
14536
+ effect(() => {
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())
14544
+ return;
14545
+ this.graph();
14546
+ this.minFitScale();
14547
+ untracked(() => this.fit());
14548
+ });
14549
+ // …and when the container changes size. ResizeObserver delivers an initial
14550
+ // callback on observe, so this covers the first fit in a browser too.
14551
+ effect(onCleanup => {
14552
+ const el = this.canvasRef()?.nativeElement;
14553
+ if (this.effectiveFitMode() === 'none' || !el || typeof ResizeObserver === 'undefined')
14554
+ return;
14555
+ const observer = new ResizeObserver(() => this.fit());
14556
+ observer.observe(el);
14557
+ onCleanup(() => observer.disconnect());
14558
+ });
14007
14559
  }
14008
- svgWidth = computed(() => this.layout().width + 80, ...(ngDevMode ? [{ debugName: "svgWidth" }] : /* istanbul ignore next */ []));
14009
- 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 */ []));
14010
14562
  viewBox = computed(() => `0 0 ${this.svgWidth()} ${this.svgHeight()}`, ...(ngDevMode ? [{ debugName: "viewBox" }] : /* istanbul ignore next */ []));
14011
14563
  transform = computed(() => {
14012
14564
  const z = this.zoom() / 100;
14013
14565
  return `translate(${this.panX()}px, ${this.panY()}px) scale(${z})`;
14014
14566
  }, ...(ngDevMode ? [{ debugName: "transform" }] : /* istanbul ignore next */ []));
14567
+ // A fitted scale is an arbitrary fraction; the readout is not the source of
14568
+ // truth, so it rounds rather than printing "63.41999999%".
14569
+ zoomLabel = computed(() => Math.round(this.zoom()), ...(ngDevMode ? [{ debugName: "zoomLabel" }] : /* istanbul ignore next */ []));
14015
14570
  selectedNode = computed(() => {
14016
14571
  const id = this.selectedNodeId();
14017
14572
  if (!id)
14018
14573
  return null;
14019
- return this.layout().nodes.find(n => n.id === id) ?? null;
14574
+ return this.graph().nodes.find(n => n.id === id) ?? null;
14020
14575
  }, ...(ngDevMode ? [{ debugName: "selectedNode" }] : /* istanbul ignore next */ []));
14021
14576
  selectedDependsOn = computed(() => {
14022
14577
  const id = this.selectedNodeId();
@@ -14029,8 +14584,10 @@ class DependencyViewerComponent {
14029
14584
  // of built-in relation types they borrow their colour from.
14030
14585
  legendItems = computed(() => {
14031
14586
  const seen = new Map();
14032
- for (const edge of this.layout().edges) {
14033
- 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))
14034
14591
  continue;
14035
14592
  seen.set(edge.label, { label: edge.label, color: edge.color, dashed: edge.dashed });
14036
14593
  }
@@ -14039,7 +14596,7 @@ class DependencyViewerComponent {
14039
14596
  typeLegendItems = computed(() => {
14040
14597
  const colors = this.typeColors();
14041
14598
  const seen = new Map();
14042
- for (const node of this.layout().nodes) {
14599
+ for (const node of this.graph().nodes) {
14043
14600
  if (!node.type || seen.has(node.type) || !colors[node.type])
14044
14601
  continue;
14045
14602
  seen.set(node.type, { type: node.type, color: colors[node.type] });
@@ -14107,11 +14664,11 @@ class DependencyViewerComponent {
14107
14664
  // expand/collapse. Mirrors lc-tree-view's expandAll()/collapseAll() surface.
14108
14665
  /** Centres the viewport on a node. No-op for an id that isn't laid out. */
14109
14666
  focusNode(id) {
14110
- const node = this.layout().nodeMap.get(id);
14667
+ const node = this.graph().nodeMap.get(id);
14111
14668
  if (!node)
14112
14669
  return;
14113
14670
  const z = this.zoom() / 100;
14114
- const el = this.hostEl.nativeElement.querySelector('.dep-viewer__canvas');
14671
+ const el = this.canvasEl();
14115
14672
  const viewW = el?.clientWidth ?? 0;
14116
14673
  const viewH = el?.clientHeight ?? 0;
14117
14674
  // Invert screen = pan + z * layout for the node's centre.
@@ -14119,8 +14676,62 @@ class DependencyViewerComponent {
14119
14676
  this.panY.set(viewH / 2 - (node.y + node.height / 2) * z);
14120
14677
  this.anchorPos = { id, x: node.x, y: node.y };
14121
14678
  }
14122
- /** Restores the initial zoom and pan. */
14679
+ /**
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.
14682
+ *
14683
+ * No-op while the canvas has no size (before the first render, or in a detached
14684
+ * / `display: none` container) — the fit observers re-run once it does.
14685
+ */
14686
+ fit() {
14687
+ const mode = this.effectiveFitMode();
14688
+ if (mode === 'none')
14689
+ return;
14690
+ const el = this.canvasEl();
14691
+ const viewW = el?.clientWidth ?? 0;
14692
+ const viewH = el?.clientHeight ?? 0;
14693
+ if (!viewW || !viewH)
14694
+ return;
14695
+ const box = this.contentBox();
14696
+ if (box.width <= 0 || box.height <= 0)
14697
+ return;
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));
14706
+ this.zoom.set(scale * 100);
14707
+ // Invert screen = pan + scale * layout for the box's top-left corner, so the
14708
+ // box lands centred in the canvas.
14709
+ this.panX.set((viewW - box.width * scale) / 2 - box.x * 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
+ }
14722
+ // The camera just moved deliberately; a stale anchor baseline would have the
14723
+ // next relayout "correct" a position this fit had already chosen.
14724
+ this.anchorPos = null;
14725
+ }
14726
+ /**
14727
+ * Restores the initial view — the fitted one under `autoFit`, otherwise the
14728
+ * default zoom and pan.
14729
+ */
14123
14730
  resetView() {
14731
+ if (this.effectiveFitMode() !== 'none') {
14732
+ this.fit();
14733
+ return;
14734
+ }
14124
14735
  this.zoom.set(100);
14125
14736
  this.panX.set(40);
14126
14737
  this.panY.set(40);
@@ -14158,8 +14769,47 @@ class DependencyViewerComponent {
14158
14769
  }
14159
14770
  this.collapsedIds.set(ids);
14160
14771
  }
14772
+ // ── Viewport measurement ───────────────────────────────────────────────────
14773
+ canvasEl() {
14774
+ return (this.canvasRef()?.nativeElement ??
14775
+ this.hostEl.nativeElement.querySelector('.dep-viewer__canvas'));
14776
+ }
14777
+ /**
14778
+ * Bounding box of everything drawn, in layout units.
14779
+ *
14780
+ * Edge label points stand in for the edges themselves. A parent→child edge stays
14781
+ * inside the node boxes anyway, and a bowed cross-reference puts its label at the
14782
+ * apex of the bow — the only part of it that reaches outside them.
14783
+ */
14784
+ contentBox() {
14785
+ const { nodes, edges } = this.graph();
14786
+ if (!nodes.length)
14787
+ return { x: 0, y: 0, width: 0, height: 0 };
14788
+ let minX = Infinity;
14789
+ let minY = Infinity;
14790
+ let maxX = -Infinity;
14791
+ let maxY = -Infinity;
14792
+ for (const n of nodes) {
14793
+ minX = Math.min(minX, n.x);
14794
+ minY = Math.min(minY, n.y);
14795
+ maxX = Math.max(maxX, n.x + n.width);
14796
+ maxY = Math.max(maxY, n.y + n.height);
14797
+ }
14798
+ for (const e of edges) {
14799
+ minX = Math.min(minX, e.labelX);
14800
+ minY = Math.min(minY, e.labelY);
14801
+ maxX = Math.max(maxX, e.labelX);
14802
+ maxY = Math.max(maxY, e.labelY);
14803
+ }
14804
+ return {
14805
+ x: minX - FIT_PADDING,
14806
+ y: minY - FIT_PADDING,
14807
+ width: maxX - minX + FIT_PADDING * 2,
14808
+ height: maxY - minY + FIT_PADDING * 2,
14809
+ };
14810
+ }
14161
14811
  onMouseDown(event) {
14162
- if (event.button !== 0)
14812
+ if (!this.interactive() || event.button !== 0)
14163
14813
  return;
14164
14814
  this.isPanning = true;
14165
14815
  this.lastMouseX = event.clientX;
@@ -14175,6 +14825,10 @@ class DependencyViewerComponent {
14175
14825
  }
14176
14826
  onMouseUp() { this.isPanning = false; }
14177
14827
  onWheel(event) {
14828
+ // Return *before* preventDefault: a static viewer must not swallow the wheel
14829
+ // event, or the page can't be scrolled past it.
14830
+ if (!this.interactive())
14831
+ return;
14178
14832
  event.preventDefault();
14179
14833
  this.zoom.update(z => event.deltaY < 0 ? Math.min(z + 10, 300) : Math.max(z - 10, 25));
14180
14834
  }
@@ -14195,12 +14849,12 @@ class DependencyViewerComponent {
14195
14849
  return typeColor ? readableInk(typeColor) : STATUS_COLORS[node.status].text;
14196
14850
  }
14197
14851
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.10", ngImport: i0, type: DependencyViewerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
14198
- 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 }, 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()" } }, ngImport: i0, template: "<!-- Toolbar -->\n@if (showToolbar()) {\n <div class=\"dep-viewer__toolbar\">\n <button class=\"dep-viewer__btn\" (click)=\"zoomIn()\" title=\"Zoom in\">+</button>\n <span class=\"dep-viewer__zoom\">{{ zoom() }}%</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 <span class=\"dep-viewer__direction-label\">{{ direction() === 'horizontal' ? '\u2192' : '\u2193' }}</span>\n </div>\n}\n\n<!-- Canvas -->\n<div\n class=\"dep-viewer__canvas\"\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:block;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%;height:100%}.dep-viewer__canvas:active{cursor:grabbing}.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 });
14199
14853
  }
14200
14854
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.10", ngImport: i0, type: DependencyViewerComponent, decorators: [{
14201
14855
  type: Component,
14202
- args: [{ selector: 'lc-dependency-viewer', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, host: { '[class]': '"dep-viewer"', '[style.height]': 'height()' }, template: "<!-- Toolbar -->\n@if (showToolbar()) {\n <div class=\"dep-viewer__toolbar\">\n <button class=\"dep-viewer__btn\" (click)=\"zoomIn()\" title=\"Zoom in\">+</button>\n <span class=\"dep-viewer__zoom\">{{ zoom() }}%</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 <span class=\"dep-viewer__direction-label\">{{ direction() === 'horizontal' ? '\u2192' : '\u2193' }}</span>\n </div>\n}\n\n<!-- Canvas -->\n<div\n class=\"dep-viewer__canvas\"\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:block;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%;height:100%}.dep-viewer__canvas:active{cursor:grabbing}.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"] }]
14203
- }], 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 }] }], 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"] }], 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: [{
14204
14858
  type: HostListener,
14205
14859
  args: ['document:mousemove', ['$event']]
14206
14860
  }], onMouseUp: [{