@yuneta/gobj-ui 5.2.1 → 5.3.0

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,463 @@
1
+ /***********************************************************************
2
+ * node_tree_model.js
3
+ *
4
+ * Pure helpers for C_YUI_NODE (no gobj, no DOM, no imports) —
5
+ * kept apart so they are trivially unit-testable, like
6
+ * route_resolver.js and route_map_model.js.
7
+ *
8
+ * The model: a navigable position is a NODE, nodes form a
9
+ * TREE, and the URL is the path of node ids from the tree's
10
+ * base route down. A node holds HOW IT WANTS ITS CHILDREN
11
+ * SEEN (the projection) — so "two levels of menu" stops being
12
+ * a concept: there is only a parent projecting its children,
13
+ * recursively, at any depth.
14
+ *
15
+ * Where the STRUCTURAL tree ends, a node declares a `link`:
16
+ * a pointer into a data space (a timeranger — millions of raw
17
+ * records, series/time, key/value) plus the viewer suited to
18
+ * that shape. Below a link there are no more nodes: the URL
19
+ * keeps going, but its tail belongs to the viewer. That is
20
+ * the scale boundary — one gobj per structural node is right,
21
+ * one gobj per meter reading is not.
22
+ *
23
+ * Validation lives HERE (normalize_spec), once, at the door;
24
+ * every other helper assumes it received validated data, so
25
+ * it never has to decide what to do with garbage in silence.
26
+ * Diagnostics are RETURNED (this file cannot log — it has no
27
+ * imports by design); C_YUI_NODE logs them.
28
+ *
29
+ * Copyright (c) 2026, ArtGins.
30
+ * All Rights Reserved.
31
+ ***********************************************************************/
32
+
33
+ /* The layouts C_YUI_NAV can render a projection with. Kept in sync
34
+ * with SUPPORTED_LAYOUTS in c_yui_nav.js — a projection is just a nav
35
+ * render config, so an unknown layout is a config error here rather
36
+ * than a fallback surprise three levels deep in the tree. */
37
+ const VALID_LAYOUTS = [
38
+ "vertical", "icon-bar", "tabs", "drawer", "submenu", "accordion", "cards",
39
+ "backbar", "breadcrumb"
40
+ ];
41
+
42
+ /* A node with no declared projection shows its children as cards: the
43
+ * drill-down landing is the shape this whole model exists to serve. */
44
+ const DEFAULT_INDEX_RENDER = {layout: "cards"};
45
+
46
+ /************************************************************
47
+ * "a/b/c" → ["a","b","c"]. Empty segments are dropped, so
48
+ * a subpath arriving as "/a//b/" behaves like "a/b" (the
49
+ * shell normalizes routes, but a subpath is a tail it slices
50
+ * out and a caller may build one by hand).
51
+ ************************************************************/
52
+ export function split_subpath(subpath)
53
+ {
54
+ if(!subpath) {
55
+ return [];
56
+ }
57
+ return String(subpath).split("/").filter((s) => s.length > 0);
58
+ }
59
+
60
+ /************************************************************
61
+ * First segment + the rest: {head, tail}. This is the whole
62
+ * of how a node decides "is this for me, or for a child?" —
63
+ * head names the child, tail is that child's own subpath.
64
+ ************************************************************/
65
+ export function head_tail(subpath)
66
+ {
67
+ let segs = split_subpath(subpath);
68
+ if(segs.length === 0) {
69
+ return {head: "", tail: ""};
70
+ }
71
+ return {head: segs[0], tail: segs.slice(1).join("/")};
72
+ }
73
+
74
+ /************************************************************
75
+ * base_route + ids → the canonical route of a node.
76
+ * ("/cards", ["energy","north"]) → "/cards/energy/north"
77
+ ************************************************************/
78
+ export function join_route(base_route, ids)
79
+ {
80
+ let base = String(base_route || "/");
81
+ if(base.charAt(0) !== "/") {
82
+ base = "/" + base;
83
+ }
84
+ base = base.replace(/\/{2,}/g, "/");
85
+ if(base.length > 1) {
86
+ base = base.replace(/\/+$/, "");
87
+ }
88
+ let tail = (ids || []).filter((s) => s && String(s).length > 0).join("/");
89
+ if(!tail) {
90
+ return base;
91
+ }
92
+ if(base === "/") {
93
+ return "/" + tail;
94
+ }
95
+ return base + "/" + tail;
96
+ }
97
+
98
+ /************************************************************
99
+ * The render configs a node uses to project its children in
100
+ * one of the two modes:
101
+ *
102
+ * "index" — I am the tip of the path: the projection IS
103
+ * the page (a card grid, a list…).
104
+ * "chrome" — a child of mine is showing: the projection is
105
+ * the chrome around it (a tab strip, a backbar).
106
+ * "path" — a child of mine is showing: the TRAIL down to
107
+ * where the user is, as one line (a breadcrumb).
108
+ * The other two project a node's CHILDREN; this one
109
+ * projects the path, which is why it is the answer
110
+ * when the stacked strips cost more vertical room
111
+ * than they are worth.
112
+ *
113
+ * Accepted shapes of `projection`:
114
+ * "cards" → index cards, no chrome
115
+ * {layout:"cards", …} → index only, no chrome
116
+ * {index:…, chrome:…} → both, each a render config
117
+ * or an ARRAY of them (that is
118
+ * how tabs>=tablet + backbar
119
+ * <tablet coexist)
120
+ *
121
+ * Input is assumed validated by normalize_spec().
122
+ ************************************************************/
123
+ export function projection_renders(projection, mode)
124
+ {
125
+ if(projection === null || projection === undefined || projection === "") {
126
+ if(mode === "index") {
127
+ return [Object.assign({}, DEFAULT_INDEX_RENDER)];
128
+ }
129
+ return [];
130
+ }
131
+ if(typeof projection === "string") {
132
+ if(mode === "index") {
133
+ return [{layout: projection}];
134
+ }
135
+ return [];
136
+ }
137
+
138
+ let has_modes = ("index" in projection) || ("chrome" in projection) ||
139
+ ("path" in projection);
140
+ if(!has_modes) {
141
+ if(mode === "index") {
142
+ return [Object.assign({}, projection)];
143
+ }
144
+ return [];
145
+ }
146
+
147
+ let raw = projection[mode];
148
+ if(raw === null || raw === undefined) {
149
+ return [];
150
+ }
151
+ if(typeof raw === "string") {
152
+ return [{layout: raw}];
153
+ }
154
+
155
+ let list = Array.isArray(raw) ? raw : [raw];
156
+ let out = [];
157
+ for(let r of list) {
158
+ out.push(Object.assign({}, r));
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /************************************************************
164
+ * Children → C_YUI_NAV `menu_items`. The nav is the renderer
165
+ * of every projection (cards, tabs, vertical, backbar…), so a
166
+ * projection is DRY with the shell's own menus: same item
167
+ * contract, same click event, same i18n and icon handling.
168
+ *
169
+ * `route_of(id)` returns the canonical route of a child — the
170
+ * node supplies it, so this file stays route-agnostic.
171
+ ************************************************************/
172
+ export function child_nav_items(children, route_of)
173
+ {
174
+ let items = [];
175
+ for(let c of (children || [])) {
176
+ let item = {
177
+ id: c.id,
178
+ name: c.label || c.id,
179
+ route: route_of(c.id)
180
+ };
181
+ if(c.icon) {
182
+ item.icon = c.icon;
183
+ }
184
+ if(c.tooltip) {
185
+ item.tooltip = c.tooltip;
186
+ }
187
+ if(c.disabled) {
188
+ item.disabled = true;
189
+ }
190
+ items.push(item);
191
+ }
192
+ return items;
193
+ }
194
+
195
+ /************************************************************
196
+ * Does the ancestor at `distance` from the tip paint its chrome?
197
+ *
198
+ * `depth` is the effective `chrome_depth` of the active path: the
199
+ * number of chrome strips painted ABOVE the current position.
200
+ * `null` means unlimited — every ancestor paints, which is the
201
+ * recursion in its raw form and stacks one strip per level.
202
+ *
203
+ * distance 1 is the tip's own parent, so `depth: 1` leaves exactly
204
+ * one strip and `depth: 0` leaves none.
205
+ ************************************************************/
206
+ export function chrome_visible(distance, depth)
207
+ {
208
+ if(depth === null || depth === undefined) {
209
+ return true;
210
+ }
211
+ return distance <= depth;
212
+ }
213
+
214
+ /************************************************************
215
+ * Validate + fill one node spec. Returns the normalized spec,
216
+ * or null when it is unusable; every rejection pushes a
217
+ * human-readable line into `errors` (the caller logs them —
218
+ * this file has no imports). `where` is a path-ish label used
219
+ * to locate the offending node in the message.
220
+ *
221
+ * Recursive: children are normalized too, so ONE call at the
222
+ * door validates a whole declared tree.
223
+ ************************************************************/
224
+ export function normalize_spec(spec, errors, where)
225
+ {
226
+ let at = where || "/";
227
+
228
+ if(!spec || typeof spec !== "object" || Array.isArray(spec)) {
229
+ errors.push(`node spec at '${at}' is not an object`);
230
+ return null;
231
+ }
232
+
233
+ let id = spec.id;
234
+ if(typeof id !== "string" || id.length === 0) {
235
+ errors.push(`node at '${at}' has no 'id' (the id IS the url segment)`);
236
+ return null;
237
+ }
238
+ if(id.indexOf("/") >= 0) {
239
+ errors.push(`node id '${id}' at '${at}' contains '/' — an id is ONE url segment`);
240
+ return null;
241
+ }
242
+
243
+ let out = {
244
+ id: id,
245
+ label: (typeof spec.label === "string" && spec.label) ? spec.label : id,
246
+ icon: (typeof spec.icon === "string") ? spec.icon : "",
247
+ tooltip: (typeof spec.tooltip === "string") ? spec.tooltip : "",
248
+ disabled: !!spec.disabled,
249
+ /* -1 = not declared here (the path inherits it). A node whose
250
+ * only job is to cap the stacked chrome of its subtree is a
251
+ * legitimate, and expected, intermediate node. */
252
+ chrome_depth: -1,
253
+ aliases: [],
254
+ projection: null,
255
+ content: null,
256
+ link: null,
257
+ children: []
258
+ };
259
+
260
+ if(spec.chrome_depth !== undefined && spec.chrome_depth !== null) {
261
+ let d = spec.chrome_depth;
262
+ /* -1 is this function's OWN "not declared" output: each level of
263
+ * the tree re-normalizes the children the level above handed it,
264
+ * so normalize_spec has to accept what it produces. */
265
+ if(typeof d !== "number" || d < -1 || Math.floor(d) !== d) {
266
+ errors.push(
267
+ `node '${at}${id}': 'chrome_depth' must be an integer >= 0 ` +
268
+ `(0 = no chrome strip, omit it to inherit)`
269
+ );
270
+ return null;
271
+ }
272
+ out.chrome_depth = d;
273
+ }
274
+
275
+ /* The tree is a CONTRACT: its paths are urls a client may hold.
276
+ * An id therefore never changes — a rename ships the old id as an
277
+ * alias, and the old url keeps resolving (rewritten to the
278
+ * canonical one), which is what makes a version bump migratable. */
279
+ if(spec.aliases !== undefined && spec.aliases !== null) {
280
+ if(!Array.isArray(spec.aliases)) {
281
+ errors.push(`node '${at}${id}': 'aliases' must be an array of ids`);
282
+ return null;
283
+ }
284
+ for(let a of spec.aliases) {
285
+ if(typeof a !== "string" || a.length === 0 || a.indexOf("/") >= 0) {
286
+ errors.push(
287
+ `node '${at}${id}': alias '${a}' is not a valid id ` +
288
+ `(one url segment)`
289
+ );
290
+ return null;
291
+ }
292
+ out.aliases.push(a);
293
+ }
294
+ }
295
+
296
+ let proj_errors = validate_projection(spec.projection, `${at}${id}`);
297
+ if(proj_errors.length) {
298
+ for(let e of proj_errors) {
299
+ errors.push(e);
300
+ }
301
+ return null;
302
+ }
303
+ if(spec.projection !== undefined) {
304
+ out.projection = spec.projection;
305
+ }
306
+
307
+ if(spec.content !== undefined && spec.content !== null) {
308
+ let c = spec.content;
309
+ if(typeof c !== "object" || Array.isArray(c)) {
310
+ errors.push(`node '${at}${id}': 'content' must be an object {gclass, kw?}`);
311
+ return null;
312
+ }
313
+ if(typeof c.gclass !== "string" || c.gclass.length === 0) {
314
+ errors.push(`node '${at}${id}': 'content.gclass' is required`);
315
+ return null;
316
+ }
317
+ out.content = {
318
+ gclass: c.gclass,
319
+ kw: (c.kw && typeof c.kw === "object") ? c.kw : {}
320
+ };
321
+ }
322
+
323
+ /* A link is where structure stops and data begins: it mounts a
324
+ * viewer and OWNS everything below it in the url. It therefore
325
+ * cannot also be a branch (children) or carry a separate content —
326
+ * declaring both is a contradiction about who owns the subpath,
327
+ * and a silent winner would be the worst outcome. */
328
+ if(spec.link !== undefined && spec.link !== null) {
329
+ let l = spec.link;
330
+ if(typeof l !== "object" || Array.isArray(l)) {
331
+ errors.push(`node '${at}${id}': 'link' must be an object {kind, gclass, kw?}`);
332
+ return null;
333
+ }
334
+ if(typeof l.kind !== "string" || l.kind.length === 0) {
335
+ errors.push(
336
+ `node '${at}${id}': 'link.kind' is required — name the data ` +
337
+ `space it points into (e.g. "tranger")`
338
+ );
339
+ return null;
340
+ }
341
+ if(typeof l.gclass !== "string" || l.gclass.length === 0) {
342
+ errors.push(
343
+ `node '${at}${id}': 'link.gclass' is required — the viewer ` +
344
+ `suited to that data`
345
+ );
346
+ return null;
347
+ }
348
+ if(out.content) {
349
+ errors.push(
350
+ `node '${at}${id}': declares both 'link' and 'content' — a ` +
351
+ `link IS the node's content`
352
+ );
353
+ return null;
354
+ }
355
+ out.link = {
356
+ kind: l.kind,
357
+ gclass: l.gclass,
358
+ kw: (l.kw && typeof l.kw === "object") ? l.kw : {}
359
+ };
360
+ }
361
+
362
+ if(spec.children !== undefined && spec.children !== null) {
363
+ if(!Array.isArray(spec.children)) {
364
+ errors.push(`node '${at}${id}': 'children' must be an array`);
365
+ return null;
366
+ }
367
+ if(out.link && spec.children.length) {
368
+ errors.push(
369
+ `node '${at}${id}': declares both 'link' and 'children' — ` +
370
+ `below a link the url belongs to the viewer, not to nodes`
371
+ );
372
+ return null;
373
+ }
374
+ let seen = {};
375
+ for(let child of spec.children) {
376
+ let n = normalize_spec(child, errors, `${at}${id}/`);
377
+ if(!n) {
378
+ return null;
379
+ }
380
+ if(seen[n.id]) {
381
+ errors.push(
382
+ `node '${at}${id}': duplicated child id '${n.id}' — ` +
383
+ `sibling ids are url segments and must be unique`
384
+ );
385
+ return null;
386
+ }
387
+ seen[n.id] = true;
388
+ out.children.push(n);
389
+ }
390
+ }
391
+
392
+ return out;
393
+ }
394
+
395
+ /************************************************************
396
+ * Shape check of a `projection` value. Returns an array of
397
+ * error lines (empty when valid).
398
+ ************************************************************/
399
+ function validate_projection(projection, at)
400
+ {
401
+ let errors = [];
402
+
403
+ if(projection === undefined || projection === null) {
404
+ return errors;
405
+ }
406
+ if(typeof projection === "string") {
407
+ if(VALID_LAYOUTS.indexOf(projection) < 0) {
408
+ errors.push(`node '${at}': unknown projection layout '${projection}'`);
409
+ }
410
+ return errors;
411
+ }
412
+ if(typeof projection !== "object" || Array.isArray(projection)) {
413
+ errors.push(`node '${at}': 'projection' must be a string or an object`);
414
+ return errors;
415
+ }
416
+
417
+ let has_modes = ("index" in projection) || ("chrome" in projection) ||
418
+ ("path" in projection);
419
+ if(!has_modes) {
420
+ check_render(projection, at, "projection", errors);
421
+ return errors;
422
+ }
423
+ for(let mode of ["index", "chrome"]) {
424
+ if(!(mode in projection)) {
425
+ continue;
426
+ }
427
+ let raw = projection[mode];
428
+ if(raw === null || raw === undefined) {
429
+ continue;
430
+ }
431
+ let list = Array.isArray(raw) ? raw : [raw];
432
+ for(let r of list) {
433
+ if(typeof r === "string") {
434
+ if(VALID_LAYOUTS.indexOf(r) < 0) {
435
+ errors.push(`node '${at}': unknown ${mode} layout '${r}'`);
436
+ }
437
+ continue;
438
+ }
439
+ check_render(r, at, `projection.${mode}`, errors);
440
+ }
441
+ }
442
+ return errors;
443
+ }
444
+
445
+ /************************************************************
446
+ * One render config: {layout, icon_pos?, show_label?, show_on?}
447
+ ************************************************************/
448
+ function check_render(render, at, what, errors)
449
+ {
450
+ if(!render || typeof render !== "object" || Array.isArray(render)) {
451
+ errors.push(`node '${at}': '${what}' must be an object {layout, …}`);
452
+ return;
453
+ }
454
+ if(typeof render.layout !== "string" || render.layout.length === 0) {
455
+ errors.push(`node '${at}': '${what}' has no 'layout'`);
456
+ return;
457
+ }
458
+ if(VALID_LAYOUTS.indexOf(render.layout) < 0) {
459
+ errors.push(`node '${at}': unknown ${what} layout '${render.layout}'`);
460
+ }
461
+ }
462
+
463
+ export { VALID_LAYOUTS };