@yuneta/gobj-ui 7.23.83 → 7.23.84

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.
@@ -58,6 +58,7 @@ import {
58
58
  gobj_short_name,
59
59
  gobj_is_destroying,
60
60
  gobj_subscribe_event,
61
+ gobj_unsubscribe_event,
61
62
  gobj_publish_event,
62
63
  gobj_send_event,
63
64
  gobj_post_event,
@@ -143,7 +144,7 @@ import {
143
144
  } from '@antv/g6';
144
145
 
145
146
  import {Circle as CircleGeometry, Rect as RectGeometry} from '@antv/g';
146
- import i18next, {t} from "i18next";
147
+ import {t} from "i18next";
147
148
 
148
149
  import {inject_svg_icons} from "./lib_icons.js";
149
150
  import {ensure_drag_canvas_patch} from "./g6_drag_canvas_touch.js";
@@ -157,6 +158,7 @@ import {
157
158
  consume_long_press_click,
158
159
  } from "./g6_touch_gestures.js";
159
160
  import {yui_theme_now, yui_watch_theme} from "./yui_theme.js";
161
+ import {yui_shell_of} from "./c_yui_shell.js";
160
162
 
161
163
  /***************************************************************
162
164
  * YuiToolbar — G6 Toolbar subclass that adds per-item className
@@ -465,11 +467,14 @@ let PRIVATE_DATA = {
465
467
  _edge_icon_el: null, // floating properties icon element
466
468
  _edge_delete_el: null, // floating delete icon element for edge
467
469
  _edge_popover_el: null, // edge properties popover element
470
+ _edge_preview_undo: null, // ...and how to undo what it previewed
468
471
  _port_icon_el: null, // floating port properties icon element
469
472
  _port_popover_el: null, // port properties popover element
473
+ _port_preview_undo: null, // ...and how to undo what it previewed
470
474
  _node_icon_el: null, // floating node properties icon element
471
475
  _node_delete_el: null, // floating delete icon element for node
472
476
  _node_popover_el: null, // node properties popover element
477
+ _node_preview_undo: null, // ...and how to undo what it previewed
473
478
  _node_detail_el: null, // read-only detail popover (on click)
474
479
  _detail_node_id: null, // node id the detail popover is showing
475
480
  _delete_confirm_el: null, // delete confirmation popover
@@ -613,6 +618,18 @@ function mt_writing(gobj, path)
613
618
  ***************************************************************/
614
619
  function mt_start(gobj)
615
620
  {
621
+ /* A language change reaches the toolbars and the `+N` chips as
622
+ * the shell's EVENT, not as a raw i18next listener: what the
623
+ * toolbar draws and what a chip says are built here, out of
624
+ * reach of refresh_language(), so somebody has to repaint them
625
+ * -- and that somebody is an action of this machine, where the
626
+ * `machine` trace can see it. Same wiring as C_YUI_PERIOD and
627
+ * C_YUI_GOBJ_TREE_JS. */
628
+ let shell = yui_shell_of(gobj);
629
+ if(shell) {
630
+ gobj_subscribe_event(shell, "EV_LANGUAGE_CHANGED", {}, gobj);
631
+ }
632
+
616
633
  return 0;
617
634
  }
618
635
 
@@ -621,6 +638,11 @@ function mt_start(gobj)
621
638
  ***************************************************************/
622
639
  function mt_stop(gobj)
623
640
  {
641
+ let shell = yui_shell_of(gobj);
642
+ if(shell) {
643
+ gobj_unsubscribe_event(shell, "EV_LANGUAGE_CHANGED", {}, gobj);
644
+ }
645
+
624
646
  return 0;
625
647
  }
626
648
 
@@ -631,11 +653,6 @@ function mt_destroy(gobj)
631
653
  {
632
654
  let priv = gobj.priv;
633
655
 
634
- if(priv._on_language_changed) {
635
- i18next.off('languageChanged', priv._on_language_changed);
636
- priv._on_language_changed = null;
637
- }
638
-
639
656
  uninstall_fold_listener(gobj);
640
657
 
641
658
  if(priv._on_pointerdown_focus) {
@@ -1022,12 +1039,6 @@ function configure_events(gobj)
1022
1039
  }
1023
1040
  });
1024
1041
 
1025
- // Re-render G6 toolbars (and the `+N` chips' titles) when language changes
1026
- priv._on_language_changed = () => {
1027
- update_toolbar(gobj);
1028
- repaint_more_chips(gobj);
1029
- };
1030
- i18next.on('languageChanged', priv._on_language_changed);
1031
1042
 
