@yuneta/gobj-ui 7.23.19 → 7.23.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.23.19",
3
+ "version": "7.23.21",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -80,6 +80,7 @@ import {
80
80
  set_cancel_state,
81
81
  set_active_state,
82
82
  set_pressed_state,
83
+ getPointPosition,
83
84
  getStrokeColor,
84
85
  } from "./lib_graph.js";
85
86
 
@@ -1598,59 +1599,6 @@ function configure_behaviour(gobj)
1598
1599
  graph_write_behaviors(gobj, behaviors);
1599
1600
  }
1600
1601
 
1601
- /************************************************************
1602
- * Returns the proportional position (between 0 and 1) of a specific point,
1603
- * centered and spaced with margins.
1604
- *
1605
- * index - Index of the point (0 to count-1)
1606
- * count - Total number of points
1607
- * margin - Total margin space (default 0.2 means 10% on each end)
1608
- ************************************************************/
1609
- function getPointPosition(count, index, margin = 0.2)
1610
- {
1611
- if(count <= 0 || index < 0 || index >= count) {
1612
- log_error("Invalid count or index");
1613
- return 0.5;
1614
- }
1615
-
1616
- const start = margin / 2;
1617
- const end = 1 - margin / 2;
1618
- const step = (end - start) / count;
1619
-
1620
- return start + index * step + step / 2;
1621
- }
1622
-
1623
- /************************************************************
1624
- * Count hooks and fkeys in topic desc, classify node type
1625
- ************************************************************/
1626
- function calculate_hooks_fkeys_counter(desc)
1627
- {
1628
- let cols = desc.cols;
1629
- desc.hooks_counter = 0;
1630
- desc.fkeys_counter = 0;
1631
-
1632
- for(let i=0; i<cols.length; i++) {
1633
- let col = cols[i];
1634
- const field_desc = treedb_get_field_desc(col);
1635
- switch(field_desc.type) {
1636
- case "hook":
1637
- desc.hooks_counter++;
1638
- break;
1639
- case "fkey":
1640
- desc.fkeys_counter++;
1641
- break;
1642
- }
1643
- }
1644
-
1645
- if(desc.hooks_counter === 0) {
1646
- desc.node_treedb_type = 'child';
1647
- } else if(desc.fkeys_counter === 0) {
1648
- desc.node_treedb_type = 'extended';
1649
- } else {
1650
- desc.node_treedb_type = 'hierarchical';
1651
- }
1652
- }
1653
-
1654
1602
  /************************************************************
1655
1603
  * Build ports for a topic desc
1656
1604
  ************************************************************/
