@data-navigator/inspector 1.0.3 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/console-menu.js +79 -0
- package/src/force-graph.js +5 -3
- package/src/inspector.js +63 -18
- package/src/menu-events.js +28 -0
- package/src/menu-sections.js +938 -0
- package/src/menu-state.js +89 -0
- package/src/svg-highlight.js +479 -0
- package/src/tree-graph.js +60 -35
- package/style.css +295 -0
|
@@ -0,0 +1,938 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builders for each section of the console menu.
|
|
3
|
+
* Each function returns an HTMLElement to be appended to the menu.
|
|
4
|
+
*
|
|
5
|
+
* Rendered Elements uses lazy rendering: child menu items are only created
|
|
6
|
+
* when their parent <details> is opened, and destroyed when it closes.
|
|
7
|
+
* Checked state persists in menu-state.js (independent of DOM).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { EVENTS, dispatch } from './menu-events.js';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Utility: lazy cleanup
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Recursively call _unsub on an element and all its descendants.
|
|
18
|
+
*/
|
|
19
|
+
function cleanupElement(el) {
|
|
20
|
+
if (el._unsub) {
|
|
21
|
+
el._unsub();
|
|
22
|
+
delete el._unsub;
|
|
23
|
+
}
|
|
24
|
+
if (el.children) {
|
|
25
|
+
Array.from(el.children).forEach(cleanupElement);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Remove all children of a <details> except its <summary>,
|
|
31
|
+
* recursively calling _unsub on each removed subtree.
|
|
32
|
+
*/
|
|
33
|
+
function cleanupChildren(detailsEl) {
|
|
34
|
+
const children = Array.from(detailsEl.children);
|
|
35
|
+
children.forEach(child => {
|
|
36
|
+
if (child.tagName === 'SUMMARY') return;
|
|
37
|
+
cleanupElement(child);
|
|
38
|
+
detailsEl.removeChild(child);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Utility: collapsible section with caret indicator
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create a <details> with a compact caret-styled <summary>.
|
|
48
|
+
*/
|
|
49
|
+
function makeDetails(summaryText, opts = {}) {
|
|
50
|
+
const details = document.createElement('details');
|
|
51
|
+
if (opts.className) details.className = opts.className;
|
|
52
|
+
if (opts.open) details.open = true;
|
|
53
|
+
|
|
54
|
+
const summary = document.createElement('summary');
|
|
55
|
+
summary.className = 'dn-menu-summary' + (opts.summaryClass ? ' ' + opts.summaryClass : '');
|
|
56
|
+
summary.textContent = summaryText;
|
|
57
|
+
details.appendChild(summary);
|
|
58
|
+
|
|
59
|
+
return details;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Create a <details> that lazily populates children on open and
|
|
64
|
+
* destroys them on close. For sections without a group checkbox.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} summaryText
|
|
67
|
+
* @param {Function} populateFn - Called with (detailsEl) when opened
|
|
68
|
+
* @param {Object} state - Menu state (for hover cleanup on close)
|
|
69
|
+
* @param {Object} [opts] - className, open, summaryClass
|
|
70
|
+
*/
|
|
71
|
+
function makeLazyDetails(summaryText, populateFn, state, opts = {}) {
|
|
72
|
+
const details = document.createElement('details');
|
|
73
|
+
if (opts.className) details.className = opts.className;
|
|
74
|
+
|
|
75
|
+
const summary = document.createElement('summary');
|
|
76
|
+
summary.className = 'dn-menu-summary' + (opts.summaryClass ? ' ' + opts.summaryClass : '');
|
|
77
|
+
summary.textContent = summaryText;
|
|
78
|
+
details.appendChild(summary);
|
|
79
|
+
|
|
80
|
+
details.addEventListener('toggle', () => {
|
|
81
|
+
if (details.open) {
|
|
82
|
+
populateFn(details);
|
|
83
|
+
} else {
|
|
84
|
+
if (state.hoveredItem) state.clearHover();
|
|
85
|
+
cleanupChildren(details);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (opts.open) {
|
|
90
|
+
details.open = true;
|
|
91
|
+
populateFn(details);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return details;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Create a <details> with a group checkbox in the <summary>.
|
|
99
|
+
* Lazily renders children on open, destroys on close.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} summaryText - Label text for the summary
|
|
102
|
+
* @param {Array<{type: string, id: string}>} childItems - Items this group controls
|
|
103
|
+
* @param {Object} state - The menu state object
|
|
104
|
+
* @param {HTMLElement} container - Container for event dispatching
|
|
105
|
+
* @param {Function} populateFn - Called with (detailsEl) when opened
|
|
106
|
+
* @param {Object} [opts] - Options passed to details (className, open, summaryClass)
|
|
107
|
+
* @returns {HTMLDetailsElement}
|
|
108
|
+
*/
|
|
109
|
+
function makeGroupDetails(summaryText, childItems, state, container, populateFn, opts = {}) {
|
|
110
|
+
const details = document.createElement('details');
|
|
111
|
+
if (opts.className) details.className = opts.className;
|
|
112
|
+
|
|
113
|
+
const summary = document.createElement('summary');
|
|
114
|
+
summary.className = 'dn-menu-summary' + (opts.summaryClass ? ' ' + opts.summaryClass : '');
|
|
115
|
+
|
|
116
|
+
const checkbox = document.createElement('input');
|
|
117
|
+
checkbox.type = 'checkbox';
|
|
118
|
+
checkbox.className = 'dn-menu-group-checkbox';
|
|
119
|
+
checkbox.addEventListener('click', e => {
|
|
120
|
+
e.stopPropagation(); // Don't toggle <details> when clicking checkbox
|
|
121
|
+
});
|
|
122
|
+
checkbox.addEventListener('change', () => {
|
|
123
|
+
const shouldCheck = checkbox.checked;
|
|
124
|
+
childItems.forEach(item => {
|
|
125
|
+
if (shouldCheck) {
|
|
126
|
+
state.check(item.type, item.id);
|
|
127
|
+
} else {
|
|
128
|
+
state.uncheck(item.type, item.id);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
const allChecked = state.getChecked();
|
|
132
|
+
dispatch(container, EVENTS.SELECTION_CHANGE, { checked: allChecked });
|
|
133
|
+
});
|
|
134
|
+
summary.appendChild(checkbox);
|
|
135
|
+
|
|
136
|
+
const labelSpan = document.createElement('span');
|
|
137
|
+
labelSpan.textContent = summaryText;
|
|
138
|
+
summary.appendChild(labelSpan);
|
|
139
|
+
|
|
140
|
+
details.appendChild(summary);
|
|
141
|
+
|
|
142
|
+
// Sync checkbox state — works without child DOM (reads from state)
|
|
143
|
+
function syncGroupCheckbox() {
|
|
144
|
+
const checkedCount = childItems.filter(item => state.isChecked(item.type, item.id)).length;
|
|
145
|
+
checkbox.checked = checkedCount === childItems.length && childItems.length > 0;
|
|
146
|
+
checkbox.indeterminate = checkedCount > 0 && checkedCount < childItems.length;
|
|
147
|
+
}
|
|
148
|
+
syncGroupCheckbox();
|
|
149
|
+
|
|
150
|
+
const unsub = state.subscribe(changeType => {
|
|
151
|
+
if (changeType === 'check' || changeType === 'uncheck') {
|
|
152
|
+
syncGroupCheckbox();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
details._unsub = unsub;
|
|
156
|
+
|
|
157
|
+
// Lazy toggle handler
|
|
158
|
+
details.addEventListener('toggle', () => {
|
|
159
|
+
if (details.open) {
|
|
160
|
+
populateFn(details);
|
|
161
|
+
} else {
|
|
162
|
+
if (state.hoveredItem) state.clearHover();
|
|
163
|
+
cleanupChildren(details);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (opts.open) {
|
|
168
|
+
details.open = true;
|
|
169
|
+
populateFn(details);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return details;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Utility: inline expandable array "label: [...](N)"
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Create an inline expandable array display.
|
|
181
|
+
* Collapsed: "label: [...](N)"
|
|
182
|
+
* Expanded: "label: [item1, item2, item3]" (inline, no new lines)
|
|
183
|
+
* Each item is interactive (hoverable via state).
|
|
184
|
+
*/
|
|
185
|
+
function buildInlineArray({ label, items, state, container, structure }) {
|
|
186
|
+
const wrapper = document.createElement('div');
|
|
187
|
+
wrapper.className = 'dn-menu-inline-array';
|
|
188
|
+
|
|
189
|
+
const toggle = document.createElement('span');
|
|
190
|
+
toggle.className = 'dn-menu-array-toggle';
|
|
191
|
+
toggle.textContent = label + ': ';
|
|
192
|
+
wrapper.appendChild(toggle);
|
|
193
|
+
|
|
194
|
+
const collapsed = document.createElement('span');
|
|
195
|
+
collapsed.className = 'dn-menu-array-collapsed';
|
|
196
|
+
collapsed.textContent = '[...](' + items.length + ')';
|
|
197
|
+
wrapper.appendChild(collapsed);
|
|
198
|
+
|
|
199
|
+
const expanded = document.createElement('span');
|
|
200
|
+
expanded.className = 'dn-menu-array-expanded dn-menu-hidden';
|
|
201
|
+
expanded.textContent = '[';
|
|
202
|
+
|
|
203
|
+
items.forEach((item, i) => {
|
|
204
|
+
const chip = document.createElement('span');
|
|
205
|
+
chip.className = 'dn-menu-array-chip';
|
|
206
|
+
chip.textContent = item.label;
|
|
207
|
+
chip.dataset.type = item.type;
|
|
208
|
+
chip.dataset.id = item.id;
|
|
209
|
+
|
|
210
|
+
chip.addEventListener('mouseenter', () => {
|
|
211
|
+
state.setHover(item.type, item.id);
|
|
212
|
+
dispatch(container, EVENTS.ITEM_HOVER, {
|
|
213
|
+
type: item.type,
|
|
214
|
+
id: item.id,
|
|
215
|
+
sourceData: getSourceData(item.type, item.id, structure)
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
chip.addEventListener('mouseleave', () => {
|
|
219
|
+
state.clearHover();
|
|
220
|
+
dispatch(container, EVENTS.ITEM_UNHOVER, { type: item.type, id: item.id });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expanded.appendChild(chip);
|
|
224
|
+
if (i < items.length - 1) {
|
|
225
|
+
expanded.appendChild(document.createTextNode(', '));
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expanded.appendChild(document.createTextNode(']'));
|
|
230
|
+
wrapper.appendChild(expanded);
|
|
231
|
+
|
|
232
|
+
const toggleFn = () => {
|
|
233
|
+
const isCollapsed = !collapsed.classList.contains('dn-menu-hidden');
|
|
234
|
+
collapsed.classList.toggle('dn-menu-hidden', isCollapsed);
|
|
235
|
+
expanded.classList.toggle('dn-menu-hidden', !isCollapsed);
|
|
236
|
+
};
|
|
237
|
+
collapsed.addEventListener('click', toggleFn);
|
|
238
|
+
toggle.addEventListener('click', toggleFn);
|
|
239
|
+
toggle.style.cursor = 'pointer';
|
|
240
|
+
collapsed.style.cursor = 'pointer';
|
|
241
|
+
|
|
242
|
+
return wrapper;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Shared helper: buildMenuItem
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Creates a menu item row with checkbox, label, and optional "log" button.
|
|
251
|
+
*/
|
|
252
|
+
export function buildMenuItem({ type, id, label, state, container, showLog, logFn, consoleListEl, structure }) {
|
|
253
|
+
const row = document.createElement('div');
|
|
254
|
+
row.className = 'dn-menu-item';
|
|
255
|
+
row.dataset.type = type;
|
|
256
|
+
row.dataset.id = id;
|
|
257
|
+
|
|
258
|
+
const checkbox = document.createElement('input');
|
|
259
|
+
checkbox.type = 'checkbox';
|
|
260
|
+
checkbox.checked = state.isChecked(type, id);
|
|
261
|
+
checkbox.addEventListener('change', () => {
|
|
262
|
+
state.toggle(type, id);
|
|
263
|
+
const allChecked = state.getChecked();
|
|
264
|
+
if (state.isChecked(type, id)) {
|
|
265
|
+
dispatch(container, EVENTS.ITEM_CHECK, { type, id, allChecked });
|
|
266
|
+
} else {
|
|
267
|
+
dispatch(container, EVENTS.ITEM_UNCHECK, { type, id, allChecked });
|
|
268
|
+
}
|
|
269
|
+
dispatch(container, EVENTS.SELECTION_CHANGE, { checked: allChecked });
|
|
270
|
+
});
|
|
271
|
+
row.appendChild(checkbox);
|
|
272
|
+
|
|
273
|
+
const labelSpan = document.createElement('span');
|
|
274
|
+
labelSpan.className = 'dn-menu-item-label';
|
|
275
|
+
labelSpan.textContent = label;
|
|
276
|
+
row.appendChild(labelSpan);
|
|
277
|
+
|
|
278
|
+
if (showLog && logFn) {
|
|
279
|
+
const logBtn = document.createElement('button');
|
|
280
|
+
logBtn.className = 'dn-menu-log-btn';
|
|
281
|
+
logBtn.textContent = 'log';
|
|
282
|
+
logBtn.addEventListener('click', e => {
|
|
283
|
+
e.stopPropagation();
|
|
284
|
+
const result = logFn();
|
|
285
|
+
if (result && consoleListEl) {
|
|
286
|
+
// Open the Console section and all ancestor <details> so it's visible
|
|
287
|
+
let el = consoleListEl;
|
|
288
|
+
while (el) {
|
|
289
|
+
if (el.tagName === 'DETAILS') el.open = true;
|
|
290
|
+
el = el.parentElement;
|
|
291
|
+
}
|
|
292
|
+
appendConsoleLogGroup(result, state, container, consoleListEl, structure);
|
|
293
|
+
dispatch(container, EVENTS.ITEM_LOG, {
|
|
294
|
+
type: result.mainEntry.type,
|
|
295
|
+
id: result.mainEntry.id,
|
|
296
|
+
loggedData: result.mainEntry.data
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
row.appendChild(logBtn);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
row.addEventListener('mouseenter', () => {
|
|
304
|
+
state.setHover(type, id);
|
|
305
|
+
dispatch(container, EVENTS.ITEM_HOVER, {
|
|
306
|
+
type,
|
|
307
|
+
id,
|
|
308
|
+
sourceData: getSourceData(type, id, structure)
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
row.addEventListener('mouseleave', () => {
|
|
312
|
+
state.clearHover();
|
|
313
|
+
dispatch(container, EVENTS.ITEM_UNHOVER, { type, id });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const unsub = state.subscribe((changeType, payload) => {
|
|
317
|
+
if ((changeType === 'check' || changeType === 'uncheck') && payload.type === type && payload.id === id) {
|
|
318
|
+
checkbox.checked = state.isChecked(type, id);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
row._unsub = unsub;
|
|
322
|
+
|
|
323
|
+
return row;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function getSourceData(type, id, structure) {
|
|
327
|
+
if (!structure) return null;
|
|
328
|
+
if (type === 'node') return structure.nodes[id] || null;
|
|
329
|
+
if (type === 'edge') return structure.edges[id] || null;
|
|
330
|
+
if (type === 'rule') return structure.navigationRules?.[id] || null;
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Console log rendering (with inline expandable arrays)
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
function appendConsoleLogGroup(result, state, container, consoleListEl, structure) {
|
|
339
|
+
const { mainEntry, relatedArrays } = result;
|
|
340
|
+
|
|
341
|
+
const mainRow = buildMenuItem({
|
|
342
|
+
type: mainEntry.type,
|
|
343
|
+
id: mainEntry.id,
|
|
344
|
+
label: mainEntry.label,
|
|
345
|
+
state,
|
|
346
|
+
container,
|
|
347
|
+
showLog: false,
|
|
348
|
+
structure
|
|
349
|
+
});
|
|
350
|
+
mainRow.classList.add('dn-menu-console-entry');
|
|
351
|
+
consoleListEl.appendChild(mainRow);
|
|
352
|
+
|
|
353
|
+
state.addLogEntry(mainEntry);
|
|
354
|
+
|
|
355
|
+
if (relatedArrays) {
|
|
356
|
+
relatedArrays.forEach(arr => {
|
|
357
|
+
const inlineEl = buildInlineArray({
|
|
358
|
+
label: arr.label,
|
|
359
|
+
items: arr.items,
|
|
360
|
+
state,
|
|
361
|
+
container,
|
|
362
|
+
structure
|
|
363
|
+
});
|
|
364
|
+
inlineEl.classList.add('dn-menu-console-entry');
|
|
365
|
+
inlineEl.style.paddingLeft = '24px';
|
|
366
|
+
consoleListEl.appendChild(inlineEl);
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
consoleListEl.scrollTop = consoleListEl.scrollHeight;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
// Log functions: return { mainEntry, relatedArrays }
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
function buildNodeLogResult(nodeId, structure) {
|
|
378
|
+
const node = structure.nodes[nodeId];
|
|
379
|
+
if (!node) return null;
|
|
380
|
+
|
|
381
|
+
const edgeItems = (node.edges || [])
|
|
382
|
+
.map(eId => {
|
|
383
|
+
const edge = structure.edges[eId];
|
|
384
|
+
if (!edge) return null;
|
|
385
|
+
const src = typeof edge.source === 'function' ? '?' : edge.source;
|
|
386
|
+
const tgt = typeof edge.target === 'function' ? '?' : edge.target;
|
|
387
|
+
return { type: 'edge', id: eId, label: src + '\u2192' + tgt };
|
|
388
|
+
})
|
|
389
|
+
.filter(Boolean);
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
mainEntry: {
|
|
393
|
+
type: 'node',
|
|
394
|
+
id: nodeId,
|
|
395
|
+
label: 'node: ' + nodeId,
|
|
396
|
+
data: node,
|
|
397
|
+
timestamp: Date.now()
|
|
398
|
+
},
|
|
399
|
+
relatedArrays:
|
|
400
|
+
edgeItems.length > 0
|
|
401
|
+
? [
|
|
402
|
+
{
|
|
403
|
+
label: 'edges',
|
|
404
|
+
items: edgeItems
|
|
405
|
+
}
|
|
406
|
+
]
|
|
407
|
+
: []
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function buildEdgeLogResult(edgeKey, structure) {
|
|
412
|
+
const edge = structure.edges[edgeKey];
|
|
413
|
+
if (!edge) return null;
|
|
414
|
+
|
|
415
|
+
const src = typeof edge.source === 'function' ? null : edge.source;
|
|
416
|
+
const tgt = typeof edge.target === 'function' ? null : edge.target;
|
|
417
|
+
|
|
418
|
+
const relatedArrays = [];
|
|
419
|
+
if (src && structure.nodes[src]) {
|
|
420
|
+
relatedArrays.push({ label: 'source', items: [{ type: 'node', id: src, label: src }] });
|
|
421
|
+
}
|
|
422
|
+
if (tgt && structure.nodes[tgt]) {
|
|
423
|
+
relatedArrays.push({ label: 'target', items: [{ type: 'node', id: tgt, label: tgt }] });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
mainEntry: {
|
|
428
|
+
type: 'edge',
|
|
429
|
+
id: edgeKey,
|
|
430
|
+
label: 'edge: ' + (src || '?') + ' \u2192 ' + (tgt || '?'),
|
|
431
|
+
data: edge,
|
|
432
|
+
timestamp: Date.now()
|
|
433
|
+
},
|
|
434
|
+
relatedArrays
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function buildNavRuleLogResult(ruleId, structure) {
|
|
439
|
+
const rule = structure.navigationRules?.[ruleId];
|
|
440
|
+
|
|
441
|
+
const matchingEdgeItems = [];
|
|
442
|
+
const matchingNodeIds = new Set();
|
|
443
|
+
|
|
444
|
+
Object.keys(structure.edges).forEach(edgeKey => {
|
|
445
|
+
const edge = structure.edges[edgeKey];
|
|
446
|
+
if (edge.navigationRules && edge.navigationRules.includes(ruleId)) {
|
|
447
|
+
const src = typeof edge.source === 'function' ? '?' : edge.source;
|
|
448
|
+
const tgt = typeof edge.target === 'function' ? '?' : edge.target;
|
|
449
|
+
matchingEdgeItems.push({ type: 'edge', id: edgeKey, label: src + '\u2192' + tgt });
|
|
450
|
+
if (typeof edge.source === 'string') matchingNodeIds.add(edge.source);
|
|
451
|
+
if (typeof edge.target === 'string') matchingNodeIds.add(edge.target);
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const matchingNodeItems = [...matchingNodeIds]
|
|
456
|
+
.filter(nid => structure.nodes[nid])
|
|
457
|
+
.map(nid => ({ type: 'node', id: nid, label: nid }));
|
|
458
|
+
|
|
459
|
+
const relatedArrays = [];
|
|
460
|
+
if (matchingEdgeItems.length > 0) {
|
|
461
|
+
relatedArrays.push({ label: 'edges', items: matchingEdgeItems });
|
|
462
|
+
}
|
|
463
|
+
if (matchingNodeItems.length > 0) {
|
|
464
|
+
relatedArrays.push({ label: 'nodes', items: matchingNodeItems });
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
mainEntry: {
|
|
469
|
+
type: 'rule',
|
|
470
|
+
id: ruleId,
|
|
471
|
+
label: 'rule: ' + ruleId,
|
|
472
|
+
data: rule || ruleId,
|
|
473
|
+
timestamp: Date.now()
|
|
474
|
+
},
|
|
475
|
+
relatedArrays
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
// Helper: build an edge menu item
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
function buildEdgeMenuItem(edgeKey, structure, state, container, consoleListEl) {
|
|
484
|
+
const edge = structure.edges[edgeKey];
|
|
485
|
+
const src = typeof edge.source === 'function' ? '(fn)' : edge.source;
|
|
486
|
+
const tgt = typeof edge.target === 'function' ? '(fn)' : edge.target;
|
|
487
|
+
const rules = (edge.navigationRules || []).join(',');
|
|
488
|
+
const label = src + ' \u2192 ' + tgt + (rules ? ' [' + rules + ']' : '');
|
|
489
|
+
return buildMenuItem({
|
|
490
|
+
type: 'edge',
|
|
491
|
+
id: edgeKey,
|
|
492
|
+
label,
|
|
493
|
+
state,
|
|
494
|
+
container,
|
|
495
|
+
showLog: true,
|
|
496
|
+
logFn: () => buildEdgeLogResult(edgeKey, structure),
|
|
497
|
+
consoleListEl,
|
|
498
|
+
structure
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ---------------------------------------------------------------------------
|
|
503
|
+
// Section builders
|
|
504
|
+
// ---------------------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Console section (collapsed by default, sits at top of menu).
|
|
508
|
+
* Includes a "clear" button on the summary row.
|
|
509
|
+
*/
|
|
510
|
+
export function buildConsoleSection() {
|
|
511
|
+
const details = document.createElement('details');
|
|
512
|
+
details.className = 'dn-menu-console';
|
|
513
|
+
|
|
514
|
+
const summary = document.createElement('summary');
|
|
515
|
+
summary.className = 'dn-menu-summary dn-menu-summary-top';
|
|
516
|
+
summary.textContent = 'Console';
|
|
517
|
+
details.appendChild(summary);
|
|
518
|
+
|
|
519
|
+
// Clear button (appended after the summary text, inside summary)
|
|
520
|
+
const clearBtn = document.createElement('button');
|
|
521
|
+
clearBtn.className = 'dn-menu-log-btn dn-menu-clear-btn';
|
|
522
|
+
clearBtn.textContent = 'clear';
|
|
523
|
+
clearBtn.addEventListener('click', e => {
|
|
524
|
+
e.stopPropagation();
|
|
525
|
+
e.preventDefault();
|
|
526
|
+
listEl.innerHTML = '';
|
|
527
|
+
});
|
|
528
|
+
summary.appendChild(clearBtn);
|
|
529
|
+
|
|
530
|
+
const listEl = document.createElement('div');
|
|
531
|
+
listEl.className = 'dn-menu-console-list';
|
|
532
|
+
details.appendChild(listEl);
|
|
533
|
+
|
|
534
|
+
return { element: details, listEl };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Rendered Elements section with grouped Nodes and grouped Edges.
|
|
539
|
+
* Uses lazy rendering: children are created on <details> open and
|
|
540
|
+
* destroyed on close. State persists in the menu-state Map.
|
|
541
|
+
*
|
|
542
|
+
* Nodes are grouped by dimension > division, then "All Nodes" flat list.
|
|
543
|
+
* Edges are grouped by navigation rule, then "All Edges" flat list.
|
|
544
|
+
*/
|
|
545
|
+
export function buildRenderedElementsSection(structure, state, container, consoleListEl, buildLabelFn) {
|
|
546
|
+
const allNodeKeys = Object.keys(structure.nodes || {});
|
|
547
|
+
const allEdgeKeys = Object.keys(structure.edges || {});
|
|
548
|
+
|
|
549
|
+
// Pre-compute edge grouping by navigation rule (cheap — just strings)
|
|
550
|
+
const ruleToEdges = new Map();
|
|
551
|
+
allEdgeKeys.forEach(edgeKey => {
|
|
552
|
+
const edge = structure.edges[edgeKey];
|
|
553
|
+
const rules = edge.navigationRules || [];
|
|
554
|
+
rules.forEach(rule => {
|
|
555
|
+
if (!ruleToEdges.has(rule)) ruleToEdges.set(rule, []);
|
|
556
|
+
ruleToEdges.get(rule).push(edgeKey);
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
const sortedRules = [...ruleToEdges.keys()].sort();
|
|
560
|
+
|
|
561
|
+
// Top-level: Rendered Elements
|
|
562
|
+
const details = makeLazyDetails(
|
|
563
|
+
'Rendered Elements',
|
|
564
|
+
parent => {
|
|
565
|
+
// --- Nodes ---
|
|
566
|
+
const nodesDetails = makeLazyDetails(
|
|
567
|
+
'Nodes (' + allNodeKeys.length + ')',
|
|
568
|
+
nodesParent => {
|
|
569
|
+
// Group nodes by dimension > division
|
|
570
|
+
if (structure.dimensions) {
|
|
571
|
+
const dimKeys = Object.keys(structure.dimensions);
|
|
572
|
+
dimKeys.forEach(dimKey => {
|
|
573
|
+
const dim = structure.dimensions[dimKey];
|
|
574
|
+
const dimNode = structure.nodes[dim.nodeId];
|
|
575
|
+
const divIds = Object.keys(dim.divisions || {});
|
|
576
|
+
|
|
577
|
+
// Collect all node IDs in this dimension for the group checkbox
|
|
578
|
+
const dimChildItems = [];
|
|
579
|
+
if (dimNode) dimChildItems.push({ type: 'node', id: dim.nodeId });
|
|
580
|
+
divIds.forEach(divId => {
|
|
581
|
+
const div = dim.divisions[divId];
|
|
582
|
+
const divNodeId = findDivisionNodeId(structure, dimKey, divId);
|
|
583
|
+
if (divNodeId) dimChildItems.push({ type: 'node', id: divNodeId });
|
|
584
|
+
Object.keys(div.values || {}).forEach(leafId => {
|
|
585
|
+
if (structure.nodes[leafId]) dimChildItems.push({ type: 'node', id: leafId });
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// Dimension-level details with group checkbox
|
|
590
|
+
const dimDetails = makeGroupDetails(
|
|
591
|
+
dimKey +
|
|
592
|
+
' (' +
|
|
593
|
+
dim.type +
|
|
594
|
+
', ' +
|
|
595
|
+
divIds.length +
|
|
596
|
+
' div' +
|
|
597
|
+
(divIds.length !== 1 ? 's' : '') +
|
|
598
|
+
')',
|
|
599
|
+
dimChildItems,
|
|
600
|
+
state,
|
|
601
|
+
container,
|
|
602
|
+
dimParent => {
|
|
603
|
+
// The dimension node itself
|
|
604
|
+
if (dimNode) {
|
|
605
|
+
dimParent.appendChild(
|
|
606
|
+
buildMenuItem({
|
|
607
|
+
type: 'node',
|
|
608
|
+
id: dim.nodeId,
|
|
609
|
+
label: dim.nodeId + ' (dimension)',
|
|
610
|
+
state,
|
|
611
|
+
container,
|
|
612
|
+
showLog: true,
|
|
613
|
+
logFn: () => buildNodeLogResult(dim.nodeId, structure),
|
|
614
|
+
consoleListEl,
|
|
615
|
+
structure
|
|
616
|
+
})
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Each division
|
|
621
|
+
divIds.forEach(divId => {
|
|
622
|
+
const div = dim.divisions[divId];
|
|
623
|
+
const leafIds = Object.keys(div.values || {});
|
|
624
|
+
|
|
625
|
+
// Collect node IDs in this division for the group checkbox
|
|
626
|
+
const divChildItems = [];
|
|
627
|
+
const divNodeId = findDivisionNodeId(structure, dimKey, divId);
|
|
628
|
+
if (divNodeId) divChildItems.push({ type: 'node', id: divNodeId });
|
|
629
|
+
leafIds.forEach(leafId => {
|
|
630
|
+
if (structure.nodes[leafId])
|
|
631
|
+
divChildItems.push({ type: 'node', id: leafId });
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
const divDetails = makeGroupDetails(
|
|
635
|
+
divId +
|
|
636
|
+
' (' +
|
|
637
|
+
leafIds.length +
|
|
638
|
+
' item' +
|
|
639
|
+
(leafIds.length !== 1 ? 's' : '') +
|
|
640
|
+
')',
|
|
641
|
+
divChildItems,
|
|
642
|
+
state,
|
|
643
|
+
container,
|
|
644
|
+
divParent => {
|
|
645
|
+
// Division node itself
|
|
646
|
+
if (divNodeId) {
|
|
647
|
+
divParent.appendChild(
|
|
648
|
+
buildMenuItem({
|
|
649
|
+
type: 'node',
|
|
650
|
+
id: divNodeId,
|
|
651
|
+
label: divNodeId + ' (division)',
|
|
652
|
+
state,
|
|
653
|
+
container,
|
|
654
|
+
showLog: true,
|
|
655
|
+
logFn: () => buildNodeLogResult(divNodeId, structure),
|
|
656
|
+
consoleListEl,
|
|
657
|
+
structure
|
|
658
|
+
})
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Leaf nodes in this division
|
|
663
|
+
leafIds.forEach(leafId => {
|
|
664
|
+
const node = structure.nodes[leafId];
|
|
665
|
+
if (!node) return;
|
|
666
|
+
const desc = buildLabelFn ? buildLabelFn(node) : leafId;
|
|
667
|
+
const label =
|
|
668
|
+
leafId +
|
|
669
|
+
(desc !== leafId ? ' \u2014 ' + truncate(desc, 30) : '');
|
|
670
|
+
divParent.appendChild(
|
|
671
|
+
buildMenuItem({
|
|
672
|
+
type: 'node',
|
|
673
|
+
id: leafId,
|
|
674
|
+
label,
|
|
675
|
+
state,
|
|
676
|
+
container,
|
|
677
|
+
showLog: true,
|
|
678
|
+
logFn: () => buildNodeLogResult(leafId, structure),
|
|
679
|
+
consoleListEl,
|
|
680
|
+
structure
|
|
681
|
+
})
|
|
682
|
+
);
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
dimParent.appendChild(divDetails);
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
nodesParent.appendChild(dimDetails);
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// All Nodes flat list
|
|
697
|
+
const allNodesDetails = makeLazyDetails(
|
|
698
|
+
'All Nodes (' + allNodeKeys.length + ')',
|
|
699
|
+
allParent => {
|
|
700
|
+
allNodeKeys.forEach(nodeId => {
|
|
701
|
+
const node = structure.nodes[nodeId];
|
|
702
|
+
const desc = buildLabelFn ? buildLabelFn(node) : nodeId;
|
|
703
|
+
const label = nodeId + (desc !== nodeId ? ' \u2014 ' + truncate(desc, 30) : '');
|
|
704
|
+
allParent.appendChild(
|
|
705
|
+
buildMenuItem({
|
|
706
|
+
type: 'node',
|
|
707
|
+
id: nodeId,
|
|
708
|
+
label,
|
|
709
|
+
state,
|
|
710
|
+
container,
|
|
711
|
+
showLog: true,
|
|
712
|
+
logFn: () => buildNodeLogResult(nodeId, structure),
|
|
713
|
+
consoleListEl,
|
|
714
|
+
structure
|
|
715
|
+
})
|
|
716
|
+
);
|
|
717
|
+
});
|
|
718
|
+
},
|
|
719
|
+
state
|
|
720
|
+
);
|
|
721
|
+
nodesParent.appendChild(allNodesDetails);
|
|
722
|
+
},
|
|
723
|
+
state
|
|
724
|
+
);
|
|
725
|
+
parent.appendChild(nodesDetails);
|
|
726
|
+
|
|
727
|
+
// --- Edges ---
|
|
728
|
+
const edgesDetails = makeLazyDetails(
|
|
729
|
+
'Edges (' + allEdgeKeys.length + ')',
|
|
730
|
+
edgesParent => {
|
|
731
|
+
// Group edges by navigation rule
|
|
732
|
+
sortedRules.forEach(rule => {
|
|
733
|
+
const edgeKeysForRule = ruleToEdges.get(rule);
|
|
734
|
+
const ruleChildItems = edgeKeysForRule.map(edgeKey => ({ type: 'edge', id: edgeKey }));
|
|
735
|
+
const ruleDetails = makeGroupDetails(
|
|
736
|
+
rule + ' (' + edgeKeysForRule.length + ')',
|
|
737
|
+
ruleChildItems,
|
|
738
|
+
state,
|
|
739
|
+
container,
|
|
740
|
+
ruleParent => {
|
|
741
|
+
edgeKeysForRule.forEach(edgeKey => {
|
|
742
|
+
ruleParent.appendChild(
|
|
743
|
+
buildEdgeMenuItem(edgeKey, structure, state, container, consoleListEl)
|
|
744
|
+
);
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
);
|
|
748
|
+
|
|
749
|
+
edgesParent.appendChild(ruleDetails);
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
// All Edges flat list
|
|
753
|
+
const allEdgesDetails = makeLazyDetails(
|
|
754
|
+
'All Edges (' + allEdgeKeys.length + ')',
|
|
755
|
+
allParent => {
|
|
756
|
+
allEdgeKeys.forEach(edgeKey => {
|
|
757
|
+
allParent.appendChild(
|
|
758
|
+
buildEdgeMenuItem(edgeKey, structure, state, container, consoleListEl)
|
|
759
|
+
);
|
|
760
|
+
});
|
|
761
|
+
},
|
|
762
|
+
state
|
|
763
|
+
);
|
|
764
|
+
edgesParent.appendChild(allEdgesDetails);
|
|
765
|
+
},
|
|
766
|
+
state
|
|
767
|
+
);
|
|
768
|
+
parent.appendChild(edgesDetails);
|
|
769
|
+
},
|
|
770
|
+
state,
|
|
771
|
+
{ summaryClass: 'dn-menu-summary-top' }
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
return details;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Find the node ID for a division node given dimension key and division ID.
|
|
779
|
+
*/
|
|
780
|
+
function findDivisionNodeId(structure, dimKey, divId) {
|
|
781
|
+
// Division nodes have derivedNode === dimKey and data[dimKey] matching
|
|
782
|
+
const nodeKeys = Object.keys(structure.nodes);
|
|
783
|
+
for (let i = 0; i < nodeKeys.length; i++) {
|
|
784
|
+
const node = structure.nodes[nodeKeys[i]];
|
|
785
|
+
if (node.derivedNode === dimKey && node.dimensionLevel === 2) {
|
|
786
|
+
// Check if this node's data value matches divId
|
|
787
|
+
if (node.data && node.data[dimKey] !== undefined && String(node.data[dimKey]) === String(divId)) {
|
|
788
|
+
return nodeKeys[i];
|
|
789
|
+
}
|
|
790
|
+
// Also check by ID pattern — sometimes the node ID is the divId itself
|
|
791
|
+
if (nodeKeys[i] === divId) {
|
|
792
|
+
return divId;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Source Input section: wraps Data, Props, Dimensions, Divisions.
|
|
801
|
+
* (Console is now separate at top; nav rules are grouped under Edges)
|
|
802
|
+
*/
|
|
803
|
+
export function buildSourceInputSection(structure, showConsoleMenu) {
|
|
804
|
+
const details = makeDetails('Source Input', { summaryClass: 'dn-menu-summary-top' });
|
|
805
|
+
|
|
806
|
+
// Data
|
|
807
|
+
if (showConsoleMenu.data) {
|
|
808
|
+
details.appendChild(buildDataSection(showConsoleMenu.data));
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Props
|
|
812
|
+
const propsEl = buildPropsSection({
|
|
813
|
+
structure: showConsoleMenu.structure,
|
|
814
|
+
input: showConsoleMenu.input,
|
|
815
|
+
rendering: showConsoleMenu.rendering
|
|
816
|
+
});
|
|
817
|
+
if (propsEl) details.appendChild(propsEl);
|
|
818
|
+
|
|
819
|
+
// Dimensions
|
|
820
|
+
details.appendChild(buildDimensionsSection(structure.dimensions));
|
|
821
|
+
|
|
822
|
+
// Divisions
|
|
823
|
+
details.appendChild(buildDivisionsSection(structure.dimensions));
|
|
824
|
+
|
|
825
|
+
return details;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
// Simple section builders (Data, Props, Dimensions, Divisions)
|
|
830
|
+
// ---------------------------------------------------------------------------
|
|
831
|
+
|
|
832
|
+
function buildDataSection(data) {
|
|
833
|
+
const count = Array.isArray(data) ? data.length + ' items' : 'object';
|
|
834
|
+
const details = makeDetails('Data (' + count + ')');
|
|
835
|
+
|
|
836
|
+
const pre = document.createElement('pre');
|
|
837
|
+
pre.className = 'dn-menu-pre';
|
|
838
|
+
const code = document.createElement('code');
|
|
839
|
+
code.textContent = JSON.stringify(data, null, 2);
|
|
840
|
+
pre.appendChild(code);
|
|
841
|
+
details.appendChild(pre);
|
|
842
|
+
|
|
843
|
+
return details;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function buildPropsSection(propsConfig) {
|
|
847
|
+
const { structure: s, input: i, rendering: r } = propsConfig;
|
|
848
|
+
if (!s && !i && !r) return null;
|
|
849
|
+
|
|
850
|
+
const details = makeDetails('Props');
|
|
851
|
+
|
|
852
|
+
if (s) {
|
|
853
|
+
const sub = makeDetails('structure');
|
|
854
|
+
const pre = document.createElement('pre');
|
|
855
|
+
pre.className = 'dn-menu-pre';
|
|
856
|
+
pre.textContent = JSON.stringify(s, null, 2);
|
|
857
|
+
sub.appendChild(pre);
|
|
858
|
+
details.appendChild(sub);
|
|
859
|
+
}
|
|
860
|
+
if (i) {
|
|
861
|
+
const sub = makeDetails('input');
|
|
862
|
+
const pre = document.createElement('pre');
|
|
863
|
+
pre.className = 'dn-menu-pre';
|
|
864
|
+
pre.textContent = JSON.stringify(i, null, 2);
|
|
865
|
+
sub.appendChild(pre);
|
|
866
|
+
details.appendChild(sub);
|
|
867
|
+
}
|
|
868
|
+
if (r) {
|
|
869
|
+
const sub = makeDetails('rendering');
|
|
870
|
+
const pre = document.createElement('pre');
|
|
871
|
+
pre.className = 'dn-menu-pre';
|
|
872
|
+
pre.textContent = JSON.stringify(r, null, 2);
|
|
873
|
+
sub.appendChild(pre);
|
|
874
|
+
details.appendChild(sub);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
return details;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function buildDimensionsSection(dimensions) {
|
|
881
|
+
const dimKeys = dimensions ? Object.keys(dimensions) : [];
|
|
882
|
+
const details = makeDetails('Dimensions (' + dimKeys.length + ')');
|
|
883
|
+
|
|
884
|
+
dimKeys.forEach(key => {
|
|
885
|
+
const dim = dimensions[key];
|
|
886
|
+
const divCount = Object.keys(dim.divisions || {}).length;
|
|
887
|
+
const item = document.createElement('div');
|
|
888
|
+
item.className = 'dn-menu-info';
|
|
889
|
+
item.textContent = key + ' (' + dim.type + ', ' + divCount + ' div' + (divCount !== 1 ? 's' : '') + ')';
|
|
890
|
+
details.appendChild(item);
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
if (dimKeys.length === 0) {
|
|
894
|
+
const empty = document.createElement('div');
|
|
895
|
+
empty.className = 'dn-menu-info dn-menu-empty';
|
|
896
|
+
empty.textContent = '(none)';
|
|
897
|
+
details.appendChild(empty);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
return details;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function buildDivisionsSection(dimensions) {
|
|
904
|
+
const allDivisions = [];
|
|
905
|
+
if (dimensions) {
|
|
906
|
+
Object.keys(dimensions).forEach(dimKey => {
|
|
907
|
+
const dim = dimensions[dimKey];
|
|
908
|
+
Object.keys(dim.divisions || {}).forEach(divId => {
|
|
909
|
+
const div = dim.divisions[divId];
|
|
910
|
+
const count = Object.keys(div.values || {}).length;
|
|
911
|
+
allDivisions.push({ dimKey, divId, count });
|
|
912
|
+
});
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const details = makeDetails('Divisions (' + allDivisions.length + ')');
|
|
917
|
+
|
|
918
|
+
allDivisions.forEach(({ dimKey, divId, count }) => {
|
|
919
|
+
const item = document.createElement('div');
|
|
920
|
+
item.className = 'dn-menu-info';
|
|
921
|
+
item.textContent = dimKey + ': ' + divId + ' (' + count + ')';
|
|
922
|
+
details.appendChild(item);
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
if (allDivisions.length === 0) {
|
|
926
|
+
const empty = document.createElement('div');
|
|
927
|
+
empty.className = 'dn-menu-info dn-menu-empty';
|
|
928
|
+
empty.textContent = '(none)';
|
|
929
|
+
details.appendChild(empty);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
return details;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function truncate(str, max) {
|
|
936
|
+
if (str.length <= max) return str;
|
|
937
|
+
return str.slice(0, max - 1) + '\u2026';
|
|
938
|
+
}
|