1032
1043
  /* Clicking a CARD is clicking a DOM element inside the container,
1033
1044
  * and that takes the keyboard away from G6's canvas -- the only
@@ -1865,6 +1876,17 @@ const LR_LAYOUTS = ["dagre"];
1865
1876
  * toolbar's +/-, and the two-finger pinch of g6_touch_gestures.
1866
1877
  * `enable` on the scroll keeps the two apart: a Ctrl wheel is
1867
1878
  * the zoom's, so the scroll stands aside for it.
1879
+ *
1880
+ * And the two have to name the SAME key, or the gesture falls
1881
+ * between them: the scroll stood aside for Meta as well, while
1882
+ * the zoom listened for Control alone, so Cmd + wheel neither
1883
+ * scrolled nor zoomed. It is ONE key and not two because a G6
1884
+ * `trigger` is a CHORD, not a list of alternatives -- its
1885
+ * `Shortcut.match()` compares the held keys to the bound ones
1886
+ * as a SET, so ["Control", "Meta"] means both keys down at
1887
+ * once. Control it is, on every platform: it is what a
1888
+ * trackpad pinch arrives as, so a Mac keeps its pinch and
1889
+ * gains nothing to press by mistake.
1868
1890
  ************************************************************/
1869
1891
  function camera_behaviors()
