@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,
|
|
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,
|
|
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`
|
|
13872
|
-
*
|
|
13873
|
-
*
|
|
13874
|
-
*
|
|
13875
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
14408
|
+
for (const root of this.roots())
|
|
14409
|
+
collectAllNodes(root, map);
|
|
13936
14410
|
return map;
|
|
13937
14411
|
}, ...(ngDevMode ? [{ debugName: "allOriginalNodes" }] : /* istanbul ignore next */ []));
|
|
13938
|
-
|
|
13939
|
-
|
|
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
|
-
|
|
13950
|
-
|
|
13951
|
-
|
|
13952
|
-
|
|
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' ?
|
|
13972
|
-
height: dir === 'horizontal' ?
|
|
14472
|
+
width: dir === 'horizontal' ? primary : secondary,
|
|
14473
|
+
height: dir === 'horizontal' ? secondary : primary,
|
|
13973
14474
|
};
|
|
13974
|
-
}
|
|
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.
|
|
14505
|
+
const nodeMap = this.graph().nodeMap;
|
|
13983
14506
|
const anchorId = this.anchorNodeId() ?? this.selectedNodeId();
|
|
13984
|
-
|
|
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.
|
|
14009
|
-
svgHeight = computed(() => this.
|
|
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.
|
|
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.
|
|
14033
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
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: [{
|