@yuneta/gobj-ui 7.23.35 → 7.23.37

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.
@@ -0,0 +1,1015 @@
1
+ /***********************************************************************
2
+ * c_yui_fsm_graph.js
3
+ *
4
+ * A finite state machine, drawn.
5
+ *
6
+ * The second view of C_YUI_GCLASS's machine zone. The matrix is the
7
+ * first, and it stays the default: it is the shape the FSM is
8
+ * DECLARED in, it reads every gclass the same way, and it never lies
9
+ * about what is missing. A graph answers a different question --
10
+ * where does this machine GO -- and it answers it best exactly where
11
+ * the matrix is worst: many states, few events per state.
12
+ *
13
+ * G6, not a canvas of our own, and for one reason: what is expensive
14
+ * in a graph is not the drawing. It is the layout, the hit testing,
15
+ * the camera and the finger -- and all four are already paid for in
16
+ * this library (`yui_graph_camera.js`, `g6_touch_gestures.js`,
17
+ * `g6_drag_canvas_touch.js`), spoken with one vocabulary by the other
18
+ * three graphs. A fourth vocabulary is what a hand-rolled canvas
19
+ * would cost, and it would go on costing it.
20
+ *
21
+ * WHAT IT DRAWS, and what it refuses to draw:
22
+ *
23
+ * - one card per STATE, with how many events it handles and how
24
+ * many of them leave.
25
+ * - one edge per PAIR of states, labelled with the events that
26
+ * make the jump. Several transitions between the same two
27
+ * states are ONE edge with several labels: G6 draws overlapping
28
+ * edges on top of each other, so a second one is invisible and
29
+ * the reader has no way to know it is there.
30
+ * - an event a state handles WITHOUT leaving is not an edge. Ten
31
+ * self-loops on one card say nothing a number cannot say
32
+ * better, and they bury the transitions that do move.
33
+ * - a state nothing declares a way INTO is marked, never drawn as
34
+ * unreachable: an action may jump with `gobj_change_state()`
35
+ * (C_IEVENT_CLI enters ST_SESSION that way) and no description
36
+ * can see inside an action.
37
+ *
38
+ * It publishes NOTHING. A child that publishes an event forces a
39
+ * declaration into every host's FSM, and the machine of a gclass has
40
+ * nothing to say back to the viewer around it.
41
+ *
42
+ * Copyright (c) 2026, ArtGins.
43
+ * All Rights Reserved.
44
+ ***********************************************************************/
45
+ import {
46
+ SDATA,
47
+ SDATA_END,
48
+ data_type_t,
49
+ gclass_create,
50
+ gclass_find_by_name,
51
+ log_error,
52
+ gobj_read_pointer_attr,
53
+ gobj_parent,
54
+ gobj_subscribe_event,
55
+ gobj_read_attr,
56
+ gobj_write_attr,
57
+ gobj_read_str_attr,
58
+ gobj_send_event,
59
+ gobj_short_name,
60
+ gobj_name,
61
+ clean_name,
62
+ createElement2,
63
+ refresh_language,
64
+ } from "@yuneta/gobj-js";
65
+
66
+ import {yui_toolbar} from "./yui_toolbar.js";
67
+ import {
68
+ yui_graph_camera_items,
69
+ yui_graph_update_zoom,
70
+ } from "./yui_graph_camera.js";
71
+ import {yui_is_dark, yui_theme_now, yui_watch_theme} from "./yui_theme.js";
72
+ import {ensure_drag_canvas_patch} from "./g6_drag_canvas_touch.js";
73
+ import {ensure_pinch_zoom_patch} from "./g6_touch_gestures.js";
74
+
75
+ import {t} from "i18next";
76
+
77
+ import {Graph} from "@antv/g6";
78
+
79
+ import "./c_yui_fsm_graph.css";
80
+
81
+
82
+ /***************************************************************
83
+ * Constants
84
+ ***************************************************************/
85
+ const GCLASS_NAME = "C_YUI_FSM_GRAPH";
86
+
87
+ /*
88
+ * Left-to-right is the house direction for a topology, and a machine
89
+ * reads as one: it starts somewhere and it goes somewhere. Top-down
90
+ * is offered for the machines that are deep rather than wide.
91
+ */
92
+ const LAYOUTS = {
93
+ "dagre-lr": {
94
+ g6_layout: {type: 'antv-dagre', rankdir: 'LR', nodesep: 28, ranksep: 70},
95
+ },
96
+ "dagre-tb": {
97
+ g6_layout: {type: 'antv-dagre', rankdir: 'TB', nodesep: 28, ranksep: 60},
98
+ },
99
+ };
100
+
101
+ const DEFAULT_LAYOUT = "dagre-lr";
102
+
103
+ /*
104
+ * How many event names an edge label prints before it gives up and
105
+ * counts. Three fit beside an edge at the zoom a reader starts at,
106
+ * and the matrix has the full list either way.
107
+ */
108
+ const MAX_EDGE_LABELS = 3;
109
+
110
+ const CARD_W = 210;
111
+ const CARD_H = 46;
112
+
113
+ const BEHAVIORS = ['zoom-canvas', 'drag-canvas', 'drag-element'];
114
+
115
+ /*
116
+ * Below this a card carries no readable text at all. A six-state
117
+ * machine fits a panel at 38%, which is the whole graph and worth
118
+ * nothing; stopping here and centring shows a part that can be read,
119
+ * and the reader pans for the rest. The same floor `C_G6_NODES_TREE`
120
+ * uses, for the same reason.
121
+ */
122
+ const MIN_READABLE_ZOOM = 0.5;
123
+
124
+ /***************************************************************
125
+ * Data
126
+ ***************************************************************/
127
+ const attrs_table = [
128
+ /*---------------- Public Attributes ----------------*/
129
+ SDATA(data_type_t.DTP_POINTER, "subscriber", 0, null, "Subscriber of output events"),
130
+
131
+ /*---------------- Data ----------------*/
132
+ SDATA(data_type_t.DTP_JSON, "fsm", 0, null, "The `fsm` block of a gclass view model: {entry_state, states, rows, published, unreachable}"),
133
+ SDATA(data_type_t.DTP_STRING, "current_state",0, "", "State of the instance the host is showing, lit in the graph"),
134
+
135
+ /*---------------- UI ----------------*/
136
+ SDATA(data_type_t.DTP_POINTER, "$container", 0, null, "HTMLElement root, mounted by the parent"),
137
+ SDATA(data_type_t.DTP_STRING, "canvas_id", 0, "", "Canvas id"),
138
+ SDATA(data_type_t.DTP_STRING, "wide", 0, "36px", "Height of the toolbar buttons"),
139
+ SDATA(data_type_t.DTP_STRING, "layout", 0, DEFAULT_LAYOUT, "Layout key (see LAYOUTS)"),
140
+ SDATA_END()
141
+ ];
142
+
143
+ let PRIVATE_DATA = {
144
+ graph: null,
145
+ theme: null,
146
+ theme_observer: null, // MutationObserver on <html data-theme>
147
+ resize_observer: null, // ResizeObserver on the canvas mount
148
+ _resize_raf: 0, // rAF id debouncing resize -> EV_RESIZE
149
+ $layout_select: null, // the direction select
150
+ };
151
+
152
+ let __gclass__ = null;
153
+
154
+
155
+
156
+
157
+ /******************************
158
+ * Framework Methods
159
+ ******************************/
160
+
161
+
162
+
163
+
164
+ /***************************************************************
165
+ * Framework Method: Create
166
+ ***************************************************************/
167
+ function mt_create(gobj)
168
+ {
169
+ let priv = gobj.priv;
170
+
171
+ if(!gobj_read_str_attr(gobj, "canvas_id")) {
172
+ gobj_write_attr(gobj, "canvas_id",
173
+ "fsm_graph_" + clean_name(gobj_name(gobj)));
174
+ }
175
+
176
+ priv.theme = yui_theme_now();
177
+
178
+ build_ui(gobj);
179
+
180
+ /*
181
+ * CHILD subscription model
182
+ */
183
+ let subscriber = gobj_read_pointer_attr(gobj, "subscriber");
184
+ if(!subscriber) {
185
+ subscriber = gobj_parent(gobj);
186
+ }
187
+ gobj_subscribe_event(gobj, null, {}, subscriber);
188
+ }
189
+
190
+ /***************************************************************
191
+ * Framework Method: Start
192
+ ***************************************************************/
193
+ function mt_start(gobj)
194
+ {
195
+ /* The mount has to be IN the document by now: G6 sizes itself
196
+ * from its container, and one behind `is-hidden` measures 0x0. */
197
+ build_graph(gobj);
198
+ load_fsm(gobj);
199
+
200
+ gobj.priv.theme_observer = yui_watch_theme(function() {
201
+ gobj_send_event(gobj, "EV_THEME_CHANGED", {}, gobj);
202
+ });
203
+ }
204
+
205
+ /***************************************************************
206
+ * Framework Method: Stop
207
+ ***************************************************************/
208
+ function mt_stop(gobj)
209
+ {
210
+ let priv = gobj.priv;
211
+
212
+ if(priv.theme_observer) {
213
+ try {
214
+ priv.theme_observer.disconnect();
215
+ } catch(e) {
216
+ /* it went with its document */
217
+ }
218
+ priv.theme_observer = null;
219
+ }
220
+ if(priv.resize_observer) {
221
+ try {
222
+ priv.resize_observer.disconnect();
223
+ } catch(e) {
224
+ /* it went with its element */
225
+ }
226
+ priv.resize_observer = null;
227
+ }
228
+ if(priv._resize_raf) {
229
+ cancelAnimationFrame(priv._resize_raf);
230
+ priv._resize_raf = 0;
231
+ }
232
+ if(priv.graph) {
233
+ try {
234
+ priv.graph.destroy();
235
+ } catch(e) {
236
+ log_error(`${gobj_short_name(gobj)}: the graph refused to die: ${e}`);
237
+ }
238
+ priv.graph = null;
239
+ }
240
+ }
241
+
242
+ /***************************************************************
243
+ * Framework Method: Destroy
244
+ ***************************************************************/
245
+ function mt_destroy(gobj)
246
+ {
247
+ destroy_ui(gobj);
248
+ }
249
+
250
+
251
+
252
+
253
+ /***************************
254
+ * Local Methods
255
+ ***************************/
256
+
257
+
258
+
259
+
260
+ /************************************************************
261
+ * Build UI
262
+ ************************************************************/
263
+ function build_ui(gobj)
264
+ {
265
+ let priv = gobj.priv;
266
+ let canvas_id = gobj_read_str_attr(gobj, "canvas_id");
267
+
268
+ let $container = createElement2(
269
+ ['div', {class: 'C_YUI_FSM_GRAPH FSM_GRAPH',
270
+ style: 'height:100%; display:flex; flex-direction:column;'}, [
271
+ ['div', {class: 'FSM_GRAPH_TOOLBAR is-flex-grow-0'}, [make_toolbar(gobj)]],
272
+ ['div', {class: 'is-flex-grow-1',
273
+ style: 'flex:1 1 auto; min-height:0; overflow:hidden;'}, [
274
+ ['div', {id: canvas_id, class: 'FSM_GRAPH_CANVAS',
275
+ style: 'height:100%; min-height:0;'}, []]
276
+ ]]
277
+ ]]
278
+ );
279
+
280
+ gobj_write_attr(gobj, "$container", $container);
281
+ priv.$layout_select = $container.querySelector('.FSM_GRAPH_LAYOUT');
282
+
283
+ refresh_language($container, t);
284
+ }
285
+
286
+ /************************************************************
287
+ * Destroy UI
288
+ ************************************************************/
289
+ function destroy_ui(gobj)
290
+ {
291
+ let priv = gobj.priv;
292
+ let $container = gobj_read_attr(gobj, "$container");
293
+
294
+ if($container) {
295
+ if($container.parentNode) {
296
+ $container.parentNode.removeChild($container);
297
+ }
298
+ gobj_write_attr(gobj, "$container", null);
299
+ }
300
+ priv.$layout_select = null;
301
+ }
302
+
303
+ /************************************************************
304
+ * Toolbar: the direction, and the camera every other graph
305
+ * in this library speaks with.
306
+ ************************************************************/
307
+ function make_toolbar(gobj)
308
+ {
309
+ let priv = gobj.priv;
310
+ let wide = gobj_read_str_attr(gobj, "wide");
311
+ let layout = gobj_read_str_attr(gobj, "layout");
312
+
313
+ /* Each option's label goes through t() with the key SPELLED OUT:
314
+ * a key reached through a variable is invisible to the apps'
315
+ * locale validator, and i18next answers an unknown key with the
316
+ * key itself -- which renders in lower-case English and never
317
+ * changes language. */
318
+ let $select = createElement2(
319
+ ['select', {class: 'FSM_GRAPH_LAYOUT', style: {height: wide},
320
+ title: t("layout"), 'data-i18n-title': 'layout',
321
+ 'aria-label': t("layout"), 'data-i18n-aria-label': 'layout'}, [
322
+ ['option', {value: 'dagre-lr', i18n: 'dagre left-right'},
323
+ t("dagre left-right")],
324
+ ['option', {value: 'dagre-tb', i18n: 'dagre top-down'},
325
+ t("dagre top-down")]
326
+ ], {
327
+ change: function(evt) {
328
+ gobj_send_event(gobj, "EV_CHANGE_LAYOUT",
329
+ {layout: evt.target.value}, gobj);
330
+ }
331
+ }]);
332
+
333
+ /* The value is set on the ELEMENT, never with a `selected`
334
+ * attribute: translating an option means writing its text, and a
335
+ * rebuilt option list would come back pointing at the first one. */
336
+ $select.value = LAYOUTS[layout]? layout: DEFAULT_LAYOUT;
337
+
338
+ const $toolbar = yui_toolbar({}, [
339
+ ['div', {class: 'yui-horizontal-toolbar-section left is-flex is-align-items-center',
340
+ style: 'gap:.25rem;'}, [
341
+ ['div', {class: 'select', style: {height: wide}}, [$select]]
342
+ ]],
343
+ ['div', {class: 'yui-horizontal-toolbar-section center'}, []],
344
+ ['div', {class: 'yui-horizontal-toolbar-section right is-flex is-align-items-center',
345
+ style: 'gap:.25rem;'},
346
+ yui_graph_camera_items(gobj, priv.graph, wide)],
347
+ ]);
348
+
349
+ refresh_language($toolbar, t);
350
+ return $toolbar;
351
+ }
352
+
353
+ /************************************************************
354
+ * The canvas mount element, or null.
355
+ ************************************************************/
356
+ function canvas_mount(gobj)
357
+ {
358
+ let $container = gobj_read_attr(gobj, "$container");
359
+ if(!$container) {
360
+ return null;
361
+ }
362
+ return $container.querySelector("#" + gobj_read_str_attr(gobj, "canvas_id"));
363
+ }
364
+
365
+ /************************************************************
366
+ * Build the G6 graph instance.
367
+ ************************************************************/
368
+ function build_graph(gobj)
369
+ {
370
+ let priv = gobj.priv;
371
+
372
+ if(priv.graph) {
373
+ return;
374
+ }
375
+ if(!canvas_mount(gobj)) {
376
+ log_error(`${gobj_short_name(gobj)}: no canvas mount, no graph`);
377
+ return;
378
+ }
379
+
380
+ /* Both patches are what make a graph work under a FINGER: G6
381
+ * re-issues pointer ids mid-gesture, and its drag-canvas needs
382
+ * the touch fix the other three graphs already use. */
383
+ ensure_drag_canvas_patch();
384
+ ensure_pinch_zoom_patch();
385
+
386
+ let cfg = LAYOUTS[gobj_read_str_attr(gobj, "layout")] || LAYOUTS[DEFAULT_LAYOUT];
387
+
388
+ priv.graph = new Graph({
389
+ container: gobj_read_str_attr(gobj, "canvas_id"),
390
+ animation: false,
391
+ autoResize: false,
392
+ zoomRange: [0.2, 3],
393
+ /* Room for the outermost cards: a fit that ends exactly on
394
+ * the bounds leaves the first and last state cut in half by
395
+ * the edge of the box. */
396
+ padding: 24,
397
+ edge: {
398
+ /*
399
+ * A FUNCTION, not a name: a `type` written on the edge
400
+ * datum is overridden by the graph-level default, so a
401
+ * reciprocal pair went on drawing as two straight
402
+ * segments on top of each other. Asking the datum here is
403
+ * what G6 documents for varying the type per element.
404
+ */
405
+ type: (d) => (d && d.data && d.data.reciprocal)?
406
+ 'quadratic': 'polyline',
407
+ style: {
408
+ stroke: edge_color(),
409
+ lineWidth: 1.2,
410
+ endArrow: true,
411
+ labelFontSize: 10,
412
+ labelFill: edge_label_color(),
413
+ labelBackground: true,
414
+ labelBackgroundFill: card_bg(),
415
+ labelBackgroundOpacity: 1,
416
+ labelPadding: [1, 3],
417
+ },
418
+ },
419
+ layout: cfg.g6_layout,
420
+ behaviors: BEHAVIORS.slice(),
421
+ });
422
+
423
+ if(priv.theme) {
424
+ try {
425
+ priv.graph.setTheme(priv.theme);
426
+ } catch(e) {
427
+ log_error(`${gobj_short_name(gobj)}: the theme was refused: ${e}`);
428
+ }
429
+ }
430
+
431
+ priv.graph.on('aftertransform', () => {
432
+ yui_graph_update_zoom(gobj_read_attr(gobj, "$container"), priv.graph);
433
+ });
434
+
435
+ /*
436
+ * G6 v5's autoResize only listens to the window: a graph inside a
437
+ * panel, a tab, or a window somebody drags never hears about it,
438
+ * and a canvas whose configured size drifts from its on-screen one
439
+ * pans by the wrong amount under a finger.
440
+ */
441
+ if(typeof ResizeObserver !== "undefined") {
442
+ let ro = new ResizeObserver(() => {
443
+ if(priv._resize_raf) {
444
+ cancelAnimationFrame(priv._resize_raf);
445
+ }
446
+ priv._resize_raf = requestAnimationFrame(() => {
447
+ priv._resize_raf = 0;
448
+ if(priv.graph) {
449
+ gobj_send_event(gobj, "EV_RESIZE", {}, gobj);
450
+ }
451
+ });
452
+ });
453
+ ro.observe(canvas_mount(gobj));
454
+ priv.resize_observer = ro;
455
+ }
456
+ }
457
+
458
+ /************************************************************
459
+ * Colours for what is drawn on the CANVAS.
460
+ *
461
+ * The cards are HTML and take theirs from the stylesheet;
462
+ * an edge cannot, so its three colours are read here and
463
+ * rewritten when the page changes theme.
464
+ ************************************************************/
465
+ function edge_color()
466
+ {
467
+ return yui_is_dark()? "#6d7690": "#a9b0c0";
468
+ }
469
+
470
+ function edge_label_color()
471
+ {
472
+ return yui_is_dark()? "#b3bacb": "#5d6474";
473
+ }
474
+
475
+ function card_bg()
476
+ {
477
+ return yui_is_dark()? "#171A21": "#FBFBFD";
478
+ }
479
+
480
+ /************************************************************
481
+ * HTML escaping. A card is built as an innerHTML string --
482
+ * that is what a G6 html node takes -- and a state name
483
+ * comes from a description some backend answered.
484
+ ************************************************************/
485
+ function esc(text)
486
+ {
487
+ return String((text === null || text === undefined)? "": text)
488
+ .replace(/&/g, "&amp;")
489
+ .replace(/</g, "&lt;")
490
+ .replace(/>/g, "&gt;")
491
+ .replace(/"/g, "&quot;");
492
+ }
493
+
494
+ /************************************************************
495
+ * How many of a state's events move it somewhere else.
496
+ ************************************************************/
497
+ function count_leaving(fsm, state_name)
498
+ {
499
+ let i = fsm.states.findIndex((s) => s.name === state_name);
500
+ if(i < 0) {
501
+ return 0;
502
+ }
503
+
504
+ let n = 0;
505
+ for(let row of fsm.rows) {
506
+ let cell = row.cells[i];
507
+ if(cell && cell.next_state) {
508
+ n++;
509
+ }
510
+ }
511
+ return n;
512
+ }
513
+
514
+ /************************************************************
515
+ * One state, as a card.
516
+ *
517
+ * Three things it can be at once -- where the machine
518
+ * starts, where this instance IS, and a state nothing
519
+ * declares a way into -- so they are classes, not a type.
520
+ ************************************************************/
521
+ function state_html(state, fsm, current, unreachable)
522
+ {
523
+ let classes = ["FSM_CARD"];
524
+ let marks = "";
525
+
526
+ if(state.name === current) {
527
+ classes.push("is-current");
528
+ }
529
+ if(state.name === fsm.entry_state) {
530
+ classes.push("is-entry");
531
+ marks += `<span class="FSM_CARD_MARK is-entry" ` +
532
+ `title="${esc(t("entry state"))}">&#9654;</span>`;
533
+ }
534
+ if(unreachable.has(state.name)) {
535
+ classes.push("is-unreachable");
536
+ marks += `<span class="FSM_CARD_MARK is-warn" ` +
537
+ `title="${esc(t("state with no declared entry"))}">&#9888;</span>`;
538
+ }
539
+
540
+ /* How many of the events it handles LEAVE. The rest are the ones
541
+ * the matrix shows and this graph deliberately does not draw, so
542
+ * the number is the only place they are counted. */
543
+ let leaving = count_leaving(fsm, state.name);
544
+ let sub = `${state.count} ${esc(t("events"))}`;
545
+ if(leaving) {
546
+ sub += ` &middot; ${leaving} ${esc(t("leave"))}`;
547
+ }
548
+
549
+ return `<div class="${classes.join(" ")}">` +
550
+ `<div class="FSM_CARD_HEAD">` +
551
+ `<span class="FSM_CARD_NAME">${esc(state.name)}</span>${marks}` +
552
+ `</div>` +
553
+ `<div class="FSM_CARD_SUB">${sub}</div>` +
554
+ `</div>`;
555
+ }
556
+
557
+ /************************************************************
558
+ * The graph data: one node per state, one edge per PAIR.
559
+ ************************************************************/
560
+ function build_data(gobj)
561
+ {
562
+ let fsm = gobj_read_attr(gobj, "fsm");
563
+
564
+ if(!fsm || !Array.isArray(fsm.states) || !Array.isArray(fsm.rows)) {
565
+ return {nodes: [], edges: []};
566
+ }
567
+
568
+ let current = gobj_read_str_attr(gobj, "current_state");
569
+ let unreachable = new Set(Array.isArray(fsm.unreachable)? fsm.unreachable: []);
570
+
571
+ let nodes = fsm.states.map(function(state) {
572
+ return {
573
+ id: state.name,
574
+ type: 'html',
575
+ data: {state: state.name},
576
+ style: {
577
+ innerHTML: state_html(state, fsm, current, unreachable),
578
+ size: [CARD_W, CARD_H],
579
+ dx: -(CARD_W / 2),
580
+ dy: -(CARD_H / 2),
581
+ },
582
+ };
583
+ });
584
+
585
+ /*
586
+ * One edge per PAIR of states, not per transition: G6 draws two
587
+ * edges with the same ends on top of each other, so the second is
588
+ * invisible -- and nothing would tell a reader that an edge
589
+ * carrying one event is not an edge carrying four.
590
+ */
591
+ let by_pair = new Map();
592
+ fsm.states.forEach(function(state, i) {
593
+ for(let row of fsm.rows) {
594
+ let cell = row.cells[i];
595
+ if(!cell || !cell.next_state) {
596
+ continue;
597
+ }
598
+ if(cell.next_state === state.name) {
599
+ continue; /* a jump to itself is not a move */
600
+ }
601
+ let key = state.name + " " + cell.next_state;
602
+ if(!by_pair.has(key)) {
603
+ by_pair.set(key, {
604
+ source: state.name,
605
+ target: cell.next_state,
606
+ events: [],
607
+ });
608
+ }
609
+ by_pair.get(key).events.push(row.event);
610
+ }
611
+ });
612
+
613
+ let edges = [];
614
+ for(let pair of by_pair.values()) {
615
+ let shown = pair.events.slice(0, MAX_EDGE_LABELS);
616
+ let label = shown.join("\n");
617
+ if(pair.events.length > shown.length) {
618
+ label += "\n+" + (pair.events.length - shown.length);
619
+ }
620
+
621
+ let style = {labelText: label};
622
+
623
+ /*
624
+ * A pair that goes BOTH ways is drawn as two arcs, one bowing
625
+ * each way.
626
+ *
627
+ * Grouping by pair fixed the transitions that share an ordered
628
+ * pair; a RECIPROCAL pair is a different key and lands on the
629
+ * same straight segment -- so the two arrows overlap and the
630
+ * second label is painted under the first. Real case:
631
+ * C_WEBSOCKET goes to ST_WAIT_HANDSHAKE on EV_CONNECTED and
632
+ * comes back on EV_DISCONNECTED, and only the return was
633
+ * readable.
634
+ */
635
+ let reciprocal = by_pair.has(pair.target + " " + pair.source);
636
+ if(reciprocal) {
637
+ /* Bowed opposite ways so the two arrows separate -- and
638
+ * the two LABELS moved off the midpoint as well, one to
639
+ * each third of its own curve. Bowing alone was not
640
+ * enough: both labels are placed at the middle by default,
641
+ * they carry an opaque background, and the one drawn last
642
+ * simply covered the other. */
643
+ /* The SAME offset on both, not opposite ones. G6 measures
644
+ * `curveOffset` from the edge's OWN direction, and a
645
+ * reciprocal pair runs in opposite directions -- so equal
646
+ * signs bow them to opposite sides of the screen, and
647
+ * opposite signs put them on the same side, one on top of
648
+ * the other.
649
+ *
650
+ * The label stays at the MIDDLE of its arc, where the two
651
+ * are furthest apart. Moving it along the curve instead
652
+ * (0.3 / 0.7) pushed it under a state card: G6's html
653
+ * nodes are DOM elements ABOVE the canvas, so whatever the
654
+ * curve draws under one is invisible. */
655
+ style.curveOffset = 34;
656
+ style.labelPlacement = 0.5;
657
+ }
658
+
659
+ edges.push({
660
+ id: "e_" + edges.length,
661
+ source: pair.source,
662
+ target: pair.target,
663
+ data: {reciprocal: reciprocal},
664
+ style: style,
665
+ });
666
+ }
667
+
668
+ return {nodes: nodes, edges: edges};
669
+ }
670
+
671
+ /************************************************************
672
+ * Fit the graph, but never past legibility.
673
+ *
674
+ * `fitCenter()` before `fitView()` is the house pair: fitView
675
+ * alone leaves an html-node graph off centre, because the
676
+ * cards are DOM elements over the canvas and the bounds it
677
+ * measures are not where the reader is looking.
678
+ ************************************************************/
679
+ async function fit_readable(graph)
680
+ {
681
+ await graph.fitCenter();
682
+ await graph.fitView();
683
+
684
+ if(graph.getZoom() < MIN_READABLE_ZOOM) {
685
+ await graph.zoomTo(MIN_READABLE_ZOOM);
686
+ await graph.fitCenter();
687
+ }
688
+ }
689
+
690
+ /************************************************************
691
+ * Feed the graph and fit it.
692
+ ************************************************************/
693
+ function load_fsm(gobj)
694
+ {
695
+ let priv = gobj.priv;
696
+
697
+ if(!priv.graph) {
698
+ return;
699
+ }
700
+
701
+ let data = build_data(gobj);
702
+ if(!data.nodes.length) {
703
+ return; /* nothing to draw; the zone said so already */
704
+ }
705
+
706
+ try {
707
+ priv.graph.setData(data);
708
+ priv.graph.render().then(function() {
709
+ return fit_readable(priv.graph);
710
+ }).then(function() {
711
+ yui_graph_update_zoom(gobj_read_attr(gobj, "$container"), priv.graph);
712
+ }).catch(function(e) {
713
+ log_error(`${gobj_short_name(gobj)}: the graph did not render: ${e}`);
714
+ });
715
+ } catch(e) {
716
+ log_error(`${gobj_short_name(gobj)}: cannot load the machine: ${e}`);
717
+ }
718
+ }
719
+
720
+ /************************************************************
721
+ * A camera move, with one place to report a refusal.
722
+ ************************************************************/
723
+ function camera(gobj, what, fn)
724
+ {
725
+ let priv = gobj.priv;
726
+
727
+ if(!priv.graph) {
728
+ return 0;
729
+ }
730
+ try {
731
+ fn(priv.graph);
732
+ } catch(e) {
733
+ log_error(`${gobj_short_name(gobj)}: cannot ${what}: ${e}`);
734
+ return -1;
735
+ }
736
+ return 0;
737
+ }
738
+
739
+
740
+
741
+
742
+ /***************************
743
+ * Actions
744
+ ***************************/
745
+
746
+
747
+
748
+
749
+ /************************************************************
750
+ * EV_SET_FSM {fsm, current_state}
751
+ ************************************************************/
752
+ function ac_set_fsm(gobj, event, kw, src)
753
+ {
754
+ if(kw.fsm === undefined) {
755
+ log_error(`${GCLASS_NAME}: EV_SET_FSM without an fsm`);
756
+ return -1;
757
+ }
758
+
759
+ gobj_write_attr(gobj, "fsm", kw.fsm);
760
+ if(typeof kw.current_state === "string") {
761
+ gobj_write_attr(gobj, "current_state", kw.current_state);
762
+ }
763
+ load_fsm(gobj);
764
+ return 0;
765
+ }
766
+
767
+ /************************************************************
768
+ * EV_CHANGE_LAYOUT {layout}
769
+ ************************************************************/
770
+ function ac_change_layout(gobj, event, kw, src)
771
+ {
772
+ let priv = gobj.priv;
773
+ let key = kw.layout;
774
+
775
+ if(!LAYOUTS[key]) {
776
+ log_error(`${GCLASS_NAME}: unknown layout '${key}'`);
777
+ return -1;
778
+ }
779
+
780
+ gobj_write_attr(gobj, "layout", key);
781
+ if(!priv.graph) {
782
+ return 0;
783
+ }
784
+
785
+ try {
786
+ priv.graph.setLayout(LAYOUTS[key].g6_layout);
787
+ priv.graph.layout().then(function() {
788
+ return fit_readable(priv.graph);
789
+ }).catch(function(e) {
790
+ log_error(`${gobj_short_name(gobj)}: the layout failed: ${e}`);
791
+ });
792
+ } catch(e) {
793
+ log_error(`${gobj_short_name(gobj)}: cannot change the layout: ${e}`);
794
+ }
795
+ return 0;
796
+ }
797
+
798
+ /************************************************************
799
+ * EV_RESIZE -- the container changed size under us.
800
+ ************************************************************/
801
+ function ac_resize(gobj, event, kw, src)
802
+ {
803
+ let priv = gobj.priv;
804
+ let $canvas = canvas_mount(gobj);
805
+
806
+ if(!priv.graph || !$canvas) {
807
+ return 0;
808
+ }
809
+ if(!$canvas.offsetWidth || !$canvas.offsetHeight) {
810
+ return 0; /* hidden: measuring now would store a 0x0 canvas */
811
+ }
812
+
813
+ return camera(gobj, "resize the graph", function(graph) {
814
+ graph.setSize($canvas.offsetWidth, $canvas.offsetHeight);
815
+ });
816
+ }
817
+
818
+ /************************************************************
819
+ * EV_THEME_CHANGED -- the page went dark, or light.
820
+ *
821
+ * The cards are HTML and follow the stylesheet on their own;
822
+ * the EDGES are canvas and keep the colours they were built
823
+ * with, so those three are rewritten.
824
+ ************************************************************/
825
+ function ac_theme_changed(gobj, event, kw, src)
826
+ {
827
+ let priv = gobj.priv;
828
+
829
+ priv.theme = yui_theme_now();
830
+ if(!priv.graph) {
831
+ return 0;
832
+ }
833
+
834
+ return camera(gobj, "repaint the graph", function(graph) {
835
+ graph.setTheme(priv.theme);
836
+ graph.setOptions({
837
+ edge: {
838
+ style: {
839
+ stroke: edge_color(),
840
+ labelFill: edge_label_color(),
841
+ labelBackgroundFill: card_bg(),
842
+ },
843
+ },
844
+ });
845
+ graph.draw();
846
+ });
847
+ }
848
+
849
+ /************************************************************
850
+ * EV_LANGUAGE_CHANGED
851
+ *
852
+ * A rebuild, not a re-translation: the words of a card live
853
+ * inside an innerHTML string, where refresh_language()
854
+ * cannot reach.
855
+ ************************************************************/
856
+ function ac_language_changed(gobj, event, kw, src)
857
+ {
858
+ let $container = gobj_read_attr(gobj, "$container");
859
+
860
+ if($container) {
861
+ refresh_language($container, t);
862
+ }
863
+ load_fsm(gobj);
864
+ return 0;
865
+ }
866
+
867
+ /************************************************************
868
+ * The camera.
869
+ ************************************************************/
870
+ function ac_zoom_in(gobj, event, kw, src)
871
+ {
872
+ return camera(gobj, "zoom in", (graph) => graph.zoomBy(1.2));
873
+ }
874
+
875
+ function ac_zoom_out(gobj, event, kw, src)
876
+ {
877
+ return camera(gobj, "zoom out", (graph) => graph.zoomBy(1 / 1.2));
878
+ }
879
+
880
+ function ac_zoom_reset(gobj, event, kw, src)
881
+ {
882
+ return camera(gobj, "reset the zoom", (graph) => graph.zoomTo(1));
883
+ }
884
+
885
+ function ac_center(gobj, event, kw, src)
886
+ {
887
+ return camera(gobj, "fit the graph", (graph) => fit_readable(graph));
888
+ }
889
+
890
+ /************************************************************
891
+ * EV_SHOW / EV_HIDE -- host visibility
892
+ ************************************************************/
893
+ function ac_show(gobj, event, kw, src)
894
+ {
895
+ let $container = gobj_read_attr(gobj, "$container");
896
+
897
+ if($container) {
898
+ $container.classList.remove('is-hidden');
899
+ }
900
+ gobj_send_event(gobj, "EV_RESIZE", {}, gobj);
901
+ return 0;
902
+ }
903
+
904
+ function ac_hide(gobj, event, kw, src)
905
+ {
906
+ let $container = gobj_read_attr(gobj, "$container");
907
+
908
+ if($container) {
909
+ $container.classList.add('is-hidden');
910
+ }
911
+ return 0;
912
+ }
913
+
914
+
915
+
916
+
917
+ /***************************
918
+ * FSM
919
+ ***************************/
920
+
921
+
922
+
923
+
924
+ /*---------------------------------------------*
925
+ * Global methods table
926
+ *---------------------------------------------*/
927
+ const gmt = {
928
+ mt_create: mt_create,
929
+ mt_start: mt_start,
930
+ mt_stop: mt_stop,
931
+ mt_destroy: mt_destroy,
932
+ };
933
+
934
+ /***************************************************************
935
+ * Create the GClass
936
+ ***************************************************************/
937
+ function create_gclass(gclass_name)
938
+ {
939
+ if(__gclass__) {
940
+ log_error(`GClass ALREADY created: ${gclass_name}`);
941
+ return -1;
942
+ }
943
+
944
+ /*---------------------------------------------*
945
+ * States
946
+ *---------------------------------------------*/
947
+ const states = [
948
+ ["ST_IDLE", [
949
+ ["EV_SET_FSM", ac_set_fsm, null],
950
+ ["EV_CHANGE_LAYOUT", ac_change_layout, null],
951
+ ["EV_ZOOM_IN", ac_zoom_in, null],
952
+ ["EV_ZOOM_OUT", ac_zoom_out, null],
953
+ ["EV_ZOOM_RESET", ac_zoom_reset, null],
954
+ ["EV_CENTER", ac_center, null],
955
+ ["EV_RESIZE", ac_resize, null],
956
+ ["EV_THEME_CHANGED", ac_theme_changed, null],
957
+ ["EV_LANGUAGE_CHANGED", ac_language_changed, null],
958
+ ["EV_SHOW", ac_show, null],
959
+ ["EV_HIDE", ac_hide, null]
960
+ ]]
961
+ ];
962
+
963
+ /*---------------------------------------------*
964
+ * Events
965
+ *---------------------------------------------*/
966
+ const event_types = [
967
+ ["EV_SET_FSM", 0],
968
+ ["EV_CHANGE_LAYOUT", 0],
969
+ ["EV_ZOOM_IN", 0],
970
+ ["EV_ZOOM_OUT", 0],
971
+ ["EV_ZOOM_RESET", 0],
972
+ ["EV_CENTER", 0],
973
+ ["EV_RESIZE", 0],
974
+ ["EV_THEME_CHANGED", 0],
975
+ ["EV_LANGUAGE_CHANGED", 0],
976
+ ["EV_SHOW", 0],
977
+ ["EV_HIDE", 0]
978
+ ];
979
+
980
+ __gclass__ = gclass_create(
981
+ gclass_name,
982
+ event_types,
983
+ states,
984
+ gmt,
985
+ 0, // lmt,
986
+ attrs_table,
987
+ PRIVATE_DATA,
988
+ 0, // authz_table,
989
+ 0, // command_table,
990
+ 0, // s_user_trace_level
991
+ 0 // gclass_flag
992
+ );
993
+
994
+ if(!__gclass__) {
995
+ return -1;
996
+ }
997
+
998
+ return 0;
999
+ }
1000
+
1001
+ /***************************************************************
1002
+ * Register GClass
1003
+ ***************************************************************/
1004
+ function register_c_yui_fsm_graph()
1005
+ {
1006
+ /* Idempotent: C_YUI_GCLASS registers it for its machine zone, so
1007
+ * an app that also registers it explicitly must not trip
1008
+ * "GClass ALREADY created". */
1009
+ if(gclass_find_by_name(GCLASS_NAME, false)) {
1010
+ return 0;
1011
+ }
1012
+ return create_gclass(GCLASS_NAME);
1013
+ }
1014
+
1015
+ export { register_c_yui_fsm_graph };