ngwr 14.4.0 → 14.5.1
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.
- package/README.md +12 -6
- package/fesm2022/ngwr-breadcrumbs.mjs +2 -2
- package/fesm2022/ngwr-breadcrumbs.mjs.map +1 -1
- package/fesm2022/ngwr-cascader.mjs +15 -3
- package/fesm2022/ngwr-cascader.mjs.map +1 -1
- package/fesm2022/ngwr-color-picker.mjs +18 -2
- package/fesm2022/ngwr-color-picker.mjs.map +1 -1
- package/fesm2022/ngwr-context-menu.mjs +35 -3
- package/fesm2022/ngwr-context-menu.mjs.map +1 -1
- package/fesm2022/ngwr-date-picker.mjs +30 -2
- package/fesm2022/ngwr-date-picker.mjs.map +1 -1
- package/fesm2022/ngwr-dialog.mjs +12 -1
- package/fesm2022/ngwr-dialog.mjs.map +1 -1
- package/fesm2022/ngwr-drag-drop.mjs +2 -2
- package/fesm2022/ngwr-drag-drop.mjs.map +1 -1
- package/fesm2022/ngwr-drawer.mjs +48 -2
- package/fesm2022/ngwr-drawer.mjs.map +1 -1
- package/fesm2022/ngwr-dropdown.mjs +22 -2
- package/fesm2022/ngwr-dropdown.mjs.map +1 -1
- package/fesm2022/ngwr-graph-testing.mjs +243 -0
- package/fesm2022/ngwr-graph-testing.mjs.map +1 -0
- package/fesm2022/ngwr-graph.mjs +1112 -0
- package/fesm2022/ngwr-graph.mjs.map +1 -0
- package/fesm2022/ngwr-i18n-ar.mjs +5 -0
- package/fesm2022/ngwr-i18n-ar.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-cs.mjs +5 -0
- package/fesm2022/ngwr-i18n-cs.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-de.mjs +5 -0
- package/fesm2022/ngwr-i18n-de.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-en.mjs +25 -5
- package/fesm2022/ngwr-i18n-en.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-es.mjs +5 -0
- package/fesm2022/ngwr-i18n-es.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-fr.mjs +5 -0
- package/fesm2022/ngwr-i18n-fr.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-he.mjs +5 -0
- package/fesm2022/ngwr-i18n-he.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-hi.mjs +5 -0
- package/fesm2022/ngwr-i18n-hi.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-id.mjs +5 -0
- package/fesm2022/ngwr-i18n-id.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-it.mjs +5 -0
- package/fesm2022/ngwr-i18n-it.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-ja.mjs +5 -0
- package/fesm2022/ngwr-i18n-ja.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-ko.mjs +5 -0
- package/fesm2022/ngwr-i18n-ko.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-nl.mjs +5 -0
- package/fesm2022/ngwr-i18n-nl.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-pl.mjs +5 -0
- package/fesm2022/ngwr-i18n-pl.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-pt.mjs +5 -0
- package/fesm2022/ngwr-i18n-pt.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-ru.mjs +5 -0
- package/fesm2022/ngwr-i18n-ru.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-sv.mjs +5 -0
- package/fesm2022/ngwr-i18n-sv.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-tr.mjs +5 -0
- package/fesm2022/ngwr-i18n-tr.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-uk.mjs +5 -0
- package/fesm2022/ngwr-i18n-uk.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-vi.mjs +5 -0
- package/fesm2022/ngwr-i18n-vi.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-zh-TW.mjs +5 -0
- package/fesm2022/ngwr-i18n-zh-TW.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-zh.mjs +5 -0
- package/fesm2022/ngwr-i18n-zh.mjs.map +1 -1
- package/fesm2022/ngwr-i18n.mjs +13 -0
- package/fesm2022/ngwr-i18n.mjs.map +1 -1
- package/fesm2022/ngwr-lightbox.mjs +25 -4
- package/fesm2022/ngwr-lightbox.mjs.map +1 -1
- package/fesm2022/ngwr-list.mjs +2 -2
- package/fesm2022/ngwr-list.mjs.map +1 -1
- package/fesm2022/ngwr-mention.mjs +18 -2
- package/fesm2022/ngwr-mention.mjs.map +1 -1
- package/fesm2022/ngwr-overlay.mjs +119 -4
- package/fesm2022/ngwr-overlay.mjs.map +1 -1
- package/fesm2022/ngwr-popconfirm.mjs +19 -3
- package/fesm2022/ngwr-popconfirm.mjs.map +1 -1
- package/fesm2022/ngwr-popover-testing.mjs +22 -5
- package/fesm2022/ngwr-popover-testing.mjs.map +1 -1
- package/fesm2022/ngwr-popover.mjs +215 -27
- package/fesm2022/ngwr-popover.mjs.map +1 -1
- package/fesm2022/ngwr-segmented.mjs +34 -1
- package/fesm2022/ngwr-segmented.mjs.map +1 -1
- package/fesm2022/ngwr-select.mjs +15 -1
- package/fesm2022/ngwr-select.mjs.map +1 -1
- package/fesm2022/ngwr-table.mjs +10 -2
- package/fesm2022/ngwr-table.mjs.map +1 -1
- package/fesm2022/ngwr-toast.mjs +19 -2
- package/fesm2022/ngwr-toast.mjs.map +1 -1
- package/fesm2022/ngwr-tour.mjs +20 -6
- package/fesm2022/ngwr-tour.mjs.map +1 -1
- package/fesm2022/ngwr-tree.mjs +9 -2
- package/fesm2022/ngwr-tree.mjs.map +1 -1
- package/fesm2022/ngwr-version.mjs +1 -1
- package/fesm2022/ngwr-version.mjs.map +1 -1
- package/fesm2022/ngwr-window.mjs +18 -1
- package/fesm2022/ngwr-window.mjs.map +1 -1
- package/graph/styles/_index.scss +113 -0
- package/i18n/ar.json +5 -0
- package/i18n/cs.json +5 -0
- package/i18n/de.json +5 -0
- package/i18n/en.json +5 -0
- package/i18n/es.json +5 -0
- package/i18n/fr.json +5 -0
- package/i18n/he.json +5 -0
- package/i18n/hi.json +5 -0
- package/i18n/id.json +5 -0
- package/i18n/it.json +5 -0
- package/i18n/ja.json +5 -0
- package/i18n/ko.json +5 -0
- package/i18n/nl.json +5 -0
- package/i18n/pl.json +5 -0
- package/i18n/pt.json +5 -0
- package/i18n/ru.json +5 -0
- package/i18n/sv.json +5 -0
- package/i18n/tr.json +5 -0
- package/i18n/uk.json +5 -0
- package/i18n/vi.json +5 -0
- package/i18n/zh-TW.json +5 -0
- package/i18n/zh.json +5 -0
- package/llms-full.txt +13 -3
- package/llms.txt +20 -9
- package/mcp/providers.js +8 -2
- package/package.json +10 -1
- package/schematics/migrations/v14/index.js +90 -34
- package/schematics/migrations/v7/index.js +15 -4
- package/schematics/migrations/v8/index.js +13 -2
- package/schematics/migrations/v9/index.js +19 -10
- package/schematics/use/symbol-map.json +2 -0
- package/segmented/styles/_index.scss +8 -0
- package/skills/ngwr/SKILL.md +10 -5
- package/skills/ngwr/references/catalog.md +2 -0
- package/skills/ngwr/references/setup.md +2 -2
- package/styles.scss +1 -0
- package/table/styles/_index.scss +39 -12
- package/theme/styles/_focus.scss +7 -0
- package/types/ngwr-cascader.d.ts +7 -0
- package/types/ngwr-color-picker.d.ts +8 -0
- package/types/ngwr-context-menu.d.ts +10 -0
- package/types/ngwr-date-picker.d.ts +16 -0
- package/types/ngwr-dialog.d.ts +1 -0
- package/types/ngwr-drawer.d.ts +28 -0
- package/types/ngwr-dropdown.d.ts +9 -0
- package/types/ngwr-graph-testing.d.ts +190 -0
- package/types/ngwr-graph.d.ts +203 -0
- package/types/ngwr-i18n-en.d.ts +16 -5
- package/types/ngwr-i18n.d.ts +13 -0
- package/types/ngwr-lightbox.d.ts +15 -0
- package/types/ngwr-mention.d.ts +7 -0
- package/types/ngwr-overlay.d.ts +62 -4
- package/types/ngwr-popconfirm.d.ts +1 -0
- package/types/ngwr-popover-testing.d.ts +22 -5
- package/types/ngwr-popover.d.ts +28 -8
- package/types/ngwr-segmented.d.ts +16 -0
- package/types/ngwr-select.d.ts +7 -0
- package/types/ngwr-table.d.ts +8 -0
- package/types/ngwr-toast.d.ts +2 -0
- package/types/ngwr-tour.d.ts +6 -0
- package/types/ngwr-tree.d.ts +2 -1
- package/types/ngwr-version.d.ts +1 -1
- package/types/ngwr-window.d.ts +1 -0
|
@@ -0,0 +1,1112 @@
|
|
|
1
|
+
import { Directionality } from '@angular/cdk/bidi';
|
|
2
|
+
import { NgTemplateOutlet } from '@angular/common';
|
|
3
|
+
import * as i0 from '@angular/core';
|
|
4
|
+
import { inject, TemplateRef, Directive, input, contentChild, LOCALE_ID, computed, isDevMode, effect, ViewEncapsulation, Component } from '@angular/core';
|
|
5
|
+
import { useI18nText, useI18nFormatter, WrI18n } from 'ngwr/i18n';
|
|
6
|
+
import { numAttr } from 'ngwr/utils';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @license
|
|
10
|
+
*
|
|
11
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
12
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Replace the default card. The node is the implicit context; its box — the
|
|
16
|
+
* size the layout reserved, its position and the relation text read to a screen
|
|
17
|
+
* reader — stays with the graph, so a template cannot break the geometry.
|
|
18
|
+
*
|
|
19
|
+
* Render `node.label` somewhere in it: the relation text of every neighbour
|
|
20
|
+
* names this node by its label.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```html
|
|
24
|
+
* <wr-graph [nodes]="nodes" [edges]="edges">
|
|
25
|
+
* <ng-template wrGraphNode let-node>
|
|
26
|
+
* <strong>{{ node.label }}</strong> {{ node.data.role }}
|
|
27
|
+
* </ng-template>
|
|
28
|
+
* </wr-graph>
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
class WrGraphNodeTemplate {
|
|
32
|
+
template = inject(TemplateRef);
|
|
33
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: WrGraphNodeTemplate, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
34
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.6", type: WrGraphNodeTemplate, isStandalone: true, selector: "ng-template[wrGraphNode]", ngImport: i0 });
|
|
35
|
+
}
|
|
36
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: WrGraphNodeTemplate, decorators: [{
|
|
37
|
+
type: Directive,
|
|
38
|
+
args: [{
|
|
39
|
+
selector: 'ng-template[wrGraphNode]',
|
|
40
|
+
}]
|
|
41
|
+
}] });
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @license
|
|
45
|
+
*
|
|
46
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
47
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
48
|
+
*/
|
|
49
|
+
/** Gansner's cap. Counted in sweeps, never in time, so a slow machine draws the same picture. */
|
|
50
|
+
const MAX_SWEEPS = 24;
|
|
51
|
+
const MAX_STALE_SWEEPS = 4;
|
|
52
|
+
/**
|
|
53
|
+
* Depth-first from every item in rank order, placing each item as it is first
|
|
54
|
+
* reached. A tree comes out with no crossings before a single sweep runs, and
|
|
55
|
+
* the sweeps only ever keep a strictly better order, so a tree stays that way.
|
|
56
|
+
*/
|
|
57
|
+
function initialOrder(graph) {
|
|
58
|
+
const layers = Array.from({ length: graph.rankCount }, () => []);
|
|
59
|
+
const byRank = Array.from({ length: graph.rankCount }, () => []);
|
|
60
|
+
graph.rank.forEach((rank, item) => byRank[rank].push(item));
|
|
61
|
+
const visited = graph.rank.map(() => false);
|
|
62
|
+
for (const bucket of byRank) {
|
|
63
|
+
for (const start of bucket) {
|
|
64
|
+
const stack = [start];
|
|
65
|
+
for (let item = stack.pop(); item !== undefined; item = stack.pop()) {
|
|
66
|
+
if (visited[item])
|
|
67
|
+
continue;
|
|
68
|
+
visited[item] = true;
|
|
69
|
+
layers[graph.rank[item]].push(item);
|
|
70
|
+
// Pushed in reverse so the first child is visited first, as a recursive DFS would.
|
|
71
|
+
const below = graph.down[item];
|
|
72
|
+
for (let i = below.length - 1; i >= 0; i--)
|
|
73
|
+
if (!visited[below[i]])
|
|
74
|
+
stack.push(below[i]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return layers;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Re-sorts one layer by the barycenter of each item's neighbours on the
|
|
82
|
+
* reference layer. Items with no such neighbour are lifted out BEFORE the sort
|
|
83
|
+
* and keep their slot: a comparator asked to rank "no barycenter" against a
|
|
84
|
+
* number has no consistent answer, and an inconsistent comparator sorts
|
|
85
|
+
* differently in different engines.
|
|
86
|
+
*
|
|
87
|
+
* Barycenters are compared exactly. They are means of integer slot indices, so
|
|
88
|
+
* `sumA / countA < sumB / countB` becomes `sumA * countB < sumB * countA`, which
|
|
89
|
+
* stays in exact integer arithmetic at any size this component draws. Ties fall
|
|
90
|
+
* back to the current slot, so the comparator is total.
|
|
91
|
+
*/
|
|
92
|
+
function reorder(layer, neighbours, slotOf) {
|
|
93
|
+
const candidates = [];
|
|
94
|
+
layer.forEach((item, slot) => {
|
|
95
|
+
const adjacent = neighbours[item];
|
|
96
|
+
if (adjacent.length === 0)
|
|
97
|
+
return;
|
|
98
|
+
let sum = 0;
|
|
99
|
+
for (const other of adjacent)
|
|
100
|
+
sum += slotOf[other];
|
|
101
|
+
candidates.push({ item, sum, count: adjacent.length, slot });
|
|
102
|
+
});
|
|
103
|
+
const slots = candidates.map(candidate => candidate.slot);
|
|
104
|
+
candidates.sort((a, b) => a.sum * b.count - b.sum * a.count || a.slot - b.slot);
|
|
105
|
+
candidates.forEach((candidate, k) => {
|
|
106
|
+
layer[slots[k]] = candidate.item;
|
|
107
|
+
slotOf[candidate.item] = slots[k];
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Crossings between adjacent layers, counted pairwise. Quadratic in the
|
|
112
|
+
* segments between one pair of layers, which at the few hundred edges this
|
|
113
|
+
* component is meant for is cheaper to trust than an accumulator tree.
|
|
114
|
+
* Two segments sharing an end never count: their product is zero.
|
|
115
|
+
*/
|
|
116
|
+
function countCrossings(layers, down) {
|
|
117
|
+
const slotOf = down.map(() => 0);
|
|
118
|
+
for (const layer of layers)
|
|
119
|
+
layer.forEach((item, slot) => (slotOf[item] = slot));
|
|
120
|
+
let crossings = 0;
|
|
121
|
+
for (let r = 0; r + 1 < layers.length; r++) {
|
|
122
|
+
const top = [];
|
|
123
|
+
const bottom = [];
|
|
124
|
+
for (const item of layers[r]) {
|
|
125
|
+
for (const next of down[item]) {
|
|
126
|
+
top.push(slotOf[item]);
|
|
127
|
+
bottom.push(slotOf[next]);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (let i = 0; i < top.length; i++) {
|
|
131
|
+
for (let j = i + 1; j < top.length; j++) {
|
|
132
|
+
if ((top[i] - top[j]) * (bottom[i] - bottom[j]) < 0)
|
|
133
|
+
crossings++;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return crossings;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Stage 5. Alternating down and up barycenter sweeps, keeping the best order
|
|
141
|
+
* seen. "Best" moves only on a strict improvement, so an equally good later
|
|
142
|
+
* order never displaces an earlier one.
|
|
143
|
+
*/
|
|
144
|
+
function orderLayers(graph) {
|
|
145
|
+
const layers = initialOrder(graph);
|
|
146
|
+
const slotOf = graph.rank.map(() => 0);
|
|
147
|
+
for (const layer of layers)
|
|
148
|
+
layer.forEach((item, slot) => (slotOf[item] = slot));
|
|
149
|
+
let best = layers.map(layer => layer.slice());
|
|
150
|
+
let fewest = countCrossings(layers, graph.down);
|
|
151
|
+
for (let sweep = 0, stale = 0; sweep < MAX_SWEEPS && stale < MAX_STALE_SWEEPS && fewest > 0; sweep++) {
|
|
152
|
+
if (sweep % 2 === 0) {
|
|
153
|
+
for (let r = 1; r < layers.length; r++)
|
|
154
|
+
reorder(layers[r], graph.up, slotOf);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
for (let r = layers.length - 2; r >= 0; r--)
|
|
158
|
+
reorder(layers[r], graph.down, slotOf);
|
|
159
|
+
}
|
|
160
|
+
const crossings = countCrossings(layers, graph.down);
|
|
161
|
+
if (crossings < fewest) {
|
|
162
|
+
fewest = crossings;
|
|
163
|
+
best = layers.map(layer => layer.slice());
|
|
164
|
+
stale = 0;
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
stale++;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return best;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @license
|
|
175
|
+
*
|
|
176
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
177
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
178
|
+
*/
|
|
179
|
+
/** Four down passes and four up passes, alternating, ending on up so parents settle over their children. */
|
|
180
|
+
const COORDINATE_PASSES = 8;
|
|
181
|
+
function separation(extents, a, b) {
|
|
182
|
+
return extents.right[a] + extents.halfGap[a] + extents.halfGap[b] + extents.left[b];
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Moves one layer toward the mean x of each item's neighbours on the reference
|
|
186
|
+
* layer, without reordering it and without letting two items come closer than
|
|
187
|
+
* their separation.
|
|
188
|
+
*
|
|
189
|
+
* Why the result never overlaps: F is built left to right as
|
|
190
|
+
* `F[i] = max(desired[i], F[i-1] + sep[i])`, so `F[i] - F[i-1] >= sep[i]`; B is
|
|
191
|
+
* built right to left as `B[i] = min(desired[i], B[i+1] - sep[i+1])`, so
|
|
192
|
+
* `B[i+1] - B[i] >= sep[i+1]`. Both are feasible on their own, and the average
|
|
193
|
+
* of two feasible placements is feasible, because
|
|
194
|
+
* `(F[i] + B[i]) / 2 - (F[i-1] + B[i-1]) / 2` is half of a sum of two terms that
|
|
195
|
+
* are each at least `sep[i]`. That holds whatever the desired positions are,
|
|
196
|
+
* which is why no pass needs to repair a previous one. Averaging the two is also
|
|
197
|
+
* what keeps a crowded layer centred on what it wants instead of piled to one
|
|
198
|
+
* side.
|
|
199
|
+
*/
|
|
200
|
+
function align(layer, neighbours, x, extents) {
|
|
201
|
+
const desired = layer.map(item => {
|
|
202
|
+
const adjacent = neighbours[item];
|
|
203
|
+
if (adjacent.length === 0)
|
|
204
|
+
return x[item];
|
|
205
|
+
let sum = 0;
|
|
206
|
+
for (const other of adjacent)
|
|
207
|
+
sum += x[other];
|
|
208
|
+
return sum / adjacent.length;
|
|
209
|
+
});
|
|
210
|
+
const forward = desired.slice();
|
|
211
|
+
for (let i = 1; i < layer.length; i++) {
|
|
212
|
+
forward[i] = Math.max(desired[i], forward[i - 1] + separation(extents, layer[i - 1], layer[i]));
|
|
213
|
+
}
|
|
214
|
+
const backward = desired.slice();
|
|
215
|
+
for (let i = layer.length - 2; i >= 0; i--) {
|
|
216
|
+
backward[i] = Math.min(desired[i], backward[i + 1] - separation(extents, layer[i], layer[i + 1]));
|
|
217
|
+
}
|
|
218
|
+
layer.forEach((item, i) => (x[item] = (forward[i] + backward[i]) / 2));
|
|
219
|
+
}
|
|
220
|
+
/** Stage 6, horizontal: the anchor x of every item, starting packed and relaxing toward neighbours. */
|
|
221
|
+
function placeItems(layers, graph, extents) {
|
|
222
|
+
const x = graph.rank.map(() => 0);
|
|
223
|
+
for (const layer of layers) {
|
|
224
|
+
layer.forEach((item, i) => {
|
|
225
|
+
x[item] = i === 0 ? extents.left[item] : x[layer[i - 1]] + separation(extents, layer[i - 1], item);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
for (let pass = 0; pass < COORDINATE_PASSES; pass++) {
|
|
229
|
+
if (pass % 2 === 0) {
|
|
230
|
+
for (let r = 1; r < layers.length; r++)
|
|
231
|
+
align(layers[r], graph.up, x, extents);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
for (let r = layers.length - 2; r >= 0; r--)
|
|
235
|
+
align(layers[r], graph.down, x, extents);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return x;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Stage 6, vertical: a layer is as tall as its tallest node, layers are
|
|
242
|
+
* `layerGap` apart, and every item is centred in its layer — so a short node
|
|
243
|
+
* beside a tall one still lines up with it. The layer's band is
|
|
244
|
+
* `centre ± half`, written the way a node's border is (`centre ± height / 2`),
|
|
245
|
+
* so the tallest node's border and the band's edge are the same number.
|
|
246
|
+
*/
|
|
247
|
+
function layerCentres(layers, extents, layerGap) {
|
|
248
|
+
const centre = [];
|
|
249
|
+
const half = [];
|
|
250
|
+
let top = 0;
|
|
251
|
+
layers.forEach((layer, r) => {
|
|
252
|
+
let tallest = 0;
|
|
253
|
+
for (const item of layer)
|
|
254
|
+
tallest = Math.max(tallest, extents.height[item]);
|
|
255
|
+
if (r > 0)
|
|
256
|
+
top += layerGap;
|
|
257
|
+
half.push(tallest / 2);
|
|
258
|
+
centre.push(top + tallest / 2);
|
|
259
|
+
top += tallest;
|
|
260
|
+
});
|
|
261
|
+
return { centre, half, height: top };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* @license
|
|
266
|
+
*
|
|
267
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
268
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
269
|
+
*/
|
|
270
|
+
/**
|
|
271
|
+
* Stage 1. Ids become integer indices in input order and everything after this
|
|
272
|
+
* works on arrays: a plain object keyed by id would enumerate `'2'` before
|
|
273
|
+
* `'10'` whatever order the consumer gave, which is exactly the trap that makes
|
|
274
|
+
* one graph draw two ways.
|
|
275
|
+
*
|
|
276
|
+
* A duplicate id keeps its first occurrence and an edge naming a missing node is
|
|
277
|
+
* dropped. Both are the component's to warn about; the layout only has to
|
|
278
|
+
* survive them.
|
|
279
|
+
*/
|
|
280
|
+
function prepareGraph(input) {
|
|
281
|
+
const index = new Map();
|
|
282
|
+
const nodes = [];
|
|
283
|
+
for (const node of input.nodes) {
|
|
284
|
+
if (index.has(node.id))
|
|
285
|
+
continue;
|
|
286
|
+
index.set(node.id, nodes.length);
|
|
287
|
+
nodes.push(node);
|
|
288
|
+
}
|
|
289
|
+
const edges = [];
|
|
290
|
+
const ends = [];
|
|
291
|
+
for (const edge of input.edges) {
|
|
292
|
+
const from = index.get(edge.from);
|
|
293
|
+
const to = index.get(edge.to);
|
|
294
|
+
if (from === undefined || to === undefined)
|
|
295
|
+
continue;
|
|
296
|
+
edges.push(edge);
|
|
297
|
+
ends.push([from, to]);
|
|
298
|
+
}
|
|
299
|
+
// Weakly connected components by BFS, started from each unassigned node in
|
|
300
|
+
// input order — so component numbers already follow the smallest index.
|
|
301
|
+
const neighbours = nodes.map(() => []);
|
|
302
|
+
for (const [from, to] of ends) {
|
|
303
|
+
neighbours[from].push(to);
|
|
304
|
+
neighbours[to].push(from);
|
|
305
|
+
}
|
|
306
|
+
const componentOf = nodes.map(() => -1);
|
|
307
|
+
const components = [];
|
|
308
|
+
for (let start = 0; start < nodes.length; start++) {
|
|
309
|
+
if (componentOf[start] !== -1)
|
|
310
|
+
continue;
|
|
311
|
+
componentOf[start] = components.length;
|
|
312
|
+
const queue = [start];
|
|
313
|
+
// `for...of` over an array that grows while it is read visits the appended nodes too.
|
|
314
|
+
for (const current of queue) {
|
|
315
|
+
for (const next of neighbours[current]) {
|
|
316
|
+
if (componentOf[next] !== -1)
|
|
317
|
+
continue;
|
|
318
|
+
componentOf[next] = components.length;
|
|
319
|
+
queue.push(next);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
components.push({ nodes: [], edges: [], loops: [] });
|
|
323
|
+
}
|
|
324
|
+
const local = [];
|
|
325
|
+
nodes.forEach((_, node) => {
|
|
326
|
+
const component = components[componentOf[node]];
|
|
327
|
+
local.push(component.nodes.length);
|
|
328
|
+
component.nodes.push(node);
|
|
329
|
+
});
|
|
330
|
+
ends.forEach(([from, to], slot) => {
|
|
331
|
+
const component = components[componentOf[from]];
|
|
332
|
+
const edge = { slot, from: local[from], to: local[to] };
|
|
333
|
+
(from === to ? component.loops : component.edges).push(edge);
|
|
334
|
+
});
|
|
335
|
+
return { nodes, edges, components };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* @license
|
|
340
|
+
*
|
|
341
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
342
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
343
|
+
*/
|
|
344
|
+
/**
|
|
345
|
+
* Stage 2. Depth-first search from the sources in input order, then from
|
|
346
|
+
* whatever is left; an edge into the current stack closes a cycle and is
|
|
347
|
+
* reversed. A back edge points at an ancestor on the current path, so both of
|
|
348
|
+
* its ends are in one strongly connected component by construction — no SCC
|
|
349
|
+
* pass is needed here. The stack is explicit because a long chain would
|
|
350
|
+
* otherwise recurse once per node.
|
|
351
|
+
*/
|
|
352
|
+
function findBackEdges(count, edges) {
|
|
353
|
+
const out = Array.from({ length: count }, () => []);
|
|
354
|
+
const indegree = new Array(count).fill(0);
|
|
355
|
+
edges.forEach((edge, k) => {
|
|
356
|
+
out[edge.from].push(k);
|
|
357
|
+
indegree[edge.to]++;
|
|
358
|
+
});
|
|
359
|
+
const reversed = edges.map(() => false);
|
|
360
|
+
const visited = new Array(count).fill(false);
|
|
361
|
+
const onStack = new Array(count).fill(false);
|
|
362
|
+
const nodes = Array.from({ length: count }, (_, node) => node);
|
|
363
|
+
const starts = [...nodes.filter(node => indegree[node] === 0), ...nodes.filter(node => indegree[node] !== 0)];
|
|
364
|
+
for (const start of starts) {
|
|
365
|
+
if (visited[start])
|
|
366
|
+
continue;
|
|
367
|
+
visited[start] = onStack[start] = true;
|
|
368
|
+
const stack = [{ node: start, next: 0 }];
|
|
369
|
+
while (stack.length > 0) {
|
|
370
|
+
const frame = stack[stack.length - 1];
|
|
371
|
+
if (frame.next === out[frame.node].length) {
|
|
372
|
+
onStack[frame.node] = false;
|
|
373
|
+
stack.pop();
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const k = out[frame.node][frame.next++];
|
|
377
|
+
const to = edges[k].to;
|
|
378
|
+
if (onStack[to]) {
|
|
379
|
+
reversed[k] = true;
|
|
380
|
+
}
|
|
381
|
+
else if (!visited[to]) {
|
|
382
|
+
visited[to] = onStack[to] = true;
|
|
383
|
+
stack.push({ node: to, next: 0 });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return reversed;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Stage 3. Longest path from the sources: a node sits one rank below its
|
|
391
|
+
* deepest parent, so an org chart keeps a leaf directly under its manager
|
|
392
|
+
* rather than sinking every leaf to the bottom rank the way a sink-based
|
|
393
|
+
* longest path does. Network simplex would be more compact and is several
|
|
394
|
+
* times the code; this is the simplest ranking that reads well.
|
|
395
|
+
*
|
|
396
|
+
* The one tightening pass fixes the cost this ranking is known for: a root
|
|
397
|
+
* whose only child is deep would otherwise sit on rank 0 and draw a long edge
|
|
398
|
+
* through every rank in between. Only a node with no incoming edge moves, and it
|
|
399
|
+
* moves to just above its highest child — so no other node's rank changes and
|
|
400
|
+
* every rank stays occupied.
|
|
401
|
+
*/
|
|
402
|
+
function assignRanks(count, from, to) {
|
|
403
|
+
const out = Array.from({ length: count }, () => []);
|
|
404
|
+
const indegree = new Array(count).fill(0);
|
|
405
|
+
from.forEach((node, k) => {
|
|
406
|
+
out[node].push(to[k]);
|
|
407
|
+
indegree[to[k]]++;
|
|
408
|
+
});
|
|
409
|
+
// Kahn's algorithm with a FIFO queue seeded in input order.
|
|
410
|
+
const rank = new Array(count).fill(0);
|
|
411
|
+
const remaining = indegree.slice();
|
|
412
|
+
const order = [];
|
|
413
|
+
for (let node = 0; node < count; node++)
|
|
414
|
+
if (indegree[node] === 0)
|
|
415
|
+
order.push(node);
|
|
416
|
+
// `for...of` over an array that grows while it is read visits the appended nodes too.
|
|
417
|
+
for (const node of order) {
|
|
418
|
+
for (const child of out[node]) {
|
|
419
|
+
rank[child] = Math.max(rank[child], rank[node] + 1);
|
|
420
|
+
if (--remaining[child] === 0)
|
|
421
|
+
order.push(child);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
for (let i = order.length - 1; i >= 0; i--) {
|
|
425
|
+
const node = order[i];
|
|
426
|
+
if (indegree[node] !== 0 || out[node].length === 0)
|
|
427
|
+
continue;
|
|
428
|
+
let highest = rank[out[node][0]];
|
|
429
|
+
for (const child of out[node])
|
|
430
|
+
highest = Math.min(highest, rank[child]);
|
|
431
|
+
if (highest - 1 > rank[node])
|
|
432
|
+
rank[node] = highest - 1;
|
|
433
|
+
}
|
|
434
|
+
let lowest = rank[0];
|
|
435
|
+
for (const value of rank)
|
|
436
|
+
lowest = Math.min(lowest, value);
|
|
437
|
+
return rank.map(value => value - lowest);
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Stages 2–4 for one connected component: break cycles, rank, and give every
|
|
441
|
+
* edge that spans k ranks its own chain of k − 1 zero-size dummies, so parallel
|
|
442
|
+
* long edges stay apart instead of sharing one.
|
|
443
|
+
*/
|
|
444
|
+
function layerGraph(count, edges) {
|
|
445
|
+
const reversed = findBackEdges(count, edges);
|
|
446
|
+
const upper = edges.map((edge, k) => (reversed[k] ? edge.to : edge.from));
|
|
447
|
+
const lower = edges.map((edge, k) => (reversed[k] ? edge.from : edge.to));
|
|
448
|
+
const rank = assignRanks(count, upper, lower);
|
|
449
|
+
const up = Array.from({ length: count }, () => []);
|
|
450
|
+
const down = Array.from({ length: count }, () => []);
|
|
451
|
+
const layered = edges.map((edge, k) => {
|
|
452
|
+
const chain = [upper[k]];
|
|
453
|
+
for (let r = rank[upper[k]] + 1; r < rank[lower[k]]; r++) {
|
|
454
|
+
chain.push(rank.length);
|
|
455
|
+
rank.push(r);
|
|
456
|
+
up.push([]);
|
|
457
|
+
down.push([]);
|
|
458
|
+
}
|
|
459
|
+
chain.push(lower[k]);
|
|
460
|
+
for (let i = 1; i < chain.length; i++) {
|
|
461
|
+
down[chain[i - 1]].push(chain[i]);
|
|
462
|
+
up[chain[i]].push(chain[i - 1]);
|
|
463
|
+
}
|
|
464
|
+
return { slot: edge.slot, reversed: reversed[k], chain };
|
|
465
|
+
});
|
|
466
|
+
let rankCount = 0;
|
|
467
|
+
for (const value of rank)
|
|
468
|
+
rankCount = Math.max(rankCount, value + 1);
|
|
469
|
+
return { rank, rankCount, up, down, edges: layered };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* @license
|
|
474
|
+
*
|
|
475
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
476
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
477
|
+
*/
|
|
478
|
+
/** Room between a node's right border and the ends of its self-loops, where the arrowhead lands. */
|
|
479
|
+
const LOOP_CLEARANCE = 4;
|
|
480
|
+
/**
|
|
481
|
+
* Spreads k ports along one side of each node, ordered by where each edge
|
|
482
|
+
* heads next so that ports on one side never cross each other; ties go to the
|
|
483
|
+
* earlier edge. Without the spread, parallel edges between adjacent layers
|
|
484
|
+
* would draw as one line. The inset shrinks to a quarter of the width on a
|
|
485
|
+
* narrow node, so any node wider than zero keeps half its side for the spread
|
|
486
|
+
* instead of stacking every port on its centre.
|
|
487
|
+
*/
|
|
488
|
+
function assignPorts(sides, geometry, portGap, inset, into) {
|
|
489
|
+
sides.forEach((ports, node) => {
|
|
490
|
+
const sorted = ports.slice().sort((a, b) => {
|
|
491
|
+
const ax = geometry.x[a.toward];
|
|
492
|
+
const bx = geometry.x[b.toward];
|
|
493
|
+
return ax < bx ? -1 : ax > bx ? 1 : a.edge - b.edge;
|
|
494
|
+
});
|
|
495
|
+
const room = geometry.width[node] - 2 * Math.min(inset, geometry.width[node] / 4);
|
|
496
|
+
const spread = Math.max(0, Math.min(room, (sorted.length - 1) * portGap));
|
|
497
|
+
const step = sorted.length > 1 ? spread / (sorted.length - 1) : 0;
|
|
498
|
+
sorted.forEach((port, j) => (into[port.edge] = geometry.x[node] - spread / 2 + j * step));
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
/** How far right a node's layout box grows to hold its self-loops. */
|
|
502
|
+
function loopReserve(loops, step) {
|
|
503
|
+
return loops === 0 ? 0 : LOOP_CLEARANCE + loops * step;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Stage 8. Out of the bottom side, through each dummy, into the top side — as
|
|
507
|
+
* vertical stretches inside the layer bands joined by exactly one stretch per
|
|
508
|
+
* inter-layer gap: straight down from the port to the bottom of its node's band,
|
|
509
|
+
* straight through every intermediate band at the dummy's x (a point on the
|
|
510
|
+
* band's top and one on its bottom), and straight down from the top of the
|
|
511
|
+
* child's band to its port.
|
|
512
|
+
*
|
|
513
|
+
* Why bands and not a bend at each dummy's centre: the component draws a
|
|
514
|
+
* stretch as a cubic whose control points sit at the stretch's middle height,
|
|
515
|
+
* so a stretch that ended inside a band swept sideways at card height and cut
|
|
516
|
+
* straight through whatever unrelated card stood between its ends. Now every
|
|
517
|
+
* stretch that moves sideways starts and ends on the two boundaries of one gap,
|
|
518
|
+
* so its y never leaves the gap, where no node lives; and every vertical one
|
|
519
|
+
* keeps the x of a port (inside its own node's width) or of a dummy (half an
|
|
520
|
+
* edge gap plus half a node gap clear of any neighbour).
|
|
521
|
+
*
|
|
522
|
+
* A reversed edge is routed the way the layout sees it and then turned around,
|
|
523
|
+
* so its points still start at the real parent: it leaves the parent's top and
|
|
524
|
+
* enters the child's bottom, which is itself the visual cue that the edge
|
|
525
|
+
* closes a cycle.
|
|
526
|
+
*/
|
|
527
|
+
function routeEdges(graph, geometry, portGap, inset) {
|
|
528
|
+
const outgoing = geometry.width.map(() => []);
|
|
529
|
+
const incoming = geometry.width.map(() => []);
|
|
530
|
+
graph.edges.forEach(({ chain }, edge) => {
|
|
531
|
+
outgoing[chain[0]].push({ edge, toward: chain[1] });
|
|
532
|
+
incoming[chain[chain.length - 1]].push({ edge, toward: chain[chain.length - 2] });
|
|
533
|
+
});
|
|
534
|
+
const start = graph.edges.map(() => 0);
|
|
535
|
+
const end = graph.edges.map(() => 0);
|
|
536
|
+
assignPorts(outgoing, geometry, portGap, inset, start);
|
|
537
|
+
assignPorts(incoming, geometry, portGap, inset, end);
|
|
538
|
+
const centre = (item) => geometry.centreY[graph.rank[item]];
|
|
539
|
+
const half = (item) => geometry.halfBand[graph.rank[item]];
|
|
540
|
+
return graph.edges.map(({ chain, reversed }, edge) => {
|
|
541
|
+
const upper = chain[0];
|
|
542
|
+
const lower = chain[chain.length - 1];
|
|
543
|
+
const points = [];
|
|
544
|
+
// A node as tall as its band, or a band with no height, would repeat a point.
|
|
545
|
+
const add = (x, y) => {
|
|
546
|
+
const last = points[points.length - 1];
|
|
547
|
+
if (last?.x !== x || last.y !== y)
|
|
548
|
+
points.push({ x, y });
|
|
549
|
+
};
|
|
550
|
+
add(start[edge], centre(upper) + geometry.height[upper] / 2);
|
|
551
|
+
add(start[edge], centre(upper) + half(upper));
|
|
552
|
+
for (let i = 1; i < chain.length - 1; i++) {
|
|
553
|
+
add(geometry.x[chain[i]], centre(chain[i]) - half(chain[i]));
|
|
554
|
+
add(geometry.x[chain[i]], centre(chain[i]) + half(chain[i]));
|
|
555
|
+
}
|
|
556
|
+
add(end[edge], centre(lower) - half(lower));
|
|
557
|
+
add(end[edge], centre(lower) - geometry.height[lower] / 2);
|
|
558
|
+
return reversed ? points.reverse() : points;
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Self-loops as fixed rectangles beside the node's right side, nested outward
|
|
563
|
+
* one `step` apart, each a little taller than the one inside it. Every point is
|
|
564
|
+
* strictly outside the node, and the space is already reserved in the node's
|
|
565
|
+
* layout box, so a loop can never run into a neighbour. The side is a layout
|
|
566
|
+
* side: under RTL the whole drawing mirrors, loop included.
|
|
567
|
+
*/
|
|
568
|
+
function routeLoops(loops, geometry, rank, step) {
|
|
569
|
+
const total = geometry.width.map(() => 0);
|
|
570
|
+
for (const loop of loops)
|
|
571
|
+
total[loop.from]++;
|
|
572
|
+
const drawn = geometry.width.map(() => 0);
|
|
573
|
+
return loops.map(({ from }) => {
|
|
574
|
+
const nth = drawn[from]++;
|
|
575
|
+
const inner = geometry.x[from] + geometry.width[from] / 2 + LOOP_CLEARANCE;
|
|
576
|
+
const outer = inner + (nth + 1) * step;
|
|
577
|
+
const centre = geometry.centreY[rank[from]];
|
|
578
|
+
const reach = ((geometry.height[from] / 2) * (nth + 1)) / (total[from] + 1);
|
|
579
|
+
return [
|
|
580
|
+
{ x: inner, y: centre - reach },
|
|
581
|
+
{ x: outer, y: centre - reach },
|
|
582
|
+
{ x: outer, y: centre + reach },
|
|
583
|
+
{ x: inner, y: centre + reach },
|
|
584
|
+
];
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* @license
|
|
590
|
+
*
|
|
591
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
592
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
593
|
+
*/
|
|
594
|
+
/**
|
|
595
|
+
* The seam a layout engine plugs into. Used as both the type and the DI token,
|
|
596
|
+
* the way `WrDateAdapter` is.
|
|
597
|
+
*
|
|
598
|
+
* The return type admits a Promise from the first release, even though the
|
|
599
|
+
* built-in engine is synchronous: an asynchronous engine (elk runs in a worker)
|
|
600
|
+
* has to fit without a breaking change, and widening a synchronous return type
|
|
601
|
+
* later would break every caller that reads the result directly.
|
|
602
|
+
*/
|
|
603
|
+
class WrGraphLayout {
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* @license
|
|
608
|
+
*
|
|
609
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
610
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
611
|
+
*/
|
|
612
|
+
function drawComponent(graph, component, gaps) {
|
|
613
|
+
const width = component.nodes.map(node => graph.nodes[node].width);
|
|
614
|
+
const height = component.nodes.map(node => graph.nodes[node].height);
|
|
615
|
+
const loops = width.map(() => 0);
|
|
616
|
+
for (const loop of component.loops)
|
|
617
|
+
loops[loop.from]++;
|
|
618
|
+
const layered = layerGraph(width.length, component.edges);
|
|
619
|
+
const layers = orderLayers(layered);
|
|
620
|
+
const real = (item) => item < width.length;
|
|
621
|
+
const extents = {
|
|
622
|
+
left: layered.rank.map((_, item) => (real(item) ? width[item] / 2 : 0)),
|
|
623
|
+
right: layered.rank.map((_, item) => (real(item) ? width[item] / 2 + loopReserve(loops[item], gaps.edgeGap) : 0)),
|
|
624
|
+
halfGap: layered.rank.map((_, item) => (real(item) ? gaps.nodeGap : gaps.edgeGap) / 2),
|
|
625
|
+
height: layered.rank.map((_, item) => (real(item) ? height[item] : 0)),
|
|
626
|
+
};
|
|
627
|
+
const x = placeItems(layers, layered, extents);
|
|
628
|
+
const { centre, half, height: total } = layerCentres(layers, extents, gaps.layerGap);
|
|
629
|
+
const geometry = { x, centreY: centre, halfBand: half, width, height };
|
|
630
|
+
const edges = routeEdges(layered, geometry, gaps.edgeGap, gaps.edgeGap / 2);
|
|
631
|
+
const selfLoops = routeLoops(component.loops, geometry, layered.rank, gaps.edgeGap);
|
|
632
|
+
// Every route point lies horizontally within some item's box — ports and the
|
|
633
|
+
// band points straight above or below them inside a node's width, bends on
|
|
634
|
+
// dummies, loops inside the reserve — so the items alone bound it.
|
|
635
|
+
let minX = x[0] - extents.left[0];
|
|
636
|
+
let maxX = x[0] + extents.right[0];
|
|
637
|
+
x.forEach((anchor, item) => {
|
|
638
|
+
minX = Math.min(minX, anchor - extents.left[item]);
|
|
639
|
+
maxX = Math.max(maxX, anchor + extents.right[item]);
|
|
640
|
+
});
|
|
641
|
+
return {
|
|
642
|
+
left: width.map((w, node) => x[node] - extents.left[node]),
|
|
643
|
+
top: height.map((h, node) => centre[layered.rank[node]] - h / 2),
|
|
644
|
+
routes: [
|
|
645
|
+
...layered.edges.map((edge, k) => ({ slot: edge.slot, points: edges[k] })),
|
|
646
|
+
...component.loops.map((loop, k) => ({ slot: loop.slot, points: selfLoops[k] })),
|
|
647
|
+
],
|
|
648
|
+
minX,
|
|
649
|
+
width: maxX - minX,
|
|
650
|
+
height: total,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* The built-in layered layout: parents above children, pure TypeScript, no DOM
|
|
655
|
+
* and no dependency, so it runs the same under prerender as in the browser.
|
|
656
|
+
*
|
|
657
|
+
* It is deliberately the light version of the pipeline: DFS cycle breaking,
|
|
658
|
+
* source-based longest-path ranking with one tightening pass, barycenter sweeps
|
|
659
|
+
* with a pairwise crossing count, and averaged forward/backward compaction for
|
|
660
|
+
* coordinates. Network simplex and Brandes–Köpf would draw some graphs more
|
|
661
|
+
* compactly at several times the code.
|
|
662
|
+
*
|
|
663
|
+
* Deterministic by construction rather than by luck: ids are turned into indices
|
|
664
|
+
* in input order before anything else, every loop is capped by a count, every
|
|
665
|
+
* comparator is total with an integer tie-break, and the arithmetic is limited
|
|
666
|
+
* to operations the language specifies exactly — so the server and the browser
|
|
667
|
+
* agree to the last bit. Nothing is kept between calls and the input is never
|
|
668
|
+
* written to.
|
|
669
|
+
*/
|
|
670
|
+
class WrLayeredGraphLayout extends WrGraphLayout {
|
|
671
|
+
gaps;
|
|
672
|
+
constructor(options = {}) {
|
|
673
|
+
super();
|
|
674
|
+
this.gaps = {
|
|
675
|
+
nodeGap: options.nodeGap ?? 24,
|
|
676
|
+
layerGap: options.layerGap ?? 48,
|
|
677
|
+
edgeGap: options.edgeGap ?? 12,
|
|
678
|
+
componentGap: options.componentGap ?? 48,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
/** Synchronous — a narrowing of the base signature, so a caller holding this class needs no `await`. */
|
|
682
|
+
layout(input) {
|
|
683
|
+
const graph = prepareGraph(input);
|
|
684
|
+
const placed = graph.nodes.map(() => ({ x: 0, y: 0 }));
|
|
685
|
+
const routes = graph.edges.map(() => []);
|
|
686
|
+
let width = 0;
|
|
687
|
+
let height = 0;
|
|
688
|
+
// Components go in one row, top-aligned, in the order of their first node.
|
|
689
|
+
// `+ 0` turns a negative zero into a positive one: the two compare equal with
|
|
690
|
+
// `===` and unequal everywhere a result is compared structurally.
|
|
691
|
+
graph.components.forEach((component, c) => {
|
|
692
|
+
const drawing = drawComponent(graph, component, this.gaps);
|
|
693
|
+
const offset = c === 0 ? 0 : width + this.gaps.componentGap;
|
|
694
|
+
const shift = offset - drawing.minX;
|
|
695
|
+
component.nodes.forEach((node, local) => {
|
|
696
|
+
placed[node] = { x: drawing.left[local] + shift + 0, y: drawing.top[local] + 0 };
|
|
697
|
+
});
|
|
698
|
+
for (const { slot, points } of drawing.routes) {
|
|
699
|
+
routes[slot] = points.map(point => ({ x: point.x + shift + 0, y: point.y + 0 }));
|
|
700
|
+
}
|
|
701
|
+
width = offset + drawing.width;
|
|
702
|
+
height = Math.max(height, drawing.height);
|
|
703
|
+
});
|
|
704
|
+
return {
|
|
705
|
+
nodes: graph.nodes.map((node, i) => ({ id: node.id, x: placed[i].x, y: placed[i].y })),
|
|
706
|
+
edges: graph.edges.map((edge, slot) => ({ id: edge.id, points: routes[slot] })),
|
|
707
|
+
width: width + 0,
|
|
708
|
+
height: height + 0,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* @license
|
|
715
|
+
*
|
|
716
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
717
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
718
|
+
*/
|
|
719
|
+
/** What a size falls back to when neither the node nor the graph supplies a usable one. */
|
|
720
|
+
const DEFAULT_NODE_WIDTH = 160;
|
|
721
|
+
const DEFAULT_NODE_HEIGHT = 48;
|
|
722
|
+
/**
|
|
723
|
+
* The arrowhead's size in pixels, and the length of the straight lead-in under it.
|
|
724
|
+
* A fixed user-space size rather than a multiple of the stroke, so a thinner or
|
|
725
|
+
* thicker `--wr-graph-edge-width` never shrinks the arrow off its lead-in.
|
|
726
|
+
*/
|
|
727
|
+
const ARROW_SIZE = 6;
|
|
728
|
+
/**
|
|
729
|
+
* Instance counter for the arrowhead marker id. Several graphs share one page
|
|
730
|
+
* under `ViewEncapsulation.None`, so the id has to be per instance — and
|
|
731
|
+
* counted rather than `randomId()`, so the prerendered id is the one the
|
|
732
|
+
* browser renders again.
|
|
733
|
+
*/
|
|
734
|
+
let graphUid = 0;
|
|
735
|
+
function isSize(value) {
|
|
736
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
|
737
|
+
}
|
|
738
|
+
function isThenable(value) {
|
|
739
|
+
return typeof value.then === 'function';
|
|
740
|
+
}
|
|
741
|
+
/** Two decimals are below anything a screen can show, and keep a 5 000-bend path short. `+ 0` drops a `-0`. */
|
|
742
|
+
function px(value) {
|
|
743
|
+
return Math.round(value * 100) / 100 + 0;
|
|
744
|
+
}
|
|
745
|
+
function prepare(nodes, edges, nodeWidth, nodeHeight) {
|
|
746
|
+
// A `Map` built in input order, never an object keyed by id: an object lists
|
|
747
|
+
// index-like keys (`'2'`, `'10'`) first, so the order would depend on the ids.
|
|
748
|
+
const kept = new Map();
|
|
749
|
+
const duplicates = [];
|
|
750
|
+
const badSizes = [];
|
|
751
|
+
nodes.forEach((node, index) => {
|
|
752
|
+
if (kept.has(node.id)) {
|
|
753
|
+
duplicates.push(node.id);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if ((node.width != null && !isSize(node.width)) || (node.height != null && !isSize(node.height))) {
|
|
757
|
+
badSizes.push(node.id);
|
|
758
|
+
}
|
|
759
|
+
kept.set(node.id, {
|
|
760
|
+
node,
|
|
761
|
+
index,
|
|
762
|
+
width: isSize(node.width) ? node.width : nodeWidth,
|
|
763
|
+
height: isSize(node.height) ? node.height : nodeHeight,
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
const keptEdges = [];
|
|
767
|
+
const dangling = [];
|
|
768
|
+
edges.forEach((edge, index) => {
|
|
769
|
+
if (kept.has(edge.from) && kept.has(edge.to))
|
|
770
|
+
keptEdges.push({ id: `e${index}`, from: edge.from, to: edge.to });
|
|
771
|
+
else
|
|
772
|
+
dangling.push(`#${index} (from "${edge.from}" to "${edge.to}")`);
|
|
773
|
+
});
|
|
774
|
+
const list = [...kept.values()];
|
|
775
|
+
return {
|
|
776
|
+
nodes: list,
|
|
777
|
+
edges: keptEdges,
|
|
778
|
+
input: { nodes: list.map(({ node, width, height }) => ({ id: node.id, width, height })), edges: keptEdges },
|
|
779
|
+
duplicates,
|
|
780
|
+
dangling,
|
|
781
|
+
badSizes,
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Names joined the way the locale joins a list, or the way `'en'` does when the
|
|
786
|
+
* runtime has no list data for that locale — never the host default.
|
|
787
|
+
*
|
|
788
|
+
* `new Intl.ListFormat('zz')` does not throw: a well-formed tag the runtime
|
|
789
|
+
* cannot serve silently resolves to whatever locale the machine runs in, which
|
|
790
|
+
* is one thing on the prerendering server and another in the browser. So the
|
|
791
|
+
* support is asked first, and only a malformed tag reaches the `catch`
|
|
792
|
+
* (`supportedLocalesOf` throws a `RangeError` for it).
|
|
793
|
+
*/
|
|
794
|
+
function listFormat(locale) {
|
|
795
|
+
try {
|
|
796
|
+
if (Intl.ListFormat.supportedLocalesOf(locale).length)
|
|
797
|
+
return new Intl.ListFormat(locale, { type: 'conjunction' });
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
// Not a valid language tag at all; handled by the fallback below.
|
|
801
|
+
}
|
|
802
|
+
return new Intl.ListFormat('en', { type: 'conjunction' });
|
|
803
|
+
}
|
|
804
|
+
function warn(message) {
|
|
805
|
+
// eslint-disable-next-line no-console -- dev-mode validation
|
|
806
|
+
console.warn(`[NGWR] <wr-graph>: ${message}`);
|
|
807
|
+
}
|
|
808
|
+
/** What a layout returned that the component cannot draw faithfully. */
|
|
809
|
+
function layoutProblems(graph, result) {
|
|
810
|
+
const problems = [];
|
|
811
|
+
const placed = new Set(result.nodes.map(node => node.id));
|
|
812
|
+
const unplaced = graph.nodes.filter(({ node }) => !placed.has(node.id)).map(({ node }) => `"${node.id}"`);
|
|
813
|
+
if (unplaced.length)
|
|
814
|
+
problems.push(`no position for ${unplaced.join(', ')}`);
|
|
815
|
+
const numbers = [
|
|
816
|
+
result.width,
|
|
817
|
+
result.height,
|
|
818
|
+
...result.nodes.flatMap(node => [node.x, node.y]),
|
|
819
|
+
...result.edges.flatMap(edge => edge.points.flatMap(point => [point.x, point.y])),
|
|
820
|
+
];
|
|
821
|
+
if (!numbers.every(Number.isFinite))
|
|
822
|
+
problems.push('a coordinate or the size is not a finite number');
|
|
823
|
+
return problems;
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* A graph of blocks joined by parent-child links, laid out in layers from top to
|
|
827
|
+
* bottom. Display only: nothing is selectable, and the graph scrolls inside its
|
|
828
|
+
* own viewport rather than the page.
|
|
829
|
+
*
|
|
830
|
+
* The layout runs in a `computed` with no DOM and no measuring, so the sizes are
|
|
831
|
+
* the consumer's: `nodeWidth` / `nodeHeight` for every node, or `width` /
|
|
832
|
+
* `height` on one. A prerender therefore ships the finished picture.
|
|
833
|
+
*
|
|
834
|
+
* What the lines show is also written out: every node carries visually hidden
|
|
835
|
+
* text naming its parents and children, and the lines themselves are hidden
|
|
836
|
+
* from assistive technology.
|
|
837
|
+
*
|
|
838
|
+
* @example
|
|
839
|
+
* ```html
|
|
840
|
+
* <wr-graph [nodes]="nodes" [edges]="edges" ariaLabel="Team structure" />
|
|
841
|
+
* ```
|
|
842
|
+
*
|
|
843
|
+
* @see https://ngwr.dev/reference/components/graph
|
|
844
|
+
*/
|
|
845
|
+
class WrGraph {
|
|
846
|
+
/**
|
|
847
|
+
* The nodes, in any order. Input order breaks layout ties, so the same arrays
|
|
848
|
+
* always produce the same picture. Never mutated.
|
|
849
|
+
*
|
|
850
|
+
* @default []
|
|
851
|
+
*/
|
|
852
|
+
nodes = input([], /* @ts-ignore */
|
|
853
|
+
...(ngDevMode ? [{ debugName: "nodes" }] : /* istanbul ignore next */ []));
|
|
854
|
+
/**
|
|
855
|
+
* Parent-child links, `from` the parent and `to` the child. Never mutated.
|
|
856
|
+
*
|
|
857
|
+
* @default []
|
|
858
|
+
*/
|
|
859
|
+
edges = input([], /* @ts-ignore */
|
|
860
|
+
...(ngDevMode ? [{ debugName: "edges" }] : /* istanbul ignore next */ []));
|
|
861
|
+
/**
|
|
862
|
+
* Width in pixels of a node that sets none of its own.
|
|
863
|
+
*
|
|
864
|
+
* @default 160
|
|
865
|
+
*/
|
|
866
|
+
nodeWidth = input(160, { ...(ngDevMode ? { debugName: "nodeWidth" } : /* istanbul ignore next */ {}), transform: numAttr(160) });
|
|
867
|
+
/**
|
|
868
|
+
* Height in pixels of a node that sets none of its own.
|
|
869
|
+
*
|
|
870
|
+
* @default 48
|
|
871
|
+
*/
|
|
872
|
+
nodeHeight = input(48, { ...(ngDevMode ? { debugName: "nodeHeight" } : /* istanbul ignore next */ {}), transform: numAttr(48) });
|
|
873
|
+
/** Accessible name of the scrolling viewport. Falls back to `graph.label`, then `'Graph'`. */
|
|
874
|
+
ariaLabel = input(null, /* @ts-ignore */
|
|
875
|
+
...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
|
|
876
|
+
label = useI18nText(this.ariaLabel, 'graph.label', 'Graph');
|
|
877
|
+
// Each value is a whole sentence, terminator included, so the punctuation is the
|
|
878
|
+
// catalog's to choose — `。` in Japanese, `।` in Hindi.
|
|
879
|
+
parentsText = useI18nFormatter('graph.parents', 'Parents: {{names}}.');
|
|
880
|
+
childrenText = useI18nFormatter('graph.children', 'Children: {{names}}.');
|
|
881
|
+
nodeTemplate = contentChild(WrGraphNodeTemplate, /* @ts-ignore */
|
|
882
|
+
...(ngDevMode ? [{ debugName: "nodeTemplate" }] : /* istanbul ignore next */ []));
|
|
883
|
+
/** `url(#…)` for every edge's `marker-end`. */
|
|
884
|
+
markerId = `wr-graph-${graphUid++}-arrow`;
|
|
885
|
+
markerUrl = `url(#${this.markerId})`;
|
|
886
|
+
arrowSize = ARROW_SIZE;
|
|
887
|
+
engine = inject(WrGraphLayout, { optional: true }) ?? new WrLayeredGraphLayout();
|
|
888
|
+
// Optional on purpose: `Directionality` is root-provided, so a consumer never has
|
|
889
|
+
// to supply it — and a bare `TestBed` does not either.
|
|
890
|
+
dir = inject(Directionality, { optional: true });
|
|
891
|
+
i18n = inject(WrI18n, { optional: true });
|
|
892
|
+
localeId = inject(LOCALE_ID);
|
|
893
|
+
isRtl = computed(() => this.dir?.valueSignal() === 'rtl', /* @ts-ignore */
|
|
894
|
+
...(ngDevMode ? [{ debugName: "isRtl" }] : /* istanbul ignore next */ []));
|
|
895
|
+
locale = computed(() => this.i18n?.locale() ?? this.localeId, /* @ts-ignore */
|
|
896
|
+
...(ngDevMode ? [{ debugName: "locale" }] : /* istanbul ignore next */ []));
|
|
897
|
+
/**
|
|
898
|
+
* The size of a node that sets none of its own. Each dimension falls back on its
|
|
899
|
+
* own, and the dev warning reads this same value, so it cannot name a size the
|
|
900
|
+
* layout did not use.
|
|
901
|
+
*/
|
|
902
|
+
defaultSize = computed(() => {
|
|
903
|
+
const width = this.nodeWidth();
|
|
904
|
+
const height = this.nodeHeight();
|
|
905
|
+
return {
|
|
906
|
+
width: isSize(width) ? width : DEFAULT_NODE_WIDTH,
|
|
907
|
+
height: isSize(height) ? height : DEFAULT_NODE_HEIGHT,
|
|
908
|
+
};
|
|
909
|
+
}, /* @ts-ignore */
|
|
910
|
+
...(ngDevMode ? [{ debugName: "defaultSize" }] : /* istanbul ignore next */ []));
|
|
911
|
+
prepared = computed(() => {
|
|
912
|
+
const { width, height } = this.defaultSize();
|
|
913
|
+
return prepare(this.nodes(), this.edges(), width, height);
|
|
914
|
+
}, /* @ts-ignore */
|
|
915
|
+
...(ngDevMode ? [{ debugName: "prepared" }] : /* istanbul ignore next */ []));
|
|
916
|
+
/** The engine's answer, as returned. A fresh signal per run: nothing aborts a synchronous layout. */
|
|
917
|
+
raw = computed(() => this.engine.layout(this.prepared().input, { signal: new AbortController().signal }), /* @ts-ignore */
|
|
918
|
+
...(ngDevMode ? [{ debugName: "raw" }] : /* istanbul ignore next */ []));
|
|
919
|
+
/**
|
|
920
|
+
* The drawable result. An asynchronous layout is declared by the interface and
|
|
921
|
+
* not supported by this release: a thenable draws nothing, and dev mode says so.
|
|
922
|
+
*/
|
|
923
|
+
result = computed(() => {
|
|
924
|
+
const raw = this.raw();
|
|
925
|
+
return isThenable(raw) ? null : raw;
|
|
926
|
+
}, /* @ts-ignore */
|
|
927
|
+
...(ngDevMode ? [{ debugName: "result" }] : /* istanbul ignore next */ []));
|
|
928
|
+
/** Layout joined to the nodes and edges, direction-free, nodes in reading order. */
|
|
929
|
+
placed = computed(() => {
|
|
930
|
+
const graph = this.prepared();
|
|
931
|
+
const result = this.result();
|
|
932
|
+
if (!result)
|
|
933
|
+
return { width: 0, height: 0, nodes: [], edges: [] };
|
|
934
|
+
const positions = new Map(result.nodes.map(placement => [placement.id, placement]));
|
|
935
|
+
const nodes = graph.nodes.flatMap(prepared => {
|
|
936
|
+
const at = positions.get(prepared.node.id);
|
|
937
|
+
return at ? [{ ...prepared, x: at.x, y: at.y }] : [];
|
|
938
|
+
});
|
|
939
|
+
// Reading order is layer, then inline position. A layer centres nodes of
|
|
940
|
+
// different heights on one line, so the layer is the vertical CENTRE, taken to
|
|
941
|
+
// two decimals so float noise cannot split one layer in two.
|
|
942
|
+
const layer = (node) => Math.round((node.y + node.height / 2) * 100);
|
|
943
|
+
nodes.sort((a, b) => layer(a) - layer(b) || a.x - b.x || a.index - b.index);
|
|
944
|
+
const routes = new Map(result.edges.map(route => [route.id, route.points]));
|
|
945
|
+
const edges = graph.edges.flatMap(edge => {
|
|
946
|
+
const points = routes.get(edge.id);
|
|
947
|
+
return points && points.length >= 2 ? [{ edge, points }] : [];
|
|
948
|
+
});
|
|
949
|
+
return { width: result.width, height: result.height, nodes, edges };
|
|
950
|
+
}, /* @ts-ignore */
|
|
951
|
+
...(ngDevMode ? [{ debugName: "placed" }] : /* istanbul ignore next */ []));
|
|
952
|
+
/**
|
|
953
|
+
* Physical pixels. Mirrored here rather than in CSS so nodes and lines get the
|
|
954
|
+
* same numbers and cannot drift apart — and kept apart from the layout so a
|
|
955
|
+
* direction change does not lay the graph out again.
|
|
956
|
+
*/
|
|
957
|
+
view = computed(() => {
|
|
958
|
+
const { width, height, nodes, edges } = this.placed();
|
|
959
|
+
const rtl = this.isRtl();
|
|
960
|
+
const x = (value, size = 0) => px(rtl ? width - value - size : value);
|
|
961
|
+
const at = (point) => `${x(point.x)} ${px(point.y)}`;
|
|
962
|
+
// A route as a smooth path. Every stretch between two route points is one cubic
|
|
963
|
+
// Bézier with both control points at the stretch's middle height, so a line
|
|
964
|
+
// leaves a parent vertically and runs through a bend without a corner — a
|
|
965
|
+
// reversed edge too, whose stretches simply climb.
|
|
966
|
+
//
|
|
967
|
+
// The last `ARROW_SIZE` pixels are a straight lead-in. A marker takes its angle
|
|
968
|
+
// from the tangent at the very end, but it covers several pixels of line, and on
|
|
969
|
+
// a curve those already point elsewhere — the line came out of the side of the
|
|
970
|
+
// arrowhead. On a straight piece the triangle and the line agree. A self-loop is
|
|
971
|
+
// one cubic out to its reserved side with the same lead-in coming back level.
|
|
972
|
+
//
|
|
973
|
+
// Only `+`, `-`, `/`, `abs`, `min` and `max`, so the server and the browser
|
|
974
|
+
// write the same numbers.
|
|
975
|
+
const curve = (points, loop) => {
|
|
976
|
+
const start = points[0];
|
|
977
|
+
const end = points[points.length - 1];
|
|
978
|
+
if (loop) {
|
|
979
|
+
const right = Math.max(...points.map(point => point.x));
|
|
980
|
+
const lead = Math.min(ARROW_SIZE, (right - end.x) / 2);
|
|
981
|
+
const outer = x(right);
|
|
982
|
+
return `M${at(start)} C${outer} ${px(start.y)} ${outer} ${px(end.y)} ${x(end.x + lead)} ${px(end.y)} L${at(end)}`;
|
|
983
|
+
}
|
|
984
|
+
const stretches = points.slice(1).map((to, i) => {
|
|
985
|
+
const from = points[i];
|
|
986
|
+
const last = i === points.length - 2;
|
|
987
|
+
const lead = last ? Math.min(ARROW_SIZE, Math.abs(to.y - from.y) / 2) : 0;
|
|
988
|
+
const toY = to.y < from.y ? to.y + lead : to.y - lead;
|
|
989
|
+
const middle = px((from.y + toY) / 2);
|
|
990
|
+
const bend = `C${x(from.x)} ${middle} ${x(to.x)} ${middle} ${x(to.x)} ${px(toY)}`;
|
|
991
|
+
return last ? `${bend} L${at(to)}` : bend;
|
|
992
|
+
});
|
|
993
|
+
return [`M${at(start)}`, ...stretches].join(' ');
|
|
994
|
+
};
|
|
995
|
+
return {
|
|
996
|
+
width: px(width),
|
|
997
|
+
height: px(height),
|
|
998
|
+
nodes: nodes.map(placed => ({
|
|
999
|
+
node: placed.node,
|
|
1000
|
+
width: placed.width,
|
|
1001
|
+
height: placed.height,
|
|
1002
|
+
transform: `translate(${x(placed.x, placed.width)}px, ${px(placed.y)}px)`,
|
|
1003
|
+
})),
|
|
1004
|
+
edges: edges.map(({ edge, points }) => ({
|
|
1005
|
+
id: edge.id,
|
|
1006
|
+
from: edge.from,
|
|
1007
|
+
to: edge.to,
|
|
1008
|
+
d: curve(points, edge.from === edge.to),
|
|
1009
|
+
})),
|
|
1010
|
+
};
|
|
1011
|
+
}, /* @ts-ignore */
|
|
1012
|
+
...(ngDevMode ? [{ debugName: "view" }] : /* istanbul ignore next */ []));
|
|
1013
|
+
/**
|
|
1014
|
+
* The visually hidden sentence per node id: its parents, then its children, as
|
|
1015
|
+
* name lists. Never a count — the catalogs have no plural forms. A neighbour
|
|
1016
|
+
* linked by several edges is named once, and names follow reading order.
|
|
1017
|
+
*/
|
|
1018
|
+
relations = computed(() => {
|
|
1019
|
+
const { nodes } = this.placed();
|
|
1020
|
+
const list = listFormat(this.locale());
|
|
1021
|
+
const order = new Map(nodes.map((placed, i) => [placed.node.id, i]));
|
|
1022
|
+
const parents = new Map();
|
|
1023
|
+
const children = new Map();
|
|
1024
|
+
const link = (map, id, neighbour) => {
|
|
1025
|
+
const set = map.get(id) ?? new Set();
|
|
1026
|
+
set.add(neighbour);
|
|
1027
|
+
map.set(id, set);
|
|
1028
|
+
};
|
|
1029
|
+
for (const { from, to } of this.prepared().edges) {
|
|
1030
|
+
if (!order.has(from) || !order.has(to))
|
|
1031
|
+
continue;
|
|
1032
|
+
link(parents, to, from);
|
|
1033
|
+
link(children, from, to);
|
|
1034
|
+
}
|
|
1035
|
+
const names = (ids) => {
|
|
1036
|
+
if (!ids?.size)
|
|
1037
|
+
return null;
|
|
1038
|
+
const sorted = [...ids].sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0));
|
|
1039
|
+
return list.format(sorted.map(id => nodes[order.get(id) ?? 0].node.label));
|
|
1040
|
+
};
|
|
1041
|
+
const text = new Map();
|
|
1042
|
+
for (const { node } of nodes) {
|
|
1043
|
+
const sentences = [];
|
|
1044
|
+
const parentNames = names(parents.get(node.id));
|
|
1045
|
+
const childNames = names(children.get(node.id));
|
|
1046
|
+
if (parentNames)
|
|
1047
|
+
sentences.push(this.parentsText({ names: parentNames }));
|
|
1048
|
+
if (childNames)
|
|
1049
|
+
sentences.push(this.childrenText({ names: childNames }));
|
|
1050
|
+
// Every mark of punctuation comes from the catalog. The one character the
|
|
1051
|
+
// component adds is the space between two sentences — a word boundary to a
|
|
1052
|
+
// screen reader, and silent in a script that sets none.
|
|
1053
|
+
if (sentences.length)
|
|
1054
|
+
text.set(node.id, sentences.join(' '));
|
|
1055
|
+
}
|
|
1056
|
+
return text;
|
|
1057
|
+
}, /* @ts-ignore */
|
|
1058
|
+
...(ngDevMode ? [{ debugName: "relations" }] : /* istanbul ignore next */ []));
|
|
1059
|
+
constructor() {
|
|
1060
|
+
if (isDevMode()) {
|
|
1061
|
+
effect(() => {
|
|
1062
|
+
const graph = this.prepared();
|
|
1063
|
+
const raw = this.raw();
|
|
1064
|
+
if (graph.duplicates.length) {
|
|
1065
|
+
warn(`node id(s) ${graph.duplicates.map(id => `"${id}"`).join(', ')} appear more than once. ` +
|
|
1066
|
+
`The first occurrence of each is drawn and the rest are skipped.`);
|
|
1067
|
+
}
|
|
1068
|
+
if (graph.dangling.length) {
|
|
1069
|
+
warn(`edge(s) ${graph.dangling.join(', ')} name a node that does not exist and were skipped.`);
|
|
1070
|
+
}
|
|
1071
|
+
if (graph.badSizes.length) {
|
|
1072
|
+
warn(`node(s) ${graph.badSizes.map(id => `"${id}"`).join(', ')} have a width or height that is not a ` +
|
|
1073
|
+
`positive finite number, so nodeWidth / nodeHeight is used instead.`);
|
|
1074
|
+
}
|
|
1075
|
+
const width = this.nodeWidth();
|
|
1076
|
+
const height = this.nodeHeight();
|
|
1077
|
+
const invalid = [
|
|
1078
|
+
...(isSize(width) ? [] : [`nodeWidth (${String(width)})`]),
|
|
1079
|
+
...(isSize(height) ? [] : [`nodeHeight (${String(height)})`]),
|
|
1080
|
+
];
|
|
1081
|
+
if (invalid.length) {
|
|
1082
|
+
const used = this.defaultSize();
|
|
1083
|
+
warn(`${invalid.join(' and ')} must be a positive finite number, so a node with no size of its own is ` +
|
|
1084
|
+
`drawn ${used.width} × ${used.height}.`);
|
|
1085
|
+
}
|
|
1086
|
+
if (isThenable(raw)) {
|
|
1087
|
+
warn('the provided WrGraphLayout returned a Promise. Asynchronous layouts are not supported in this ' +
|
|
1088
|
+
'release, so nothing is drawn.');
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
const problems = layoutProblems(graph, raw);
|
|
1092
|
+
if (problems.length)
|
|
1093
|
+
warn(`the layout result cannot be drawn faithfully: ${problems.join('; ')}.`);
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: WrGraph, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1098
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: WrGraph, isStandalone: true, selector: "wr-graph", inputs: { nodes: { classPropertyName: "nodes", publicName: "nodes", isSignal: true, isRequired: false, transformFunction: null }, edges: { classPropertyName: "edges", publicName: "edges", isSignal: true, isRequired: false, transformFunction: null }, nodeWidth: { classPropertyName: "nodeWidth", publicName: "nodeWidth", isSignal: true, isRequired: false, transformFunction: null }, nodeHeight: { classPropertyName: "nodeHeight", publicName: "nodeHeight", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "ariaLabel", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "wr-graph" }, queries: [{ propertyName: "nodeTemplate", first: true, predicate: WrGraphNodeTemplate, descendants: true, isSignal: true }], ngImport: i0, template: "@let graph = view();\n<!-- The scroller is the one tab stop: a named group, so its label is allowed, and focusable so a keyboard can scroll it. -->\n<div class=\"wr-graph__viewport\" role=\"group\" tabindex=\"0\" [attr.aria-label]=\"label()\">\n <div class=\"wr-graph__stage\" [style.width.px]=\"graph.width\" [style.height.px]=\"graph.height\">\n <svg\n class=\"wr-graph__edges\"\n aria-hidden=\"true\"\n focusable=\"false\"\n [attr.width]=\"graph.width\"\n [attr.height]=\"graph.height\"\n >\n <defs>\n <marker\n [attr.id]=\"markerId\"\n viewBox=\"0 0 10 10\"\n refX=\"10\"\n refY=\"5\"\n markerUnits=\"userSpaceOnUse\"\n [attr.markerWidth]=\"arrowSize\"\n [attr.markerHeight]=\"arrowSize\"\n orient=\"auto\"\n >\n <polygon points=\"0,1 10,5 0,9\" />\n </marker>\n </defs>\n @for (edge of graph.edges; track edge.id) {\n <path\n class=\"wr-graph__edge\"\n [attr.d]=\"edge.d\"\n [attr.data-from]=\"edge.from\"\n [attr.data-to]=\"edge.to\"\n [attr.marker-end]=\"markerUrl\"\n />\n }\n </svg>\n <!-- Items are DIRECT children of the list: a wrapper carrying ARIA or a tabindex would break ownership. -->\n <div class=\"wr-graph__nodes\" [attr.role]=\"graph.nodes.length ? 'list' : null\">\n @for (entry of graph.nodes; track entry.node.id) {\n @let relation = relations().get(entry.node.id);\n <div\n class=\"wr-graph__node\"\n role=\"listitem\"\n [style.width.px]=\"entry.width\"\n [style.height.px]=\"entry.height\"\n [style.transform]=\"entry.transform\"\n >\n @if (nodeTemplate(); as custom) {\n <ng-container [ngTemplateOutlet]=\"custom.template\" [ngTemplateOutletContext]=\"{ $implicit: entry.node }\" />\n } @else {\n <div class=\"wr-graph__card\">{{ entry.node.label }}</div>\n }\n @if (relation) {\n <span class=\"wr-graph__sr-only\">{{ relation }}</span>\n }\n </div>\n }\n </div>\n </div>\n</div>\n", dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], encapsulation: i0.ViewEncapsulation.None });
|
|
1099
|
+
}
|
|
1100
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: WrGraph, decorators: [{
|
|
1101
|
+
type: Component,
|
|
1102
|
+
args: [{ selector: 'wr-graph', imports: [NgTemplateOutlet], encapsulation: ViewEncapsulation.None, host: {
|
|
1103
|
+
class: 'wr-graph',
|
|
1104
|
+
}, template: "@let graph = view();\n<!-- The scroller is the one tab stop: a named group, so its label is allowed, and focusable so a keyboard can scroll it. -->\n<div class=\"wr-graph__viewport\" role=\"group\" tabindex=\"0\" [attr.aria-label]=\"label()\">\n <div class=\"wr-graph__stage\" [style.width.px]=\"graph.width\" [style.height.px]=\"graph.height\">\n <svg\n class=\"wr-graph__edges\"\n aria-hidden=\"true\"\n focusable=\"false\"\n [attr.width]=\"graph.width\"\n [attr.height]=\"graph.height\"\n >\n <defs>\n <marker\n [attr.id]=\"markerId\"\n viewBox=\"0 0 10 10\"\n refX=\"10\"\n refY=\"5\"\n markerUnits=\"userSpaceOnUse\"\n [attr.markerWidth]=\"arrowSize\"\n [attr.markerHeight]=\"arrowSize\"\n orient=\"auto\"\n >\n <polygon points=\"0,1 10,5 0,9\" />\n </marker>\n </defs>\n @for (edge of graph.edges; track edge.id) {\n <path\n class=\"wr-graph__edge\"\n [attr.d]=\"edge.d\"\n [attr.data-from]=\"edge.from\"\n [attr.data-to]=\"edge.to\"\n [attr.marker-end]=\"markerUrl\"\n />\n }\n </svg>\n <!-- Items are DIRECT children of the list: a wrapper carrying ARIA or a tabindex would break ownership. -->\n <div class=\"wr-graph__nodes\" [attr.role]=\"graph.nodes.length ? 'list' : null\">\n @for (entry of graph.nodes; track entry.node.id) {\n @let relation = relations().get(entry.node.id);\n <div\n class=\"wr-graph__node\"\n role=\"listitem\"\n [style.width.px]=\"entry.width\"\n [style.height.px]=\"entry.height\"\n [style.transform]=\"entry.transform\"\n >\n @if (nodeTemplate(); as custom) {\n <ng-container [ngTemplateOutlet]=\"custom.template\" [ngTemplateOutletContext]=\"{ $implicit: entry.node }\" />\n } @else {\n <div class=\"wr-graph__card\">{{ entry.node.label }}</div>\n }\n @if (relation) {\n <span class=\"wr-graph__sr-only\">{{ relation }}</span>\n }\n </div>\n }\n </div>\n </div>\n</div>\n" }]
|
|
1105
|
+
}], ctorParameters: () => [], propDecorators: { nodes: [{ type: i0.Input, args: [{ isSignal: true, alias: "nodes", required: false }] }], edges: [{ type: i0.Input, args: [{ isSignal: true, alias: "edges", required: false }] }], nodeWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "nodeWidth", required: false }] }], nodeHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "nodeHeight", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "ariaLabel", required: false }] }], nodeTemplate: [{ type: i0.ContentChild, args: [i0.forwardRef(() => WrGraphNodeTemplate), { isSignal: true }] }] } });
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* Generated bundle index. Do not edit.
|
|
1109
|
+
*/
|
|
1110
|
+
|
|
1111
|
+
export { WrGraph, WrGraphNodeTemplate };
|
|
1112
|
+
//# sourceMappingURL=ngwr-graph.mjs.map
|