@yuneta/gobj-ui 7.23.18 → 7.23.20
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/dist/gobj-ui.cjs.js +819 -380
- package/dist/gobj-ui.es.js +811 -372
- package/package.json +1 -1
- package/src/c_g6_nodes_tree.js +135 -3
- package/src/c_yui_gobj_tree_js.js +185 -7
- package/src/c_yui_json_graph.js +269 -24
- package/src/json_view_helpers.js +67 -0
- package/src/json_view_helpers.test.js +67 -0
- package/src/lib_icons.js +7 -0
- package/src/yui_graph_camera.js +95 -0
package/src/c_yui_json_graph.js
CHANGED
|
@@ -50,6 +50,9 @@ import {yui_toolbar} from "./yui_toolbar.js";
|
|
|
50
50
|
import {attach_clear} from "./yui_inputs.js";
|
|
51
51
|
import {
|
|
52
52
|
yui_graph_camera_items,
|
|
53
|
+
yui_graph_anchor_item,
|
|
54
|
+
yui_graph_update_anchor,
|
|
55
|
+
yui_graph_center_on,
|
|
53
56
|
yui_graph_fold_items,
|
|
54
57
|
yui_graph_refresh_item,
|
|
55
58
|
yui_graph_update_zoom,
|
|
@@ -69,6 +72,11 @@ import {
|
|
|
69
72
|
import { ensure_drag_canvas_patch } from "./g6_drag_canvas_touch.js";
|
|
70
73
|
import { ensure_pinch_zoom_patch } from "./g6_touch_gestures.js";
|
|
71
74
|
import { yui_is_dark, yui_theme_now, yui_watch_theme } from "./yui_theme.js";
|
|
75
|
+
import {
|
|
76
|
+
is_pure_collection,
|
|
77
|
+
has_branch,
|
|
78
|
+
count_branches,
|
|
79
|
+
} from "./json_view_helpers.js";
|
|
72
80
|
|
|
73
81
|
/***************************************************************
|
|
74
82
|
* Constants
|
|
@@ -190,6 +198,12 @@ let PRIVATE_DATA = {
|
|
|
190
198
|
$find_input: null,
|
|
191
199
|
$find_result: null,
|
|
192
200
|
$find_count: null,
|
|
201
|
+
|
|
202
|
+
/*---------------- camera anchor ----------------*/
|
|
203
|
+
anchor_id: "", // the node the camera keeps in the middle
|
|
204
|
+
anchor_path: "", // its path, so a rebuild can find it again
|
|
205
|
+
anchor_state: "off", // "off" | "arming" | "on"
|
|
206
|
+
last_zoom: 1, // tells a wheel zoom from a drag in aftertransform
|
|
193
207
|
};
|
|
194
208
|
|
|
195
209
|
let __gclass__ = null;
|
|
@@ -592,6 +606,7 @@ function make_toolbar(gobj)
|
|
|
592
606
|
* the same console and a copied toolbar drifts.
|
|
593
607
|
*/
|
|
594
608
|
center_items = yui_graph_camera_items(gobj, priv.graph, toolbar_wide);
|
|
609
|
+
center_items.push(yui_graph_anchor_item(gobj, toolbar_wide));
|
|
595
610
|
center_items.push(yui_graph_refresh_item(gobj, toolbar_wide));
|
|
596
611
|
|
|
597
612
|
/*
|
|
@@ -697,6 +712,30 @@ function build_graph(gobj)
|
|
|
697
712
|
* after every notch. */
|
|
698
713
|
graph.on('aftertransform', () => {
|
|
699
714
|
yui_graph_update_zoom(gobj_read_attr(gobj, "$container"), priv.graph);
|
|
715
|
+
|
|
716
|
+
/*
|
|
717
|
+
* A ZOOM re-centres on the anchor; a PAN does not.
|
|
718
|
+
* `aftertransform` fires for both, and an anchor that also
|
|
719
|
+
* answered a drag would make the graph impossible to move
|
|
720
|
+
* while it is set. The zoom LEVEL is what tells them apart:
|
|
721
|
+
* it changes on a wheel notch and never on a drag.
|
|
722
|
+
*
|
|
723
|
+
* No recursion: the centring translates without zooming, so
|
|
724
|
+
* the level it fires back with is the one just stored.
|
|
725
|
+
*/
|
|
726
|
+
let z = priv.last_zoom;
|
|
727
|
+
try {
|
|
728
|
+
z = priv.graph.getZoom();
|
|
729
|
+
} catch(e) {
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if(z === priv.last_zoom) {
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
priv.last_zoom = z;
|
|
736
|
+
if(priv.anchor_state === "on" && priv.anchor_id) {
|
|
737
|
+
yui_graph_center_on(priv.graph, priv.anchor_id);
|
|
738
|
+
}
|
|
700
739
|
});
|
|
701
740
|
|
|
702
741
|
graph.on(CanvasEvent.CLICK, (evt) => {
|
|
@@ -736,7 +775,7 @@ function build_graph(gobj)
|
|
|
736
775
|
/************************************************************
|
|
737
776
|
* Build cell value HTML (matching old mx_json_viewer colors)
|
|
738
777
|
************************************************************/
|
|
739
|
-
function build_cell_html(key, value, type, dark, matched)
|
|
778
|
+
function build_cell_html(key, value, type, dark, matched, fold)
|
|
740
779
|
{
|
|
741
780
|
/*
|
|
742
781
|
* A match is a LIGHT chip on BOTH themes, so what sits on it is
|
|
@@ -779,6 +818,19 @@ function build_cell_html(key, value, type, dark, matched)
|
|
|
779
818
|
let row = `<span style="color:${key_color}">• ${escapeHtml(String(key))}: </span>` +
|
|
780
819
|
`<span style="color:${color}">${display_value}</span>`;
|
|
781
820
|
|
|
821
|
+
/*
|
|
822
|
+
* A fold chip ON THE ROW, for a container that gets no card of its
|
|
823
|
+
* own: the control belongs next to the key it folds, which is
|
|
824
|
+
* where a tree puts it, and it is the only handle those children
|
|
825
|
+
* have -- their parent's header chip folds every branch it has,
|
|
826
|
+
* not this one.
|
|
827
|
+
*/
|
|
828
|
+
if(fold) {
|
|
829
|
+
row += `<span style="margin-left:8px;">` +
|
|
830
|
+
fold_toggle_html(fold.path, true, fold.folded, fold.branches, fold.colors) +
|
|
831
|
+
`</span>`;
|
|
832
|
+
}
|
|
833
|
+
|
|
782
834
|
if(matched) {
|
|
783
835
|
/* The highlight is baked into the card's MARKUP and not set as
|
|
784
836
|
* a G6 node state: the key shape of an `html` node is a DOM
|
|
@@ -951,6 +1003,10 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id)
|
|
|
951
1003
|
let pending_complex = [];
|
|
952
1004
|
let card_matched = false;
|
|
953
1005
|
|
|
1006
|
+
let colors = json_card_style(
|
|
1007
|
+
is_dict ? GROUP_COLORS.dict : GROUP_COLORS.list, dark
|
|
1008
|
+
);
|
|
1009
|
+
|
|
954
1010
|
let entries = is_dict ? Object.entries(kw) : kw.map((v, i) => [i, v]);
|
|
955
1011
|
|
|
956
1012
|
for(let [key, value] of entries) {
|
|
@@ -960,13 +1016,62 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id)
|
|
|
960
1016
|
card_matched = true;
|
|
961
1017
|
priv.match_count++;
|
|
962
1018
|
}
|
|
963
|
-
let html = build_cell_html(key, value, type, dark, matched);
|
|
964
|
-
lines.push(html);
|
|
965
1019
|
|
|
966
|
-
|
|
967
|
-
|
|
1020
|
+
/*
|
|
1021
|
+
* EVERY key is a row, containers included: `cols` is one key of
|
|
1022
|
+
* the topic dict exactly like `pkey` and `tkey`, and a drawing
|
|
1023
|
+
* that leaves it out of the card does not say what the document
|
|
1024
|
+
* says.
|
|
1025
|
+
*
|
|
1026
|
+
* What a container key does NOT get is its contents repeated
|
|
1027
|
+
* underneath it -- the row says `[14]` and the fourteen cards
|
|
1028
|
+
* say the rest. That is the part which does not scale: an array
|
|
1029
|
+
* of a thousand dicts would otherwise print a thousand-row card
|
|
1030
|
+
* beside the thousand cards it duplicates.
|
|
1031
|
+
*/
|
|
1032
|
+
let fold = null;
|
|
1033
|
+
if(has_branch(value)) {
|
|
968
1034
|
pending_complex.push({key: key, value: value});
|
|
1035
|
+
|
|
1036
|
+
/*
|
|
1037
|
+
* A child that gets no card of its own is folded from HERE
|
|
1038
|
+
* or from nowhere.
|
|
1039
|
+
*/
|
|
1040
|
+
if(is_pure_collection(value)) {
|
|
1041
|
+
let child_path = add_segment(path, key);
|
|
1042
|
+
fold = {
|
|
1043
|
+
path: child_path,
|
|
1044
|
+
folded: priv.collapsed.has(child_path),
|
|
1045
|
+
branches: count_branches(value),
|
|
1046
|
+
colors: colors,
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
lines.push(build_cell_html(key, value, type, dark, matched, fold));
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/*
|
|
1055
|
+
* A pure collection is not drawn: its row above names it and its
|
|
1056
|
+
* children hang from THIS card's parent. The root always gets a
|
|
1057
|
+
* card, or a document that is a plain list of records would have
|
|
1058
|
+
* nothing to hang anything from.
|
|
1059
|
+
*/
|
|
1060
|
+
if(parent_id && is_pure_collection(kw)) {
|
|
1061
|
+
if(priv.collapsed.has(path)) {
|
|
1062
|
+
return parent_id;
|
|
1063
|
+
}
|
|
1064
|
+
for(let pending of pending_complex) {
|
|
1065
|
+
build_json_nodes(
|
|
1066
|
+
gobj,
|
|
1067
|
+
add_segment(path, pending.key),
|
|
1068
|
+
pending.value,
|
|
1069
|
+
nodes,
|
|
1070
|
+
edges,
|
|
1071
|
+
parent_id
|
|
1072
|
+
);
|
|
969
1073
|
}
|
|
1074
|
+
return parent_id;
|
|
970
1075
|
}
|
|
971
1076
|
|
|
972
1077
|
let label = get_group_label(path) || (is_dict ? "{}" : "[]");
|
|
@@ -981,9 +1086,6 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id)
|
|
|
981
1086
|
*/
|
|
982
1087
|
let foldable = pending_complex.length > 0;
|
|
983
1088
|
let folded = foldable && priv.collapsed.has(path);
|
|
984
|
-
let colors = json_card_style(
|
|
985
|
-
is_dict ? GROUP_COLORS.dict : GROUP_COLORS.list, dark
|
|
986
|
-
);
|
|
987
1089
|
let border_color = card_matched? (dark? "#ffb300": "#ff8f00"): colors.border;
|
|
988
1090
|
let border_width = card_matched? 2: 1;
|
|
989
1091
|
|
|
@@ -1103,20 +1205,47 @@ function load_json(gobj)
|
|
|
1103
1205
|
priv.pending_preserve_view = false;
|
|
1104
1206
|
|
|
1105
1207
|
if(nodes.length > 0) {
|
|
1208
|
+
let root_id = nodes[0].id; /* built before its children */
|
|
1106
1209
|
graph.setData({nodes: nodes, edges: edges});
|
|
1107
1210
|
graph.render().then(() => {
|
|
1108
|
-
|
|
1109
|
-
|
|
1211
|
+
/* Ids are generated per build: point the anchor at its path
|
|
1212
|
+
* again before anybody asks the camera to go there. */
|
|
1213
|
+
reanchor(gobj);
|
|
1214
|
+
|
|
1215
|
+
if(preserve_view) {
|
|
1216
|
+
yui_graph_update_zoom(gobj_read_attr(gobj, "$container"), priv.graph);
|
|
1217
|
+
return;
|
|
1110
1218
|
}
|
|
1111
|
-
|
|
1219
|
+
|
|
1220
|
+
/*
|
|
1221
|
+
* ACTUAL SIZE, not fit. A JSON graph of anything real does
|
|
1222
|
+
* not fit at a zoom anybody can read: the schema of one
|
|
1223
|
+
* topic fits at 37%, where every card is grey texture.
|
|
1224
|
+
* Fitting answered "where is everything" when the question
|
|
1225
|
+
* on opening is "what does this say".
|
|
1226
|
+
*
|
|
1227
|
+
* Centred on the ANCHOR if there is one, else on the ROOT
|
|
1228
|
+
* -- never parked at the layout's origin, which is a corner
|
|
1229
|
+
* with nothing in it and is where `1:1` used to land.
|
|
1230
|
+
*/
|
|
1231
|
+
Promise.resolve(graph.zoomTo(1)).then(() => {
|
|
1232
|
+
if(!yui_graph_center_on(graph, priv.anchor_id)) {
|
|
1233
|
+
yui_graph_center_on(graph, root_id);
|
|
1234
|
+
}
|
|
1235
|
+
priv.last_zoom = 1;
|
|
1236
|
+
yui_graph_update_zoom(gobj_read_attr(gobj, "$container"), priv.graph);
|
|
1237
|
+
});
|
|
1112
1238
|
});
|
|
1113
1239
|
}
|
|
1114
1240
|
}
|
|
1115
1241
|
|
|
1116
1242
|
/************************************************************
|
|
1117
1243
|
* Every path that HAS a branch to fold, in the same recursion
|
|
1118
|
-
* build_json_nodes uses — so "collapse all" can never name
|
|
1119
|
-
*
|
|
1244
|
+
* build_json_nodes uses — so "collapse all" can never name
|
|
1245
|
+
* something with no handle. The handle is a card's header chip
|
|
1246
|
+
* for a container that gets a card, and the chip on its ROW in
|
|
1247
|
+
* the parent for a pure collection, which gets none; both are
|
|
1248
|
+
* keyed by this same path. The root is excluded: folding it
|
|
1120
1249
|
* would leave an empty canvas.
|
|
1121
1250
|
************************************************************/
|
|
1122
1251
|
function collect_foldable_paths(value, path, out, is_root)
|
|
@@ -1127,20 +1256,21 @@ function collect_foldable_paths(value, path, out, is_root)
|
|
|
1127
1256
|
return false;
|
|
1128
1257
|
}
|
|
1129
1258
|
|
|
1130
|
-
|
|
1259
|
+
/* `found_branch` and not `has_branch`: that name belongs to the
|
|
1260
|
+
* helper above, and a local would shadow it here. */
|
|
1261
|
+
let found_branch = false;
|
|
1131
1262
|
let entries = is_dict ? Object.entries(value) : value.map((v, i) => [i, v]);
|
|
1132
1263
|
for(let [key, child] of entries) {
|
|
1133
|
-
if((
|
|
1134
|
-
|
|
1135
|
-
has_branch = true;
|
|
1264
|
+
if(has_branch(child)) {
|
|
1265
|
+
found_branch = true;
|
|
1136
1266
|
collect_foldable_paths(child, add_segment(path, key), out, false);
|
|
1137
1267
|
}
|
|
1138
1268
|
}
|
|
1139
1269
|
|
|
1140
|
-
if(
|
|
1270
|
+
if(found_branch && !is_root) {
|
|
1141
1271
|
out.push(path);
|
|
1142
1272
|
}
|
|
1143
|
-
return
|
|
1273
|
+
return found_branch;
|
|
1144
1274
|
}
|
|
1145
1275
|
|
|
1146
1276
|
/************************************************************
|
|
@@ -1490,6 +1620,93 @@ function ac_theme(gobj, event, kw, src)
|
|
|
1490
1620
|
return 0;
|
|
1491
1621
|
}
|
|
1492
1622
|
|
|
1623
|
+
/************************************************************
|
|
1624
|
+
* Every camera action ends the same way once an anchor is
|
|
1625
|
+
* set: the thing you were reading goes back to the middle.
|
|
1626
|
+
*
|
|
1627
|
+
* Chained on the promise G6 returns rather than run beside it
|
|
1628
|
+
* -- centring before the zoom has finished centres the OLD
|
|
1629
|
+
* view, and the reader watches the node slide away and come
|
|
1630
|
+
* back.
|
|
1631
|
+
************************************************************/
|
|
1632
|
+
function after_camera(gobj, moved)
|
|
1633
|
+
{
|
|
1634
|
+
let priv = gobj.priv;
|
|
1635
|
+
|
|
1636
|
+
if(priv.anchor_state !== "on" || !priv.anchor_id) {
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
Promise.resolve(moved).then(function() {
|
|
1640
|
+
yui_graph_center_on(priv.graph, priv.anchor_id);
|
|
1641
|
+
}).catch(function() {
|
|
1642
|
+
/* a camera move that never finished has nothing to centre */
|
|
1643
|
+
});
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/************************************************************
|
|
1647
|
+
* Point the anchor back at its node after a rebuild.
|
|
1648
|
+
*
|
|
1649
|
+
* Node ids are generated per build, so the id the reader
|
|
1650
|
+
* chose does not survive a refresh, a fold or a layout
|
|
1651
|
+
* change -- the PATH does, and it is what the anchor really
|
|
1652
|
+
* means: "this place in the document", not "this card".
|
|
1653
|
+
*
|
|
1654
|
+
* A path that is no longer drawn (its parent got folded)
|
|
1655
|
+
* releases the anchor rather than leaving a button that says
|
|
1656
|
+
* it is locked onto something invisible.
|
|
1657
|
+
************************************************************/
|
|
1658
|
+
function reanchor(gobj)
|
|
1659
|
+
{
|
|
1660
|
+
let priv = gobj.priv;
|
|
1661
|
+
|
|
1662
|
+
if(priv.anchor_state !== "on" || !priv.anchor_path) {
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
let found = "";
|
|
1667
|
+
try {
|
|
1668
|
+
for(let node of priv.graph.getNodeData()) {
|
|
1669
|
+
if(node && node.data && node.data.path === priv.anchor_path) {
|
|
1670
|
+
found = node.id;
|
|
1671
|
+
break;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
} catch(e) {
|
|
1675
|
+
return; /* between renders: keep what we have and try later */
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
priv.anchor_id = found;
|
|
1679
|
+
if(!found) {
|
|
1680
|
+
priv.anchor_state = "off";
|
|
1681
|
+
priv.anchor_path = "";
|
|
1682
|
+
}
|
|
1683
|
+
yui_graph_update_anchor(gobj_read_attr(gobj, "$container"), priv.anchor_state);
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
/************************************************************
|
|
1687
|
+
* Arm the anchor, or let it go.
|
|
1688
|
+
*
|
|
1689
|
+
* Three states and one button, so a press ADVANCES: with no
|
|
1690
|
+
* target it starts waiting for one, and with a target it
|
|
1691
|
+
* releases. Pressing it while it waits is a cancel, which is
|
|
1692
|
+
* what somebody who pressed it by mistake will try.
|
|
1693
|
+
************************************************************/
|
|
1694
|
+
function ac_toggle_anchor(gobj, event, kw, src)
|
|
1695
|
+
{
|
|
1696
|
+
let priv = gobj.priv;
|
|
1697
|
+
|
|
1698
|
+
if(priv.anchor_state === "off") {
|
|
1699
|
+
priv.anchor_state = "arming";
|
|
1700
|
+
} else {
|
|
1701
|
+
priv.anchor_state = "off";
|
|
1702
|
+
priv.anchor_id = "";
|
|
1703
|
+
priv.anchor_path = "";
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
yui_graph_update_anchor(gobj_read_attr(gobj, "$container"), priv.anchor_state);
|
|
1707
|
+
return 0;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1493
1710
|
/************************************************************
|
|
1494
1711
|
*
|
|
1495
1712
|
************************************************************/
|
|
@@ -1498,7 +1715,7 @@ function ac_zoom_in(gobj, event, kw, src)
|
|
|
1498
1715
|
let graph = gobj.priv.graph;
|
|
1499
1716
|
if(graph) {
|
|
1500
1717
|
let z = graph.getZoom();
|
|
1501
|
-
graph.zoomTo(z * 1.2);
|
|
1718
|
+
after_camera(gobj, graph.zoomTo(z * 1.2));
|
|
1502
1719
|
}
|
|
1503
1720
|
return 0;
|
|
1504
1721
|
}
|
|
@@ -1511,7 +1728,7 @@ function ac_zoom_out(gobj, event, kw, src)
|
|
|
1511
1728
|
let graph = gobj.priv.graph;
|
|
1512
1729
|
if(graph) {
|
|
1513
1730
|
let z = graph.getZoom();
|
|
1514
|
-
graph.zoomTo(z * 0.8);
|
|
1731
|
+
after_camera(gobj, graph.zoomTo(z * 0.8));
|
|
1515
1732
|
}
|
|
1516
1733
|
return 0;
|
|
1517
1734
|
}
|
|
@@ -1521,10 +1738,21 @@ function ac_zoom_out(gobj, event, kw, src)
|
|
|
1521
1738
|
************************************************************/
|
|
1522
1739
|
function ac_zoom_reset(gobj, event, kw, src)
|
|
1523
1740
|
{
|
|
1524
|
-
let
|
|
1741
|
+
let priv = gobj.priv;
|
|
1742
|
+
let graph = priv.graph;
|
|
1743
|
+
|
|
1525
1744
|
if(graph && graph.rendered) {
|
|
1526
|
-
|
|
1527
|
-
|
|
1745
|
+
/*
|
|
1746
|
+
* With an anchor, actual size means "this node, life size".
|
|
1747
|
+
* Without one it means the layout's origin, which is where
|
|
1748
|
+
* `1:1` always landed -- a corner nobody was looking at.
|
|
1749
|
+
*/
|
|
1750
|
+
if(priv.anchor_state === "on" && priv.anchor_id) {
|
|
1751
|
+
after_camera(gobj, graph.zoomTo(1));
|
|
1752
|
+
} else {
|
|
1753
|
+
graph.zoomTo(1);
|
|
1754
|
+
graph.translateTo([0, 0]);
|
|
1755
|
+
}
|
|
1528
1756
|
}
|
|
1529
1757
|
return 0;
|
|
1530
1758
|
}
|
|
@@ -1556,6 +1784,21 @@ function ac_node_click(gobj, event, kw, src)
|
|
|
1556
1784
|
nodedata = graph.getNodeData(node_id);
|
|
1557
1785
|
} catch(e) {}
|
|
1558
1786
|
|
|
1787
|
+
/*
|
|
1788
|
+
* A waiting anchor TAKES the click: it is what the reader armed
|
|
1789
|
+
* the button for, and letting the same press also open the item
|
|
1790
|
+
* would make picking a target a side effect of doing something
|
|
1791
|
+
* else.
|
|
1792
|
+
*/
|
|
1793
|
+
if(priv.anchor_state === "arming") {
|
|
1794
|
+
priv.anchor_id = node_id;
|
|
1795
|
+
priv.anchor_path = (nodedata && nodedata.data)? nodedata.data.path: "";
|
|
1796
|
+
priv.anchor_state = "on";
|
|
1797
|
+
yui_graph_update_anchor(gobj_read_attr(gobj, "$container"), priv.anchor_state);
|
|
1798
|
+
yui_graph_center_on(graph, node_id);
|
|
1799
|
+
return 0;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1559
1802
|
if(nodedata && nodedata.data) {
|
|
1560
1803
|
gobj_publish_event(gobj, "EV_JSON_ITEM_CLICKED", {
|
|
1561
1804
|
path: nodedata.data.path,
|
|
@@ -1677,6 +1920,7 @@ function create_gclass(gclass_name)
|
|
|
1677
1920
|
["EV_ZOOM_RESET", ac_zoom_reset, null],
|
|
1678
1921
|
["EV_CENTER", ac_center, null],
|
|
1679
1922
|
["EV_NODE_CLICK", ac_node_click, null],
|
|
1923
|
+
["EV_TOGGLE_ANCHOR", ac_toggle_anchor, null],
|
|
1680
1924
|
["EV_RESIZE", ac_resize, null],
|
|
1681
1925
|
["EV_SHOW", ac_show, null],
|
|
1682
1926
|
["EV_HIDE", ac_hide, null],
|
|
@@ -1700,6 +1944,7 @@ function create_gclass(gclass_name)
|
|
|
1700
1944
|
["EV_ZOOM_RESET", 0],
|
|
1701
1945
|
["EV_CENTER", 0],
|
|
1702
1946
|
["EV_NODE_CLICK", 0],
|
|
1947
|
+
["EV_TOGGLE_ANCHOR", 0],
|
|
1703
1948
|
["EV_RESIZE", 0],
|
|
1704
1949
|
["EV_SHOW", 0],
|
|
1705
1950
|
["EV_HIDE", 0],
|
package/src/json_view_helpers.js
CHANGED
|
@@ -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_icons.js
CHANGED
|
@@ -140,6 +140,13 @@ const ICONS = `<svg xmlns="http://www.w3.org/2000/svg">
|
|
|
140
140
|
stroke="currentColor" stroke-width="40"
|
|
141
141
|
stroke-dasharray="59 36"/>
|
|
142
142
|
</symbol>
|
|
143
|
+
<!-- location-crosshairs (anchor the camera on one element).
|
|
144
|
+
The SAME path as the DOM toolbars and their yi-location-crosshairs
|
|
145
|
+
mask: the two toolbars sit in one console, and a control that
|
|
146
|
+
does one thing has to be drawn one way. -->
|
|
147
|
+
<symbol id="g6-icon-anchor" viewBox="0 0 576 512">
|
|
148
|
+
<path fill="currentColor" d="M288-16c17.7 0 32 14.3 32 32l0 18.3c98.1 14 175.7 91.6 189.7 189.7l18.3 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-18.3 0c-14 98.1-91.6 175.7-189.7 189.7l0 18.3c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-18.3C157.9 463.7 80.3 386.1 66.3 288L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l18.3 0C80.3 125.9 157.9 48.3 256 34.3L256 16c0-17.7 14.3-32 32-32zM128 256a160 160 0 1 0 320 0 160 160 0 1 0 -320 0zm160-96a96 96 0 1 1 0 192 96 96 0 1 1 0-192z"/>
|
|
149
|
+
</symbol>
|
|
143
150
|
<!-- floppy-disk (save) -->
|
|
144
151
|
<symbol id="g6-icon-save" viewBox="0 0 448 512">
|
|
145
152
|
<path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/>
|