1870
1892
  {
@@ -1872,7 +1894,7 @@ function camera_behaviors()
1872
1894
  {
1873
1895
  type: "scroll-canvas",
1874
1896
  key: "scroll-canvas",
1875
- enable: (event) => !(event && (event.ctrlKey || event.metaKey)),
1897
+ enable: (event) => !(event && event.ctrlKey),
1876
1898
  },
1877
1899
  {
1878
1900
  type: "zoom-canvas",
@@ -4046,6 +4068,42 @@ function hide_overlay(gobj, ...keys)
4046
4068
  /************************************************************
4047
4069
  * Enable the save button (mark graph as dirty).
4048
4070
  ************************************************************/
4071
+ /************************************************************
4072
+ * A popover that PREVIEWS its changes owes an undo.
4073
+ *
4074
+ * What the preview writes is not the reader's answer until
4075
+ * `apply` is pressed, and `cancel` used to be the only way
4076
+ * back: every other way out of the popover -- a click on the
4077
+ * canvas, the icon pressed again, the element going away --
4078
+ * left the preview standing on the element, and the next Save
4079
+ * wrote it to `__graphs__`. So the undo is kept beside the
4080
+ * popover and run by whoever hides it; `apply` drops it first,
4081
+ * because then the preview IS the answer.
4082
+ ************************************************************/
4083
+ function set_preview_undo(gobj, key, undo)
4084
+ {
4085
+ gobj.priv[key] = undo;
4086
+ }
4087
+
4088
+ function drop_preview_undo(gobj, key)
4089
+ {
4090
+ gobj.priv[key] = null;
4091
+ }
4092
+
4093
+ function run_preview_undo(gobj, key)
4094
+ {
4095
+ let undo = gobj.priv[key];
4096
+ gobj.priv[key] = null;
4097
+ if(!undo || gobj_is_destroying(gobj)) {
4098
+ return;
4099
+ }
4100
+ try {
4101
+ undo();
4102
+ } catch(e) {
4103
+ log_error(`${gobj_short_name(gobj)}: cannot undo the preview: ${e}`);
4104
+ }
4105
+ }
4106
+
4049
4107
  function mark_graph_dirty(gobj)
4050
4108
  {
4051
4109
  let $container = gobj_read_attr(gobj, "$container");
@@ -4990,6 +5048,34 @@ function port_hit_slop_world(graph)
4990
5048
  return slop / zoom;
4991
5049
  }
4992
5050
 
5051
+ /************************************************************
5052
+ * Whether a point landed on a port, by the port's SHAPE.
5053
+ *
5054
+ * A port was a circle and the test was a radius; since 7.23.81
5055
+ * it can be a square, a diamond or a triangle too, and a circle
5056
+ * around a diamond answers for the air off its corners while
5057
+ * missing the middle of its edges. `r` is the drawn radius with
5058
+ * the hit slop already added, and the shapes are the ones
5059
+ * TreedbCard.drawPortShapes() draws from it.
5060
+ *
5061
+ * dx, dy the point, relative to the port's centre
5062
+ ************************************************************/
5063
+ function port_shape_hit(shape, dx, dy, r)
5064
+ {
5065
+ switch(shape) {
5066
+ case 'square':
5067
+ return Math.abs(dx) <= r && Math.abs(dy) <= r;
5068
+ case 'diamond':
5069
+ return Math.abs(dx) + Math.abs(dy) <= r;
5070
+ case 'triangle':
5071
+ /* (0,-r), (r,r), (-r,r): under the base and inside the
5072
+ * two slanted sides, which meet at 2|x| - y = r. */
5073
+ return dy <= r && (2 * Math.abs(dx) - dy) <= r;
5074
+ default:
5075
+ return Math.hypot(dx, dy) <= r;
5076
+ }
5077
+ }
5078
+
4993
5079
  /************************************************************
4994
5080
  * Detect if a click in canvas coordinates hits a port.
4995
5081
  * Returns the port key string, or null if no port hit.
@@ -5021,8 +5107,9 @@ function detect_port_click(gobj, node_id, canvasX, canvasY)
5021
5107
  let dist = Math.sqrt(dx * dx + dy * dy);
5022
5108
 
5023
5109
  /* Nearest wins, so the enlarged areas of two ports
5024
- * overlapping is not a tie -- it is the closer one. */
5025
- if(dist <= r + slop && dist < best_dist) {
5110
+ * overlapping is not a tie -- it is the closer one. The
5111
+ * distance ranks them; the SHAPE says who is in the running. */
5112
+ if(port_shape_hit(ports[i].shape, dx, dy, r + slop) && dist < best_dist) {
5026
5113
  best_dist = dist;
5027
5114
  best_key = ports[i].key;
5028
5115
  }
@@ -5240,6 +5327,7 @@ function toggle_port_popover(gobj)
5240
5327
 
5241
5328
  function hide_port_popover(gobj)
5242
5329
  {
5330
+ run_preview_undo(gobj, '_port_preview_undo');
5243
5331
  hide_overlay(gobj, '_port_popover_el');
5244
5332
  }
5245
5333
 
@@ -5280,6 +5368,17 @@ function show_port_popover(gobj)
5280
5368
  xy.x + floating_popover_dx(), xy.y, 'g6-port-popover', '#d9d9d9', 180
5281
5369
  );
5282
5370
 
5371
+ /* What the preview writes on this port, undone: whoever hides
5372
+ * the popover without applying runs it (see set_preview_undo). */
5373
+ let undo_port = () => {
5374
+ graph.updateNodeData([{id: node_id, style: {
5375
+ ports: ports_with(nodeData.style.ports, port_key, {shape: orig_shape, r: orig_r}),
5376
+ }}]);
5377
+ graph.draw().then(() => {
5378
+ update_port_resize_handles_position(gobj);
5379
+ });
5380
+ };
5381
+
5283
5382
  /* Live preview on THIS port; the others wait for apply. */
5284
5383
  function preview_port() {
5285
5384
  let r = parseInt(rInput.value) || orig_r;
@@ -5313,13 +5412,7 @@ function show_port_popover(gobj)
5313
5412
  text: 'cancel',
5314
5413
  style: BTN_STYLE_CANCEL,
5315
5414
  onClick: () => {
5316
- graph.updateNodeData([{id: node_id, style: {
5317
- ports: ports_with(nodeData.style.ports, port_key, {shape: orig_shape, r: orig_r}),
5318
- }}]);
5319
- graph.draw().then(() => {
5320
- update_port_resize_handles_position(gobj);
5321
- });
5322
- hide_port_popover(gobj);
5415
+ hide_port_popover(gobj); /* which undoes the preview */
5323
5416
  },
5324
5417
  },
5325
5418
  {
@@ -5327,6 +5420,7 @@ function show_port_popover(gobj)
5327
5420
  style: 'flex:1;padding:6px;background:#1890ff;color:#fff;border:none;' +
5328
5421
  'border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;',
5329
5422
  onClick: () => {
5423
+ drop_preview_undo(gobj, '_port_preview_undo');
5330
5424
  apply_port_properties(gobj, node_id, port_key,
5331
5425
  shapeSelect.value, parseInt(rInput.value) || orig_r, scopeSelect.value
5332
5426
  );
@@ -5336,6 +5430,7 @@ function show_port_popover(gobj)
5336
5430
 
5337
5431
  priv.$container.appendChild(popover);
5338
5432
  priv._port_popover_el = popover;
5433
+ set_preview_undo(gobj, '_port_preview_undo', undo_port);
5339
5434
  clamp_popover_position(gobj, popover);
5340
5435
  }
5341
5436
 
@@ -6184,6 +6279,12 @@ function show_edge_popover(gobj)
6184
6279
  'g6-edge-popover', '#d9d9d9', 180
6185
6280
  );
6186
6281
 
6282
+ /* What the preview writes, undone (see set_preview_undo). */
6283
+ let undo_edge = () => {
6284
+ graph.updateEdgeData([{ id: edge_id, style: { lineWidth: origLW, stroke: origStroke } }]);
6285
+ graph.draw();
6286
+ };
6287
+
6187
6288
  // Live preview: update the selected edge in real time
6188
6289
  function preview_edge() {
6189
6290
  let lw = parseInt(lwInput.value) || 2;
@@ -6213,9 +6314,7 @@ function show_edge_popover(gobj)
6213
6314
  text: 'cancel',
6214
6315
  style: BTN_STYLE_CANCEL,
6215
6316
  onClick: () => {
6216
- graph.updateEdgeData([{ id: edge_id, style: { lineWidth: origLW, stroke: origStroke } }]);
6217
- graph.draw();
6218
- deselect_edge(gobj);
6317
+ deselect_edge(gobj); /* which undoes the preview */
6219
6318
  },
6220
6319
  },
6221
6320
  {
@@ -6223,6 +6322,7 @@ function show_edge_popover(gobj)
6223
6322
  style: 'flex:1;padding:6px;background:#52c41a;color:#fff;border:none;' +
6224
6323
  'border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;',
6225
6324
  onClick: () => {
6325
+ drop_preview_undo(gobj, '_edge_preview_undo');
6226
6326
  apply_edge_properties(gobj, edge_id,
6227
6327
  parseInt(lwInput.value) || 2,
6228
6328
  colorInput.value,
@@ -6234,6 +6334,7 @@ function show_edge_popover(gobj)
6234
6334
 
6235
6335
  priv.$container.appendChild(popover);
6236
6336
  priv._edge_popover_el = popover;
6337
+ set_preview_undo(gobj, '_edge_preview_undo', undo_edge);
6237
6338
 
6238
6339
  // Clamp popover inside the container
6239
6340
  clamp_popover_position(gobj, popover);
@@ -6278,6 +6379,7 @@ function clamp_popover_position(gobj, popover)
6278
6379
 
6279
6380
  function hide_edge_popover(gobj)
6280
6381
  {
6382
+ run_preview_undo(gobj, '_edge_preview_undo');
6281
6383
  hide_overlay(gobj, '_edge_popover_el');
6282
6384
  }
6283
6385
 
@@ -6404,6 +6506,22 @@ function show_node_popover(gobj)
6404
6506
  let node_graph_type = nodeData.data && nodeData.data.desc ?
6405
6507
  nodeData.data.desc.node_treedb_type : null;
6406
6508
 
6509
+ /* What the preview writes, undone (see set_preview_undo). */
6510
+ let undo_node = () => {
6511
+ let restoreStyle = { fill: origFill, stroke: origStroke, lineWidth: origLW };
6512
+ if(node_graph_type === 'hierarchical') {
6513
+ let record = nodeData.data.record || {};
6514
+ restoreStyle.innerHTML = build_node_innerHTML(
6515
+ origFill, priv.theme, record.icon,
6516
+ node_label(nodeData.data.desc, record),
6517
+ nodeData.data.desc.topic_name, false, record.id,
6518
+ false, false, false, pills_html_of(gobj, nodeData)
6519
+ );
6520
+ }
6521
+ graph.updateNodeData([{ id: node_id, style: restoreStyle }]);
6522
+ graph.draw();
6523
+ };
6524
+
6407
6525
  // Live preview
6408
6526
  function preview_node() {
6409
6527
  let fill = fillInput.value;
@@ -6460,19 +6578,7 @@ function show_node_popover(gobj)
6460
6578
  text: 'cancel',
6461
6579
  style: BTN_STYLE_CANCEL,
6462
6580
  onClick: () => {
6463
- let restoreStyle = { fill: origFill, stroke: origStroke, lineWidth: origLW };
6464
- if(node_graph_type === 'hierarchical') {
6465
- let record = nodeData.data.record || {};
6466
- restoreStyle.innerHTML = build_node_innerHTML(
6467
- origFill, priv.theme, record.icon,
6468
- node_label(nodeData.data.desc, record),
6469
- nodeData.data.desc.topic_name, false, record.id,
6470
- false, false, false, pills_html_of(gobj, nodeData)
6471
- );
6472
- }
6473
- graph.updateNodeData([{ id: node_id, style: restoreStyle }]);
6474
- graph.draw();
6475
- hide_node_popover(gobj);
6581
+ hide_node_popover(gobj); /* which undoes the preview */
6476
6582
  },
6477
6583
  },
6478
6584
  {
@@ -6480,6 +6586,7 @@ function show_node_popover(gobj)
6480
6586
  style: 'flex:1;padding:6px;background:#1890ff;color:#fff;border:none;' +
6481
6587
  'border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;',
6482
6588
  onClick: () => {
6589
+ drop_preview_undo(gobj, '_node_preview_undo');
6483
6590
  apply_node_properties(gobj, node_id,
6484
6591
  fillInput.value,
6485
6592
  strokeInput.value,
@@ -6493,6 +6600,7 @@ function show_node_popover(gobj)
6493
6600
 
6494
6601
  priv.$container.appendChild(popover);
6495
6602
  priv._node_popover_el = popover;
6603
+ set_preview_undo(gobj, '_node_preview_undo', undo_node);
6496
6604
 
6497
6605
  // Clamp popover inside the container
6498
6606
  clamp_popover_position(gobj, popover);
@@ -6500,6 +6608,7 @@ function show_node_popover(gobj)
6500
6608
 
6501
6609
  function hide_node_popover(gobj)
6502
6610
  {
6611
+ run_preview_undo(gobj, '_node_preview_undo');
6503
6612
  hide_overlay(gobj, '_node_popover_el');
6504
6613
  }
6505
6614
 
@@ -8980,6 +9089,19 @@ async function reconcile_fold_apply(gobj, opts, visible)
8980
9089
  return changed;
8981
9090
  }
8982
9091
 
9092
+ /************************************************************
9093
+ * The application changed language: what this gclass DRAWS has
9094
+ * to be drawn again. `refresh_language()` reaches what carries
9095
+ * an i18n key in the DOM; a G6 toolbar and the html of a chip
9096
+ * are built here and carry none.
9097
+ ************************************************************/
9098
+ function ac_language_changed(gobj, event, kw, src)
9099
+ {
9100
+ update_toolbar(gobj);
9101
+ repaint_more_chips(gobj);
9102
+ return 0;
9103
+ }
9104
+
8983
9105
  /************************************************************
8984
9106
  * Repaint the `+N` chips (theme or language changed).
8985
9107
  ************************************************************/
@@ -10527,6 +10649,7 @@ function create_gclass(gclass_name)
10527
10649
  ["EV_HIDE", ac_hide, null],
10528
10650
  ["EV_RESIZE", ac_resize, null],
10529
10651
  ["EV_THEME", ac_theme, null],
10652
+ ["EV_LANGUAGE_CHANGED", ac_language_changed, null],
10530
10653
  ]]
10531
10654
  ];
10532
10655
 
@@ -10621,6 +10744,7 @@ function create_gclass(gclass_name)
10621
10744
  ["EV_HIDE", 0],
10622
10745
  ["EV_RESIZE", 0],
10623
10746
  ["EV_THEME", 0],
10747
+ ["EV_LANGUAGE_CHANGED", 0],
10624
10748
  ];
10625
10749
 
10626
10750
  /*----------------------------------------*
@@ -173,6 +173,7 @@ let PRIVATE_DATA = {
173
173
  layout: null,
174
174
  _topics_subscribed: {},
175
175
  _legend_state: null, /* the engine's last EV_LEGEND_STATE */
176
+ _find_result: null, /* ...and its last EV_FIND_RESULT */
176
177
  _links_subscribed: false, /* EV_TREEDB_NODE_LINKED/UNLINKED (treedb-wide) */
177
178
 
178
179
  is_pinhold_window: false, // inherited of v6, todo review
@@ -303,6 +304,18 @@ function mt_start(gobj)
303
304
  {
304
305
  let priv = gobj.priv;
305
306
 
307
+ /* The legend is built from the engine's EV_LEGEND_STATE, which
308
+ * arrives after a reconcile and not when the language changes:
309
+ * a chip's title says an ACTION (show / hide this topic), and
310
+ * the strip would keep the old language until somebody folded
311
+ * something. The keys travel in `data-i18n-title` too, so this
312
+ * is the belt to that pair of braces -- and it is what repaints
313
+ * the counted line of the find box, which has no key at all. */
314
+ let shell = yui_shell_of(gobj);
315
+ if(shell) {
316
+ gobj_subscribe_event(shell, "EV_LANGUAGE_CHANGED", {}, gobj);
317
+ }
318
+
306
319
  if(priv.gobj_nodes_tree) {
307
320
  gobj_start(priv.gobj_nodes_tree);
308
321
  }
@@ -323,6 +336,7 @@ function mt_stop(gobj)
323
336
  let shell = yui_shell_of(gobj);
324
337
  if(shell) {
325
338
  yui_shell_set_sub_routes(shell, gobj_read_str_attr(gobj, "base_route"), null);
339
+ gobj_unsubscribe_event(shell, "EV_LANGUAGE_CHANGED", {}, gobj);
326
340
  }
327
341
  close_json_viewer(gobj);
328
342
  gobj_stop_children(gobj);
@@ -2523,6 +2537,8 @@ function refresh_legend(gobj)
2523
2537
  hidden? `${entry.total}` : `${entry.visible}/${entry.total}`]
2524
2538
  );
2525
2539
 
2540
+ let body_key = is_main? 'main topic' : (hidden? 'show topic' : 'hide topic');
2541
+
2526
2542
  /* Built as an object first: an attribute that is `undefined`
2527
2543
  * is still SET by createElement2 (to the string "undefined"),
2528
2544
  * and a `disabled="undefined"` button is a disabled button --
@@ -2533,7 +2549,14 @@ function refresh_legend(gobj)
2533
2549
  (is_main? ' GRAPH_LEGEND_MAIN' : ''),
2534
2550
  type: 'button',
2535
2551
  style: 'gap:0; flex:0 0 auto;' + (focused? ' ' + LEGEND_FOCUSED_STYLE : ''),
2536
- title: is_main? t('main topic') : (hidden? t('show topic') : t('hide topic')),
2552
+ /* The KEY as well as the text: the strip is redrawn only
2553
+ * when the engine speaks again (EV_LEGEND_STATE), and a
2554
+ * language change is not that -- refresh_language() reaches
2555
+ * what carries `data-i18n-title` and nothing else. The key
2556
+ * is picked here because the chip is rebuilt whenever the
2557
+ * state behind it changes. */
2558
+ title: t(body_key),
2559
+ 'data-i18n-title': body_key,
2537
2560
  'aria-label': topic_name,
2538
2561
  'aria-pressed': hidden? 'false' : 'true',
2539
2562
  };
@@ -2560,7 +2583,9 @@ function refresh_legend(gobj)
2560
2583
  extras.push(['button', {class: 'GRAPH_LEGEND_STAR button is-small pressed_state',
2561
2584
  type: 'button', style: 'padding:0 .4rem;',
2562
2585
  title: t('main topic'), 'data-i18n-title': 'main topic',
2563
- 'aria-label': t('main topic'), 'aria-pressed': 'true'},
2586
+ 'aria-label': t('main topic'),
2587
+ 'data-i18n-aria-label': 'main topic',
2588
+ 'aria-pressed': 'true'},
2564
2589
  [['span', {style: LEGEND_GLYPH_STYLE}, '★']], {
2565
2590
  click: (evt) => {
2566
2591
  evt.stopPropagation();
@@ -2577,7 +2602,8 @@ function refresh_legend(gobj)
2577
2602
  extras.push(['button', {class: 'GRAPH_LEGEND_STAR button is-small',
2578
2603
  type: 'button', style: 'padding:0 .4rem;',
2579
2604
  title: t('main topic'), 'data-i18n-title': 'main topic',
2580
- 'aria-label': t('main topic')},
2605
+ 'aria-label': t('main topic'),
2606
+ 'data-i18n-aria-label': 'main topic'},
2581
2607
  [['span', {style: LEGEND_GLYPH_STYLE}, '☆']], {
2582
2608
  click: (evt) => {
2583
2609
  evt.stopPropagation();
@@ -2597,6 +2623,7 @@ function refresh_legend(gobj)
2597
2623
  type: 'button', style: 'padding:0 .45rem;',
2598
2624
  title: t('loose records'), 'data-i18n-title': 'loose records',
2599
2625
  'aria-label': t('loose records'),
2626
+ 'data-i18n-aria-label': 'loose records',
2600
2627
  'aria-pressed': entry.loose_shown? 'true' : 'false'
2601
2628
  }, `+${entry.loose}`, {
2602
2629
  click: (evt) => {
@@ -2614,6 +2641,7 @@ function refresh_legend(gobj)
2614
2641
  type: 'button', style: 'padding:0 .4rem;',
2615
2642
  title: t('highlight topic'), 'data-i18n-title': 'highlight topic',
2616
2643
  'aria-label': t('highlight topic'),
2644
+ 'data-i18n-aria-label': 'highlight topic',
2617
2645
  'aria-pressed': focused? 'true' : 'false'
2618
2646
  }, [['i', {class: 'yi-location-crosshairs', style: LEGEND_GLYPH_STYLE}]], {
2619
2647
  click: (evt) => {
@@ -2633,6 +2661,20 @@ function refresh_legend(gobj)
2633
2661
  }
2634
2662
  }
2635
2663
 
2664
+ /************************************************************
2665
+ * The application changed language. `refresh_language()` has
2666
+ * already been over the document by now (the shell runs it
2667
+ * before publishing), so what is left is what carries no key:
2668
+ * the legend, whose chips say an ACTION that depends on the
2669
+ * state, and the find box's counted line.
2670
+ ************************************************************/
2671
+ function ac_language_changed(gobj, event, kw, src)
2672
+ {
2673
+ refresh_legend(gobj);
2674
+ refresh_find_result(gobj);
2675
+ return 0;
2676
+ }
2677
+
2636
2678
  /************************************************************
2637
2679
  * The engine said what is drawn: repaint the strip.
2638
2680
  ************************************************************/
@@ -2848,6 +2890,18 @@ function ac_toggle_node_labels(gobj, event, kw, src)
2848
2890
  ************************************************************/
2849
2891
  function ac_find_result(gobj, event, kw, src)
2850
2892
  {
2893
+ /* Kept, because the line is COMPOSED -- a counted word and a
2894
+ * count of hidden topics -- so no i18n key in the DOM can
2895
+ * re-translate it: its owner repaints it from the last answer
2896
+ * when the language changes. */
2897
+ gobj.priv._find_result = kw || null;
2898
+ refresh_find_result(gobj);
2899
+ return 0;
2900
+ }
2901
+
2902
+ function refresh_find_result(gobj)
2903
+ {
2904
+ let kw = gobj.priv._find_result;
2851
2905
  let $container = gobj_read_attr(gobj, "$container");
2852
2906
  if(!$container) {
2853
2907
  return 0;
@@ -3004,6 +3058,7 @@ function create_gclass(gclass_name)
3004
3058
  ["EV_SHOW", ac_show, null],
3005
3059
  ["EV_HIDE", ac_hide, null],
3006
3060
  ["EV_TRANSPORT_STATE", ac_transport_state, null],
3061
+ ["EV_LANGUAGE_CHANGED", ac_language_changed, null],
3007
3062
  ]]
3008
3063
  ];
3009
3064
 
@@ -3052,6 +3107,7 @@ function create_gclass(gclass_name)
3052
3107
  event_flag_t.EVF_OUTPUT_EVENT | event_flag_t.EVF_NO_WARN_SUBS],
3053
3108
  ["EV_SHOW", 0],
3054
3109
  ["EV_HIDE", 0],
3110
+ ["EV_LANGUAGE_CHANGED", 0],
3055
3111
  ["EV_TRANSPORT_STATE", 0],
3056
3112
  ];
3057
3113
 
@@ -51,8 +51,10 @@
51
51
  *
52
52
  * `radial`: the root in the middle and a ring per depth, each
53
53
  * subtree owning a sector proportional to its leaves; the radius
54
- * of a ring grows until its neighbours sit side by side, so
55
- * nothing overlaps by construction (see `layout_radial`).
54
+ * of a ring grows until its neighbours sit side by side AND
55
+ * until it clears the ring inside it, so nothing overlaps by
56
+ * construction, across the ring or along the radius (see
57
+ * `layout_radial`).
56
58
  *
57
59
  * Pure: takes plain nodes and edges, returns positions. The G6
58
60
  * classes in c_g6_nodes_tree.js are two thin adapters over it.
@@ -272,6 +274,18 @@ function tidy_tree_lr(nodes, edges, o)
272
274
  return pos;
273
275
  }
274
276
 
277
+ /* How much of a card lies along the direction `a`: the projection
278
+ * of its rectangle on that direction. Full width when the card is
279
+ * read along its width, full height across it, and the honest
280
+ * mixture in between. */
281
+ function extent_at(n, a)
282
+ {
283
+ if(!n) {
284
+ return 0;
285
+ }
286
+ return Math.abs(n.w * Math.cos(a)) + Math.abs(n.h * Math.sin(a));
287
+ }
288
+
275
289
  /************************************************************
276
290
  * The radial tree: the root in the middle, a ring per depth.
277
291
  *
@@ -333,10 +347,10 @@ export function layout_radial(nodes, edges, opts)
333
347
  /* An explicit stack, not recursion: the sectors of a ring are
334
348
  * sorted by angle in pass 2, so the order they are pushed in does
335
349
  * not matter -- only that every node gets its own. */
336
- let sector = (root_id, root_from, root_span) => {
337
- let stack = [{id: root_id, from: root_from, span: root_span}];
350
+ let sector = (root_id, root_from, root_span, root_parent) => {
351
+ let stack = [{id: root_id, from: root_from, span: root_span, parent: root_parent}];
338
352
  while(stack.length) {
339
- let {id, from, span} = stack.pop();
353
+ let {id, from, span, parent} = stack.pop();
340
354
  let a = from + span / 2;
341
355
  angle.set(id, a);
342
356
  let ring = ring_of(id);
@@ -344,11 +358,21 @@ export function layout_radial(nodes, edges, opts)
344
358
  rings[ring] = [];
345
359
  }
346
360
  let n = t.by_id.get(id);
347
- rings[ring].push({id: id, angle: a, diag: Math.hypot(n.w, n.h)});
361
+ rings[ring].push({
362
+ id: id,
363
+ angle: a,
364
+ diag: Math.hypot(n.w, n.h),
365
+ /* How much of the card lies along the RADIUS at this
366
+ * angle -- the projection of the rectangle on that
367
+ * direction -- for itself and for the node it hangs
368
+ * from, which is the one it has to clear. */
369
+ ext: extent_at(n, a),
370
+ parent_ext: parent? extent_at(t.by_id.get(parent), a) : 0,
371
+ });
348
372
  let cursor = from;
349
373
  for(let kid of (t.children.get(id) || [])) {
350
374
  let part = span * leaves.get(kid) / leaves.get(id);
351
- stack.push({id: kid, from: cursor, span: part});
375
+ stack.push({id: kid, from: cursor, span: part, parent: id});
352
376
  cursor += part;
353
377
  }
354
378
  }
@@ -359,28 +383,43 @@ export function layout_radial(nodes, edges, opts)
359
383
  let cursor = -Math.PI / 2; /* the first child at the top */
360
384
  for(let kid of (t.children.get(root) || [])) {
361
385
  let part = 2 * Math.PI * leaves.get(kid) / leaves.get(root);
362
- sector(kid, cursor, part);
386
+ sector(kid, cursor, part, root);
363
387
  cursor += part;
364
388
  }
365
389
  } else {
366
390
  let cursor = -Math.PI / 2;
367
391
  for(let root of t.roots) {
368
392
  let part = 2 * Math.PI * leaves.get(root) / total;
369
- sector(root, cursor, part);
393
+ sector(root, cursor, part, null);
370
394
  cursor += part;
371
395
  }
372
396
  }
373
397
 
374
- /* Pass 2, the RADII: a ring is one step further out than the
375
- * ring before, and as far out as its two closest neighbours need
376
- * to sit side by side -- the arc between two adjacent centres is
377
- * r·Δangle, and it has to hold half of each card plus the gap.
378
- * Measured on NEIGHBOURS, not on the ring's total length: a
379
- * leafless root squeezed between two big subtrees gets a sliver
380
- * of angle, and it is the sliver that sets the radius. */
398
+ /* Pass 2, the RADII. Two constraints, and the larger wins.
399
+ *
400
+ * ACROSS the ring: its two closest neighbours have to sit side
401
+ * by side -- the arc between two adjacent centres is r·Δangle,
402
+ * and it has to hold half of each card plus the gap. Measured on
403
+ * NEIGHBOURS, not on the ring's total length: a leafless root
404
+ * squeezed between two big subtrees gets a sliver of angle, and
405
+ * it is the sliver that sets the radius.
406
+ *
407
+ * ALONG the radius: `ranksep` is a step between CENTRES, so a
408
+ * ring one step out was one step out whatever the cards
409
+ * measured -- 180 between the centres of two cards that reach
410
+ * 95 each way is two cards touching. The tidy tree never had
411
+ * this: its `ranksep` is a gap between COLUMNS, and a column
412
+ * carries its own width. Here every card is measured against
413
+ * the one it HANGS FROM, in the direction it hangs in -- the
414
+ * two are radially aligned, or nearly, since a child lives
415
+ * inside its parent's sector -- and the ring goes out far
416
+ * enough for the worst of them. */
381
417
  let radius = [0];
382
418
  for(let ring = 1; ring < rings.length; ring++) {
383
419
  let r = radius[ring - 1] + o.ranksep;
420
+ for(let n of (rings[ring] || [])) {
421
+ r = Math.max(r, radius[ring - 1] + (n.parent_ext + n.ext) / 2 + o.nodesep);
422
+ }
384
423
  let list = (rings[ring] || []).slice().sort((a, b) => a.angle - b.angle);
385
424
  for(let i = 0; i < list.length && list.length > 1; i++) {
386
425
  let a = list[i];