@@ -72,6 +72,12 @@ import {
72
72
  import { ensure_drag_canvas_patch } from "./g6_drag_canvas_touch.js";
73
73
  import { ensure_pinch_zoom_patch } from "./g6_touch_gestures.js";
74
74
  import { yui_is_dark, yui_theme_now, yui_watch_theme } from "./yui_theme.js";
75
+ import { getPointPosition } from "./lib_graph.js";
76
+ import {
77
+ is_pure_collection,
78
+ has_branch,
79
+ count_branches,
80
+ } from "./json_view_helpers.js";
75
81
 
76
82
  /***************************************************************
77
83
  * Constants
@@ -767,10 +773,22 @@ function build_graph(gobj)
767
773
  }
768
774
  }
769
775
 
776
+ /************************************************************
777
+ * The port a container key opens on its card.
778
+ *
779
+ * Prefixed so it can never collide with anything G6 puts in
780
+ * the same namespace, and stringified because an array index
781
+ * is a number and a port key is a string.
782
+ ************************************************************/
783
+ function port_key(key)
784
+ {
785
+ return "p_" + String(key);
786
+ }
787
+
770
788
  /************************************************************
771
789
  * Build cell value HTML (matching old mx_json_viewer colors)
772
790
  ************************************************************/
773
- function build_cell_html(key, value, type, dark, matched)
791
+ function build_cell_html(key, value, type, dark, matched, fold)
774
792
  {
775
793
  /*
776
794
  * A match is a LIGHT chip on BOTH themes, so what sits on it is
@@ -813,6 +831,19 @@ function build_cell_html(key, value, type, dark, matched)
813
831
  let row = `<span style="color:${key_color}">• ${escapeHtml(String(key))}: </span>` +
814
832
  `<span style="color:${color}">${display_value}</span>`;
815
833
 
834
+ /*
835
+ * A fold chip ON THE ROW, for a container that gets no card of its
836
+ * own: the control belongs next to the key it folds, which is
837
+ * where a tree puts it, and it is the only handle those children
838
+ * have -- their parent's header chip folds every branch it has,
839
+ * not this one.
840
+ */
841
+ if(fold) {
842
+ row += `<span style="margin-left:8px;">` +
843
+ fold_toggle_html(fold.path, true, fold.folded, fold.branches, fold.colors) +
844
+ `</span>`;
845
+ }
846
+
816
847
  if(matched) {
817
848
  /* The highlight is baked into the card's MARKUP and not set as
818
849
  * a G6 node state: the key shape of an `html` node is a DOM
@@ -963,7 +994,7 @@ function get_group_label(path)
963
994
  /************************************************************
964
995
  * Recursively build nodes and edges from JSON
965
996
  ************************************************************/
966
- function build_json_nodes(gobj, path, kw, nodes, edges, parent_id)
997
+ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id, parent_port)
967
998
  {
968
999
  let group_id = gen_node_id(gobj);
969
1000
  let is_dict = is_object(kw);
@@ -985,6 +1016,10 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id)
985
1016
  let pending_complex = [];
986
1017
  let card_matched = false;
987
1018
 
1019
+ let colors = json_card_style(
1020
+ is_dict ? GROUP_COLORS.dict : GROUP_COLORS.list, dark
1021
+ );
1022
+
988
1023
  let entries = is_dict ? Object.entries(kw) : kw.map((v, i) => [i, v]);
989
1024
 
990
1025
  for(let [key, value] of entries) {
@@ -996,32 +1031,72 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id)
996
1031
  }
997
1032
 
998
1033
  /*
999
- * A non-empty container gets a CARD OF ITS OWN, so a row for it
1000
- * here would be the same thing drawn twice -- and the second
1001
- * drawing is the one that does not scale. An array of 14 dicts
1002
- * printed fourteen `0: {13}` rows above fourteen cards saying
1003
- * the same; an array of a thousand prints a thousand-row card
1004
- * nobody can read, beside the thousand cards it duplicates.
1034
+ * EVERY key is a row, containers included: `cols` is one key of
1035
+ * the topic dict exactly like `pkey` and `tkey`, and a drawing
1036
+ * that leaves it out of the card does not say what the document
1037
+ * says.
1005
1038
  *
1006
- * What is left in the body is exactly the SCALARS: what this
1007
- * node IS. What it CONTAINS is the edges, and the count now
1008
- * rides in the header for the case where the body comes out
1009
- * empty.
1039
+ * What a container key does NOT get is its contents repeated
1040
+ * underneath it -- the row says `[14]` and the fourteen cards
1041
+ * say the rest. That is the part which does not scale: an array
1042
+ * of a thousand dicts would otherwise print a thousand-row card
1043
+ * beside the thousand cards it duplicates.
1010
1044
  */
1011
- if((is_object(value) && json_object_size(value) > 0) ||
1012
- (is_array(value) && value.length > 0)) {
1013
- pending_complex.push({key: key, value: value});
1014
- continue;
1045
+ let fold = null;
1046
+ if(has_branch(value)) {
1047
+ pending_complex.push({key: key, value: value, port: port_key(key)});
1048
+
1049
+ /*
1050
+ * A child that gets no card of its own is folded from HERE
1051
+ * or from nowhere.
1052
+ */
1053
+ if(is_pure_collection(value)) {
1054
+ let child_path = add_segment(path, key);
1055
+ fold = {
1056
+ path: child_path,
1057
+ folded: priv.collapsed.has(child_path),
1058
+ branches: count_branches(value),
1059
+ colors: colors,
1060
+ };
1061
+ }
1015
1062
  }
1016
1063
 
1017
- lines.push(build_cell_html(key, value, type, dark, matched));
1064
+ lines.push(build_cell_html(key, value, type, dark, matched, fold));
1018
1065
  }
1019
1066
 
1020
- let label = get_group_label(path) || (is_dict ? "{}" : "[]");
1021
- if(pending_complex.length > 0) {
1022
- label += is_dict? ` {${pending_complex.length}}`: ` [${pending_complex.length}]`;
1067
+ /*
1068
+ * A pure collection is not drawn: its row above names it and its
1069
+ * children hang from THIS card's parent. The root always gets a
1070
+ * card, or a document that is a plain list of records would have
1071
+ * nothing to hang anything from.
1072
+ */
1073
+ if(parent_id && is_pure_collection(kw)) {
1074
+ if(priv.collapsed.has(path)) {
1075
+ return parent_id;
1076
+ }
1077
+ for(let pending of pending_complex) {
1078
+ /*
1079
+ * `parent_port` and not one of ours: this collection has no
1080
+ * card, so the port its row opened in the PARENT is the one
1081
+ * every one of these lines leaves from -- fourteen columns
1082
+ * out of the single `cols` port, which is what the document
1083
+ * says and what the reader traces back.
1084
+ */
1085
+ build_json_nodes(
1086
+ gobj,
1087
+ add_segment(path, pending.key),
1088
+ pending.value,
1089
+ nodes,
1090
+ edges,
1091
+ parent_id,
1092
+ parent_port
1093
+ );
1094
+ }
1095
+ return parent_id;
1023
1096
  }
1024
1097
 
1098
+ let label = get_group_label(path) || (is_dict ? "{}" : "[]");
1099
+
1025
1100
  /*
1026
1101
  * Folded: keep the card, drop the branch, and SAY how much went
1027
1102
  * with it. A cut that is not marked reads as a document that ends
@@ -1032,9 +1107,6 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id)
1032
1107
  */
1033
1108
  let foldable = pending_complex.length > 0;
1034
1109
  let folded = foldable && priv.collapsed.has(path);
1035
- let colors = json_card_style(
1036
- is_dict ? GROUP_COLORS.dict : GROUP_COLORS.list, dark
1037
- );
1038
1110
  let border_color = card_matched? (dark? "#ffb300": "#ff8f00"): colors.border;
1039
1111
  let border_width = card_matched? 2: 1;
1040
1112
 
@@ -1069,6 +1141,38 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id)
1069
1141
  ${content_html}
1070
1142
  </div>`;
1071
1143
 
1144
+ /*
1145
+ * ONE PORT PER CONTAINER KEY, and every line leaves from its own.
1146
+ *
1147
+ * Without them each card was a single anchor: fourteen lines came
1148
+ * out of one point, and which row a line belonged to was a guess
1149
+ * the reader had to make from where it landed. A port makes the row
1150
+ * the ORIGIN, which is the same thing the treedb graph does with a
1151
+ * hook column and the reason `getPointPosition` is now shared.
1152
+ *
1153
+ * On the BOTTOM edge because the layouts put children below, and
1154
+ * exactly ON it (`y = 1`) rather than inside: an html node draws its
1155
+ * HTML in a DOM layer above the canvas, so a port fully inside the
1156
+ * box would be painted under the card and never seen. Half of it
1157
+ * sticks out.
1158
+ *
1159
+ * Coloured by what the row LEADS TO -- a dict or a list -- which is
1160
+ * the one thing the port can say that the row does not.
1161
+ */
1162
+ let ports = [];
1163
+ for(let i = 0; i < pending_complex.length; i++) {
1164
+ let pending = pending_complex[i];
1165
+ let child_colors = json_card_style(
1166
+ is_object(pending.value)? GROUP_COLORS.dict: GROUP_COLORS.list, dark
1167
+ );
1168
+ ports.push({
1169
+ key: pending.port,
1170
+ placement: [getPointPosition(pending_complex.length, i), 1],
1171
+ fill: child_colors.border,
1172
+ stroke: colors.border,
1173
+ });
1174
+ }
1175
+
1072
1176
  let node = {
1073
1177
  id: group_id,
1074
1178
  type: 'html',
@@ -1081,22 +1185,30 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id)
1081
1185
  size: [min_width, 24 + lines.length * 22],
1082
1186
  dx: -(min_width / 2),
1083
1187
  dy: -((24 + lines.length * 22) / 2),
1188
+ port: ports.length > 0,
1189
+ ports: ports,
1190
+ portR: 4,
1191
+ portLineWidth: 1,
1084
1192
  },
1085
1193
  };
1086
1194
  nodes.push(node);
1087
1195
 
1088
1196
  /*
1089
- * Edge from parent to this group
1197
+ * Edge from parent to this group, out of the port its key opened.
1090
1198
  */
1091
1199
  if(parent_id) {
1092
- edges.push({
1200
+ let edge = {
1093
1201
  source: parent_id,
1094
1202
  target: group_id,
1095
1203
  style: {
1096
1204
  stroke: colors.border,
1097
1205
  lineWidth: 1,
1098
1206
  }
1099
- });
1207
+ };
1208
+ if(parent_port) {
1209
+ edge.style.sourcePort = parent_port;
1210
+ }
1211
+ edges.push(edge);
1100
1212
  }
1101
1213
 
1102
1214
  /*
@@ -1112,7 +1224,8 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id)
1112
1224
  pending.value,
1113
1225
  nodes,
1114
1226
  edges,
1115
- group_id
1227
+ group_id,
1228
+ pending.port
1116
1229
  );
1117
1230
  }
1118
1231
 
@@ -1190,8 +1303,11 @@ function load_json(gobj)
1190
1303
 
1191
1304
  /************************************************************
1192
1305
  * Every path that HAS a branch to fold, in the same recursion
1193
- * build_json_nodes uses — so "collapse all" can never name a
1194
- * card that does not exist. The root is excluded: folding it
1306
+ * build_json_nodes uses — so "collapse all" can never name
1307
+ * something with no handle. The handle is a card's header chip
1308
+ * for a container that gets a card, and the chip on its ROW in
1309
+ * the parent for a pure collection, which gets none; both are
1310
+ * keyed by this same path. The root is excluded: folding it
1195
1311
  * would leave an empty canvas.
1196
1312
  ************************************************************/
1197
1313
  function collect_foldable_paths(value, path, out, is_root)
@@ -1202,20 +1318,21 @@ function collect_foldable_paths(value, path, out, is_root)
1202
1318
  return false;
1203
1319
  }
1204
1320
 
1205
- let has_branch = false;
1321
+ /* `found_branch` and not `has_branch`: that name belongs to the
1322
+ * helper above, and a local would shadow it here. */
1323
+ let found_branch = false;
1206
1324
  let entries = is_dict ? Object.entries(value) : value.map((v, i) => [i, v]);
1207
1325
  for(let [key, child] of entries) {
1208
- if((is_object(child) && json_object_size(child) > 0) ||
1209
- (is_array(child) && child.length > 0)) {
1210
- has_branch = true;
1326
+ if(has_branch(child)) {
1327
+ found_branch = true;
1211
1328
  collect_foldable_paths(child, add_segment(path, key), out, false);
1212
1329
  }
1213
1330
  }
1214
1331
 
1215
- if(has_branch && !is_root) {
1332
+ if(found_branch && !is_root) {
1216
1333
  out.push(path);
1217
1334
  }
1218
- return has_branch;
1335
+ return found_branch;
1219
1336
  }
1220
1337
 
1221
1338
  /************************************************************
@@ -314,3 +314,70 @@ export function container_label(value, max_chars)
314
314
  }
315
315
  return text;
316
316
  }
317
+
318
+
319
+ /************************************************************
320
+ * A dict, in the JSON sense: not an array and not null.
321
+ ************************************************************/
322
+ function is_plain_object(v)
323
+ {
324
+ return v !== null && typeof v === "object" && !Array.isArray(v);
325
+ }
326
+
327
+ /************************************************************
328
+ * Does this container hold ONLY other containers?
329
+ *
330
+ * Such a value is not a thing, it is a LIST of things -- and a
331
+ * list is fully described by the row that names it in its
332
+ * parent, plus the edges to what it holds. Drawing it as a
333
+ * card of its own put a node in the middle of the graph with no
334
+ * data in it, saying nothing the parent's row did not already
335
+ * say, and pushed everything below it one level down.
336
+ *
337
+ * An EMPTY container inside counts as content: `{0}` is
338
+ * something the card has to show, and there is no child card
339
+ * to show it instead.
340
+ ************************************************************/
341
+ export function is_pure_collection(value)
342
+ {
343
+ let entries = is_plain_object(value)? Object.entries(value)
344
+ : Array.isArray(value)? value.map((v, i) => [i, v])
345
+ : [];
346
+
347
+ if(entries.length === 0) {
348
+ return false;
349
+ }
350
+ for(let [, v] of entries) {
351
+ if(!has_branch(v)) {
352
+ return false;
353
+ }
354
+ }
355
+ return true;
356
+ }
357
+
358
+ /************************************************************
359
+ * A value that gets a card, or children, of its own.
360
+ ************************************************************/
361
+ export function has_branch(value)
362
+ {
363
+ return (is_plain_object(value) && Object.keys(value).length > 0) ||
364
+ (Array.isArray(value) && value.length > 0);
365
+ }
366
+
367
+ /************************************************************
368
+ * How many branches a container has, which is what the fold
369
+ * chip counts.
370
+ ************************************************************/
371
+ export function count_branches(value)
372
+ {
373
+ let entries = is_plain_object(value)? Object.values(value)
374
+ : Array.isArray(value)? value
375
+ : [];
376
+ let n = 0;
377
+ for(let v of entries) {
378
+ if(has_branch(v)) {
379
+ n++;
380
+ }
381
+ }
382
+ return n;
383
+ }
@@ -18,6 +18,9 @@ import {
18
18
  json_text_dump,
19
19
  pick_view_mode,
20
20
  container_label,
21
+ is_pure_collection,
22
+ has_branch,
23
+ count_branches,
21
24
  } from "./json_view_helpers.js";
22
25
 
23
26
 
@@ -243,3 +246,67 @@ test("container_label: cut at max_chars, and said with an ellipsis", () => {
243
246
  expect(container_label({id: uuid}, 0)).toBe(uuid);
244
247
  expect(container_label({id: uuid}, undefined)).toBe(uuid);
245
248
  });
249
+
250
+ /***************************************************************
251
+ * has_branch() / count_branches() / is_pure_collection()
252
+ ***************************************************************/
253
+ test("has_branch: only a NON-EMPTY container branches", () => {
254
+ expect(has_branch({a: 1})).toBe(true);
255
+ expect(has_branch([1])).toBe(true);
256
+ expect(has_branch({})).toBe(false);
257
+ expect(has_branch([])).toBe(false);
258
+ expect(has_branch("x")).toBe(false);
259
+ expect(has_branch(0)).toBe(false);
260
+ expect(has_branch(null)).toBe(false);
261
+ });
262
+
263
+ test("count_branches: counts what gets a card, not what is there", () => {
264
+ expect(count_branches({a: 1, b: {}, c: {x: 1}, d: [1]})).toBe(2);
265
+ expect(count_branches([{}, [], {a: 1}])).toBe(1);
266
+ expect(count_branches({a: 1})).toBe(0);
267
+ expect(count_branches("x")).toBe(0);
268
+ });
269
+
270
+ test("is_pure_collection: a LIST of things, not a thing", () => {
271
+ /* Every entry a container with something in it. */
272
+ expect(is_pure_collection([{a: 1}, {b: 2}])).toBe(true);
273
+ expect(is_pure_collection({x: {a: 1}, y: [1]})).toBe(true);
274
+
275
+ /* One scalar is enough to make it a thing with content of its own,
276
+ * and so is an EMPTY container: `{0}` has no child card to show it. */
277
+ expect(is_pure_collection([{a: 1}, 2])).toBe(false);
278
+ expect(is_pure_collection({x: {a: 1}, n: 3})).toBe(false);
279
+ expect(is_pure_collection([{a: 1}, {}])).toBe(false);
280
+
281
+ expect(is_pure_collection([])).toBe(false);
282
+ expect(is_pure_collection({})).toBe(false);
283
+ expect(is_pure_collection("x")).toBe(false);
284
+ });
285
+
286
+ test("is_pure_collection: the schema this came from", () => {
287
+ /* The `devices` topic descriptor, cut to shape: `cols` is one key of
288
+ * the dict like `pkey` is, and the only one that branches. */
289
+ let col = {id: "id", header: "Device", type: "string", flag: [], enum: {}};
290
+ let topic = {
291
+ topic_name: "devices",
292
+ pkey: "id",
293
+ pkey2s: [], /* empty: a row, not a branch */
294
+ tkey: "tm",
295
+ system_flag: 1,
296
+ topic_version: 3,
297
+ cols: [col, col, col],
298
+ };
299
+
300
+ /* The topic is NOT a pure collection: it has six scalar rows, so it
301
+ * keeps its card. */
302
+ expect(is_pure_collection(topic)).toBe(false);
303
+ expect(count_branches(topic)).toBe(1);
304
+
305
+ /* `cols` IS one: nothing but columns. It gets no card of its own --
306
+ * its row in the topic names it, and the columns hang from the topic. */
307
+ expect(is_pure_collection(topic.cols)).toBe(true);
308
+ expect(count_branches(topic.cols)).toBe(3);
309
+
310
+ /* A column has content, so it keeps its card. */
311
+ expect(is_pure_collection(col)).toBe(false);
312
+ });
package/src/lib_graph.js CHANGED
@@ -8,6 +8,7 @@
8
8
  ***********************************************************************/
9
9
  import {
10
10
  gobj_read_attr,
11
+ log_error,
11
12
  } from "@yuneta/gobj-js";
12
13
 
13
14
  // import {t} from "i18next";
@@ -195,6 +196,59 @@ function getStrokeColor(fillColor, theme = 'light', factor = 0.2) {
195
196
  //=======================================================================
196
197
  // Expose the class via the global object
197
198
  //=======================================================================
199
+ /************************************************************
200
+ * Returns the proportional position (between 0 and 1) of a specific point,
201
+ * centered and spaced with margins.
202
+ *
203
+ * index - Index of the point (0 to count-1)
204
+ * count - Total number of points
205
+ * margin - Total margin space (default 0.2 means 10% on each end)
206
+ ************************************************************/
207
+ function getPointPosition(count, index, margin = 0.2)
208
+ {
209
+ if(count <= 0 || index < 0 || index >= count) {
210
+ log_error("Invalid count or index");
211
+ return 0.5;
212
+ }
213
+
214
+ const start = margin / 2;
215
+ const end = 1 - margin / 2;
216
+ const step = (end - start) / count;
217
+
218
+ return start + index * step + step / 2;
219
+ }
220
+
221
+ /************************************************************
222
+ * Count hooks and fkeys in topic desc, classify node type
223
+ ************************************************************/
224
+ function calculate_hooks_fkeys_counter(desc)
225
+ {
226
+ let cols = desc.cols;
227
+ desc.hooks_counter = 0;
228
+ desc.fkeys_counter = 0;
229
+
230
+ for(let i=0; i<cols.length; i++) {
231
+ let col = cols[i];
232
+ const field_desc = treedb_get_field_desc(col);
233
+ switch(field_desc.type) {
234
+ case "hook":
235
+ desc.hooks_counter++;
236
+ break;
237
+ case "fkey":
238
+ desc.fkeys_counter++;
239
+ break;
240
+ }
241
+ }
242
+
243
+ if(desc.hooks_counter === 0) {
244
+ desc.node_treedb_type = 'child';
245
+ } else if(desc.fkeys_counter === 0) {
246
+ desc.node_treedb_type = 'extended';
247
+ } else {
248
+ desc.node_treedb_type = 'hierarchical';
249
+ }
250
+ }
251
+
198
252
  export {
199
253
  addClasses,
200
254
  removeClasses,
@@ -207,4 +261,5 @@ export {
207
261
  set_active_state,
208
262
  set_pressed_state,
209
263
  getStrokeColor,
264
+ getPointPosition,
210
265
  };