@yuneta/gobj-ui 7.23.69 → 7.23.71
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 +49 -0
- package/dist/gobj-ui.cjs.js +470 -3
- package/dist/gobj-ui.es.js +470 -3
- package/package.json +1 -1
- package/src/c_g6_nodes_tree.js +103 -2
- package/src/c_yui_form.js +8 -2
- package/src/c_yui_treedb_graph.js +6 -0
- package/src/form_field_readonly.js +61 -0
- package/src/form_field_readonly.test.js +50 -0
- package/src/treedb_layout.js +380 -0
- package/src/treedb_layout.test.js +172 -0
package/src/c_yui_form.js
CHANGED
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
import "./c_yui_form.css";
|
|
66
66
|
import {attach_clear, refresh_clear} from "./yui_inputs.js";
|
|
67
67
|
import {plan_toolbar, DEFAULT_TOOLBAR} from "./form_toolbar_plan.js";
|
|
68
|
+
import {field_is_readonly} from "./form_field_readonly.js";
|
|
68
69
|
/* Read at field-build time only — deliberately NOT watched: the hosting
|
|
69
70
|
* dialog rebuilds the form on every open, and re-rendering a form under
|
|
70
71
|
* the user mid-edit would throw away what they typed. */
|
|
@@ -601,8 +602,13 @@ function build_form_field_conf(gobj, field_desc)
|
|
|
601
602
|
extras: {}, // extra html input/textarea attributes
|
|
602
603
|
/* A form opened to LOOK at a record has no editable field, and
|
|
603
604
|
* that is a property of the opening, not of the schema: the same
|
|
604
|
-
* col is writable when the same form is opened to edit.
|
|
605
|
-
|
|
605
|
+
* col is writable when the same form is opened to edit. And an
|
|
606
|
+
* fkey is editable WITHOUT `writable`, because a link is not
|
|
607
|
+
* written, it is linked -- the rule and its why live in
|
|
608
|
+
* form_field_readonly.js. */
|
|
609
|
+
readonly: field_is_readonly(
|
|
610
|
+
gobj_read_bool_attr(gobj, "readonly"), field_desc
|
|
611
|
+
),
|
|
606
612
|
required: field_desc.is_required,
|
|
607
613
|
hidden: field_desc.is_hidden,
|
|
608
614
|
default_value: field_desc.default_value,
|
|
@@ -506,6 +506,12 @@ function option_label(name)
|
|
|
506
506
|
/* Layouts (`layout_names` of the G6 child). */
|
|
507
507
|
case "manual":
|
|
508
508
|
return t("manual");
|
|
509
|
+
case "treedb-tree":
|
|
510
|
+
return t("treedb-tree");
|
|
511
|
+
case "treedb-outline":
|
|
512
|
+
return t("treedb-outline");
|
|
513
|
+
case "radial":
|
|
514
|
+
return t("radial");
|
|
509
515
|
case "dagre":
|
|
510
516
|
return t("dagre");
|
|
511
517
|
case "antv-dagre":
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* form_field_readonly.js
|
|
3
|
+
*
|
|
4
|
+
* Is this field of the form read-only?
|
|
5
|
+
*
|
|
6
|
+
* Pure, so the rule can be tested without a DOM: the gclass turns
|
|
7
|
+
* the answer into `readonly`/`disabled` on a control, this decides
|
|
8
|
+
* the answer. Same split as form_toolbar_plan.js and
|
|
9
|
+
* treedb_write_plan.js.
|
|
10
|
+
*
|
|
11
|
+
* The rule it exists to state ONCE: **`writable` governs the WRITE
|
|
12
|
+
* of a column's VALUE, and an fkey is not written — it is LINKED.**
|
|
13
|
+
* An fkey column is normally declared with no `writable` flag at
|
|
14
|
+
* all (`treedb_authzs`'s `users.roles` is `['fkey']`, and so is
|
|
15
|
+
* almost every fkey in the tree), so reading "not writable" as "not
|
|
16
|
+
* editable" takes away the only way to link a record to its parent
|
|
17
|
+
* — with `users.roles`, the only way to give a person a role.
|
|
18
|
+
*
|
|
19
|
+
* The other half of this rule is already written down in
|
|
20
|
+
* c_yui_treedb_topic_with_form.js, where the record travels back:
|
|
21
|
+
* the form sends "the writable cols, the fkeys (a link is edited by
|
|
22
|
+
* linking) and the pkey". The two halves have to agree, and when
|
|
23
|
+
* they did not, the form disabled the control whose value the save
|
|
24
|
+
* path was still waiting for.
|
|
25
|
+
*
|
|
26
|
+
* A `file` column is an fkey too (flagged `['fkey','file']`) and is
|
|
27
|
+
* NOT covered: it answers `type: "file"`, its bytes travel beside
|
|
28
|
+
* the record, and the SDK declares as legal a `file` column that
|
|
29
|
+
* only a load fills.
|
|
30
|
+
*
|
|
31
|
+
* Copyright (c) 2026, ArtGins.
|
|
32
|
+
* All Rights Reserved.
|
|
33
|
+
***********************************************************************/
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
/***************************************************************
|
|
37
|
+
* field_is_readonly(form_readonly, field_desc) -> boolean
|
|
38
|
+
*
|
|
39
|
+
* form_readonly the FORM is read-only: a record opened to be
|
|
40
|
+
* looked at, or a store nobody may write. It is
|
|
41
|
+
* a property of the opening, not of the schema,
|
|
42
|
+
* and it wins over everything.
|
|
43
|
+
* field_desc as treedb_get_field_desc() answers it
|
|
44
|
+
* (`type`, `is_writable`).
|
|
45
|
+
***************************************************************/
|
|
46
|
+
function field_is_readonly(form_readonly, field_desc)
|
|
47
|
+
{
|
|
48
|
+
if(form_readonly) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let desc = field_desc || {};
|
|
53
|
+
if(desc.type === "fkey") {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return !desc.is_writable;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
export {field_is_readonly};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {describe, it, expect} from "vitest";
|
|
2
|
+
import {field_is_readonly} from "./form_field_readonly.js";
|
|
3
|
+
|
|
4
|
+
describe("field_is_readonly", () => {
|
|
5
|
+
|
|
6
|
+
it("a form opened to LOOK has no editable field, fkey included", () => {
|
|
7
|
+
/* A record opened to be read: the schema does not enter into it. */
|
|
8
|
+
expect(field_is_readonly(true, {type: "fkey", is_writable: false}))
|
|
9
|
+
.toBe(true);
|
|
10
|
+
expect(field_is_readonly(true, {type: "fkey", is_writable: true}))
|
|
11
|
+
.toBe(true);
|
|
12
|
+
expect(field_is_readonly(true, {type: "string", is_writable: true}))
|
|
13
|
+
.toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("an fkey is EDITABLE without `writable`: a link is edited by linking", () => {
|
|
17
|
+
/* `treedb_authzs`'s `users.roles` is declared `['fkey']` and
|
|
18
|
+
* nothing else -- as almost every fkey in the tree is. Reading
|
|
19
|
+
* that as "not editable" left a user with no way to be given a
|
|
20
|
+
* role, while the save path was still sending the column. */
|
|
21
|
+
expect(field_is_readonly(false, {type: "fkey", is_writable: false}))
|
|
22
|
+
.toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("a plain column still needs `writable`", () => {
|
|
26
|
+
expect(field_is_readonly(false, {type: "string", is_writable: false}))
|
|
27
|
+
.toBe(true);
|
|
28
|
+
expect(field_is_readonly(false, {type: "string", is_writable: true}))
|
|
29
|
+
.toBe(false);
|
|
30
|
+
expect(field_is_readonly(false, {type: "enum", is_writable: false}))
|
|
31
|
+
.toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("a `file` column is NOT covered by the fkey exemption", () => {
|
|
35
|
+
/* It is an fkey (`['fkey','file']`) but answers `type: "file"`,
|
|
36
|
+
* and the SDK declares as legal a `file` column that only a load
|
|
37
|
+
* fills. */
|
|
38
|
+
expect(field_is_readonly(false, {type: "file", is_writable: false, is_file: true}))
|
|
39
|
+
.toBe(true);
|
|
40
|
+
expect(field_is_readonly(false, {type: "file", is_writable: true, is_file: true}))
|
|
41
|
+
.toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("no descriptor is read-only, not editable", () => {
|
|
45
|
+
/* Nothing known about a field is not a reason to let it be
|
|
46
|
+
* written. */
|
|
47
|
+
expect(field_is_readonly(false, undefined)).toBe(true);
|
|
48
|
+
expect(field_is_readonly(false, {})).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* treedb_layout.js
|
|
3
|
+
*
|
|
4
|
+
* Two layouts made for a treedb, and nothing else.
|
|
5
|
+
*
|
|
6
|
+
* What a treedb graph IS, once it is folded (treedb_fold_model.js):
|
|
7
|
+
* a forest read left to right. A main topic is a tree (places:
|
|
8
|
+
* country, region, site, hall), the other topics hang from its
|
|
9
|
+
* nodes through hooks (devices, users, controllers), a record may
|
|
10
|
+
* hang from two parents (a device from its place AND its
|
|
11
|
+
* controller), and every visible node was reached from a root.
|
|
12
|
+
* Tens to a few hundred cards on screen, never the whole store.
|
|
13
|
+
*
|
|
14
|
+
* G6's own layouts either do not know that or pay for not knowing:
|
|
15
|
+
*
|
|
16
|
+
* - `dagre` gets the reading direction right and then ranks by
|
|
17
|
+
* longest path and minimises crossings with a heuristic that
|
|
18
|
+
* REORDERS the siblings on every run -- open one hook and the
|
|
19
|
+
* column next to it shuffles. It is O(n·m) per sweep and it is
|
|
20
|
+
* the one thing that takes time on a big expansion.
|
|
21
|
+
* - the tree layouts (`compact-box`, `indented`, `dendrogram`,
|
|
22
|
+
* `mindmap`) draw exactly the shape wanted, but G6 runs them on
|
|
23
|
+
* a TREE it builds from the edges: a node with two parents is
|
|
24
|
+
* placed by whichever parent the builder met first, unstably.
|
|
25
|
+
* - the force family scatters a tree, which is the pile with
|
|
26
|
+
* physics.
|
|
27
|
+
*
|
|
28
|
+
* So: a tidy tree (`tree`) and an outline (`outline`), both fed by
|
|
29
|
+
* the SAME spanning tree, chosen deterministically:
|
|
30
|
+
*
|
|
31
|
+
* - roots are the nodes with no incoming edge, in node order;
|
|
32
|
+
* - a node belongs to the FIRST parent that reaches it in a
|
|
33
|
+
* breadth-first walk from the roots -- the place, not the
|
|
34
|
+
* controller, because the place's column came first -- and the
|
|
35
|
+
* other links are drawn as edges across the tree;
|
|
36
|
+
* - the children of a node are ordered by the HOOK they hang from
|
|
37
|
+
* (the parent's port order, which is the schema's column order)
|
|
38
|
+
* and then by node order (record order, the pages' order);
|
|
39
|
+
* - a node nothing reaches (a cycle) is a root too.
|
|
40
|
+
*
|
|
41
|
+
* `tree`: the classic tidy tree, left to right. Each depth is a
|
|
42
|
+
* column as wide as its widest card; a node is centred on the
|
|
43
|
+
* block of its children; siblings stack with `nodesep` between
|
|
44
|
+
* them. O(n), and opening a hook moves NOTHING that is not below
|
|
45
|
+
* or beside it -- the cards keep their order, the eye keeps its
|
|
46
|
+
* place.
|
|
47
|
+
*
|
|
48
|
+
* `outline`: one node per row, indented by depth -- what a JSON
|
|
49
|
+
* viewer draws, and what a treedb is when read as one. Tall, but
|
|
50
|
+
* nothing is ever beside anything: the reader scrolls a list.
|
|
51
|
+
*
|
|
52
|
+
* Pure: takes plain nodes and edges, returns positions. The G6
|
|
53
|
+
* classes in c_g6_nodes_tree.js are two thin adapters over it.
|
|
54
|
+
*
|
|
55
|
+
* Copyright (c) 2026, ArtGins.
|
|
56
|
+
* All Rights Reserved.
|
|
57
|
+
***********************************************************************/
|
|
58
|
+
|
|
59
|
+
const DEFAULTS = {
|
|
60
|
+
nodesep: 18, /* gap between siblings (tree) / rows (outline) */
|
|
61
|
+
ranksep: 110, /* gap between a column and the next (tree) */
|
|
62
|
+
indent: 48, /* what a depth adds to the x of a row (outline) */
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/************************************************************
|
|
66
|
+
* The spanning tree of a graph, deterministic.
|
|
67
|
+
*
|
|
68
|
+
* nodes [{id, w, h, order}] order: creation index
|
|
69
|
+
* edges [{source, target, rank}] rank: the hook's position in
|
|
70
|
+
* the parent's ports (0 = first)
|
|
71
|
+
*
|
|
72
|
+
* Returns {roots: [id], children: Map<id, [id]>, depth: Map<id, n>,
|
|
73
|
+
* by_id: Map<id, node>}
|
|
74
|
+
************************************************************/
|
|
75
|
+
export function spanning_tree(nodes, edges)
|
|
76
|
+
{
|
|
77
|
+
let by_id = new Map();
|
|
78
|
+
for(let i = 0; i < nodes.length; i++) {
|
|
79
|
+
let n = nodes[i];
|
|
80
|
+
by_id.set(n.id, Object.assign({order: i}, n));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* Outgoing links per node, with the rank of the hook they leave by. */
|
|
84
|
+
let out = new Map();
|
|
85
|
+
let has_parent = new Set();
|
|
86
|
+
for(let e of edges) {
|
|
87
|
+
if(!by_id.has(e.source) || !by_id.has(e.target) || e.source === e.target) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if(!out.has(e.source)) {
|
|
91
|
+
out.set(e.source, []);
|
|
92
|
+
}
|
|
93
|
+
out.get(e.source).push({id: e.target, rank: (e.rank > 0)? e.rank : 0});
|
|
94
|
+
has_parent.add(e.target);
|
|
95
|
+
}
|
|
96
|
+
for(let list of out.values()) {
|
|
97
|
+
list.sort((a, b) => {
|
|
98
|
+
if(a.rank !== b.rank) {
|
|
99
|
+
return a.rank - b.rank;
|
|
100
|
+
}
|
|
101
|
+
return by_id.get(a.id).order - by_id.get(b.id).order;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let roots = [];
|
|
106
|
+
for(let n of by_id.values()) {
|
|
107
|
+
if(!has_parent.has(n.id)) {
|
|
108
|
+
roots.push(n.id);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let children = new Map();
|
|
113
|
+
let depth = new Map();
|
|
114
|
+
let assign = (start) => {
|
|
115
|
+
let queue = [start];
|
|
116
|
+
depth.set(start, 0);
|
|
117
|
+
while(queue.length) {
|
|
118
|
+
let id = queue.shift();
|
|
119
|
+
let kids = [];
|
|
120
|
+
for(let link of (out.get(id) || [])) {
|
|
121
|
+
if(depth.has(link.id)) {
|
|
122
|
+
continue; /* reached before: that parent keeps it */
|
|
123
|
+
}
|
|
124
|
+
depth.set(link.id, depth.get(id) + 1);
|
|
125
|
+
kids.push(link.id);
|
|
126
|
+
queue.push(link.id);
|
|
127
|
+
}
|
|
128
|
+
children.set(id, kids);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
for(let root of roots) {
|
|
132
|
+
assign(root);
|
|
133
|
+
}
|
|
134
|
+
/* A cycle with no way in: its first node, in node order, is a root. */
|
|
135
|
+
for(let n of by_id.values()) {
|
|
136
|
+
if(!depth.has(n.id)) {
|
|
137
|
+
roots.push(n.id);
|
|
138
|
+
assign(n.id);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {roots: roots, children: children, depth: depth, by_id: by_id};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/************************************************************
|
|
146
|
+
* The tidy tree, left to right.
|
|
147
|
+
*
|
|
148
|
+
* Returns Map<id, {x, y}> with the CENTRE of each node, the way G6
|
|
149
|
+
* positions a node.
|
|
150
|
+
************************************************************/
|
|
151
|
+
export function layout_tree(nodes, edges, opts)
|
|
152
|
+
{
|
|
153
|
+
let o = Object.assign({}, DEFAULTS, opts || {});
|
|
154
|
+
let t = spanning_tree(nodes, edges);
|
|
155
|
+
let pos = new Map();
|
|
156
|
+
|
|
157
|
+
/* Column widths: the widest card of each depth. */
|
|
158
|
+
let col_w = [];
|
|
159
|
+
for(let [id, d] of t.depth) {
|
|
160
|
+
let w = t.by_id.get(id).w;
|
|
161
|
+
col_w[d] = Math.max(col_w[d] || 0, w);
|
|
162
|
+
}
|
|
163
|
+
let col_x = [];
|
|
164
|
+
let x = 0;
|
|
165
|
+
for(let d = 0; d < col_w.length; d++) {
|
|
166
|
+
col_x[d] = x;
|
|
167
|
+
x += (col_w[d] || 0) + o.ranksep;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* Subtree heights, post-order. */
|
|
171
|
+
let height = new Map();
|
|
172
|
+
let measure = (id) => {
|
|
173
|
+
let kids = t.children.get(id) || [];
|
|
174
|
+
let own = t.by_id.get(id).h;
|
|
175
|
+
if(!kids.length) {
|
|
176
|
+
height.set(id, own);
|
|
177
|
+
return own;
|
|
178
|
+
}
|
|
179
|
+
let sum = 0;
|
|
180
|
+
for(let i = 0; i < kids.length; i++) {
|
|
181
|
+
sum += measure(kids[i]);
|
|
182
|
+
if(i > 0) {
|
|
183
|
+
sum += o.nodesep;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
let h = Math.max(own, sum);
|
|
187
|
+
height.set(id, h);
|
|
188
|
+
return h;
|
|
189
|
+
};
|
|
190
|
+
for(let root of t.roots) {
|
|
191
|
+
measure(root);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/* Placement, pre-order: a node is centred on the block of its
|
|
195
|
+
* children, which starts at the top of its own block. */
|
|
196
|
+
let place = (id, top) => {
|
|
197
|
+
let n = t.by_id.get(id);
|
|
198
|
+
let d = t.depth.get(id);
|
|
199
|
+
let kids = t.children.get(id) || [];
|
|
200
|
+
let block = height.get(id);
|
|
201
|
+
let cx = col_x[d] + (col_w[d] - n.w) / 2 + n.w / 2;
|
|
202
|
+
if(!kids.length) {
|
|
203
|
+
pos.set(id, {x: cx, y: top + block / 2});
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
let kids_h = 0;
|
|
207
|
+
for(let i = 0; i < kids.length; i++) {
|
|
208
|
+
kids_h += height.get(kids[i]);
|
|
209
|
+
if(i > 0) {
|
|
210
|
+
kids_h += o.nodesep;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
let cursor = top + (block - kids_h) / 2;
|
|
214
|
+
for(let kid of kids) {
|
|
215
|
+
place(kid, cursor);
|
|
216
|
+
cursor += height.get(kid) + o.nodesep;
|
|
217
|
+
}
|
|
218
|
+
pos.set(id, {x: cx, y: top + block / 2});
|
|
219
|
+
};
|
|
220
|
+
let top = 0;
|
|
221
|
+
for(let root of t.roots) {
|
|
222
|
+
place(root, top);
|
|
223
|
+
top += height.get(root) + o.nodesep * 2;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return pos;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/************************************************************
|
|
230
|
+
* The radial tree: the root in the middle, a ring per depth.
|
|
231
|
+
*
|
|
232
|
+
* G6's `radial` places by MDS on the graph distances and pushes
|
|
233
|
+
* overlaps apart afterwards, with a ring radius it is TOLD; twenty
|
|
234
|
+
* cards of 172px on a ring of radius 200 have 52px each and pile
|
|
235
|
+
* up. This one is the radial form of the tidy tree above: every
|
|
236
|
+
* subtree gets an angular SECTOR proportional to its leaves, a node
|
|
237
|
+
* sits in the middle of its sector, and the radius of each ring is
|
|
238
|
+
* the larger of "one step further out than the ring before" and
|
|
239
|
+
* "long enough for its nodes to sit side by side" -- so a hall
|
|
240
|
+
* with a hundred devices is a wide, sparse fan, and nothing
|
|
241
|
+
* overlaps by construction. Several roots share the circle around
|
|
242
|
+
* a virtual centre.
|
|
243
|
+
*
|
|
244
|
+
* ranksep the step between rings
|
|
245
|
+
* nodesep the gap between neighbours on a ring
|
|
246
|
+
************************************************************/
|
|
247
|
+
export function layout_radial(nodes, edges, opts)
|
|
248
|
+
{
|
|
249
|
+
let o = Object.assign({}, DEFAULTS, opts || {});
|
|
250
|
+
let t = spanning_tree(nodes, edges);
|
|
251
|
+
let pos = new Map();
|
|
252
|
+
if(!t.roots.length) {
|
|
253
|
+
return pos;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* Leaves under each node: the sector it deserves. */
|
|
257
|
+
let leaves = new Map();
|
|
258
|
+
let count = (id) => {
|
|
259
|
+
let kids = t.children.get(id) || [];
|
|
260
|
+
if(!kids.length) {
|
|
261
|
+
leaves.set(id, 1);
|
|
262
|
+
return 1;
|
|
263
|
+
}
|
|
264
|
+
let n = 0;
|
|
265
|
+
for(let kid of kids) {
|
|
266
|
+
n += count(kid);
|
|
267
|
+
}
|
|
268
|
+
leaves.set(id, n);
|
|
269
|
+
return n;
|
|
270
|
+
};
|
|
271
|
+
let total = 0;
|
|
272
|
+
for(let root of t.roots) {
|
|
273
|
+
total += count(root);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/* One root sits at the centre; several sit on the first ring
|
|
277
|
+
* around an empty centre, so every depth shifts out by one. */
|
|
278
|
+
let single = (t.roots.length === 1);
|
|
279
|
+
let ring_of = (id) => t.depth.get(id) + (single? 0 : 1);
|
|
280
|
+
|
|
281
|
+
/* Pass 1, the ANGLES: a node's children split its sector by
|
|
282
|
+
* their leaves, and a node sits at the middle of its sector.
|
|
283
|
+
* Angles do not depend on the radii, so they come first. */
|
|
284
|
+
let angle = new Map();
|
|
285
|
+
let rings = []; /* ring -> [{id, angle, diag}] */
|
|
286
|
+
let sector = (id, from, span) => {
|
|
287
|
+
let a = from + span / 2;
|
|
288
|
+
angle.set(id, a);
|
|
289
|
+
let ring = ring_of(id);
|
|
290
|
+
if(!rings[ring]) {
|
|
291
|
+
rings[ring] = [];
|
|
292
|
+
}
|
|
293
|
+
let n = t.by_id.get(id);
|
|
294
|
+
rings[ring].push({id: id, angle: a, diag: Math.hypot(n.w, n.h)});
|
|
295
|
+
let cursor = from;
|
|
296
|
+
for(let kid of (t.children.get(id) || [])) {
|
|
297
|
+
let part = span * leaves.get(kid) / leaves.get(id);
|
|
298
|
+
sector(kid, cursor, part);
|
|
299
|
+
cursor += part;
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
if(single) {
|
|
303
|
+
let root = t.roots[0];
|
|
304
|
+
angle.set(root, 0);
|
|
305
|
+
let cursor = -Math.PI / 2; /* the first child at the top */
|
|
306
|
+
for(let kid of (t.children.get(root) || [])) {
|
|
307
|
+
let part = 2 * Math.PI * leaves.get(kid) / leaves.get(root);
|
|
308
|
+
sector(kid, cursor, part);
|
|
309
|
+
cursor += part;
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
let cursor = -Math.PI / 2;
|
|
313
|
+
for(let root of t.roots) {
|
|
314
|
+
let part = 2 * Math.PI * leaves.get(root) / total;
|
|
315
|
+
sector(root, cursor, part);
|
|
316
|
+
cursor += part;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* Pass 2, the RADII: a ring is one step further out than the
|
|
321
|
+
* ring before, and as far out as its two closest neighbours need
|
|
322
|
+
* to sit side by side -- the arc between two adjacent centres is
|
|
323
|
+
* r·Δangle, and it has to hold half of each card plus the gap.
|
|
324
|
+
* Measured on NEIGHBOURS, not on the ring's total length: a
|
|
325
|
+
* leafless root squeezed between two big subtrees gets a sliver
|
|
326
|
+
* of angle, and it is the sliver that sets the radius. */
|
|
327
|
+
let radius = [0];
|
|
328
|
+
for(let ring = 1; ring < rings.length; ring++) {
|
|
329
|
+
let r = radius[ring - 1] + o.ranksep;
|
|
330
|
+
let list = (rings[ring] || []).slice().sort((a, b) => a.angle - b.angle);
|
|
331
|
+
for(let i = 0; i < list.length && list.length > 1; i++) {
|
|
332
|
+
let a = list[i];
|
|
333
|
+
let b = list[(i + 1) % list.length];
|
|
334
|
+
let gap = (i + 1 < list.length)
|
|
335
|
+
? b.angle - a.angle
|
|
336
|
+
: b.angle + 2 * Math.PI - a.angle;
|
|
337
|
+
if(gap > 1e-9) {
|
|
338
|
+
r = Math.max(r, (a.diag / 2 + b.diag / 2 + o.nodesep) / gap);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
radius[ring] = r;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/* Pass 3, the positions. */
|
|
345
|
+
for(let [id, a] of angle) {
|
|
346
|
+
let r = radius[ring_of(id)] || 0;
|
|
347
|
+
pos.set(id, {x: r * Math.cos(a), y: r * Math.sin(a)});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return pos;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/************************************************************
|
|
354
|
+
* The outline: one node per row, indented by depth, in the
|
|
355
|
+
* pre-order of the spanning tree. Rows are left-aligned on
|
|
356
|
+
* their indent, so a column of chips reads as a list.
|
|
357
|
+
************************************************************/
|
|
358
|
+
export function layout_outline(nodes, edges, opts)
|
|
359
|
+
{
|
|
360
|
+
let o = Object.assign({}, DEFAULTS, opts || {});
|
|
361
|
+
let t = spanning_tree(nodes, edges);
|
|
362
|
+
let pos = new Map();
|
|
363
|
+
|
|
364
|
+
let y = 0;
|
|
365
|
+
let visit = (id) => {
|
|
366
|
+
let n = t.by_id.get(id);
|
|
367
|
+
let d = t.depth.get(id);
|
|
368
|
+
pos.set(id, {x: d * o.indent + n.w / 2, y: y + n.h / 2});
|
|
369
|
+
y += n.h + o.nodesep;
|
|
370
|
+
for(let kid of (t.children.get(id) || [])) {
|
|
371
|
+
visit(kid);
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
for(let root of t.roots) {
|
|
375
|
+
visit(root);
|
|
376
|
+
y += o.nodesep; /* a breath between two trees */
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return pos;
|
|
380
|
+
}
|