yaml-flow 2.5.0 → 2.6.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/browser/card-compute.js +546 -0
- package/browser/live-cards.js +1381 -0
- package/browser/live-cards.schema.json +246 -0
- package/dist/card-compute/index.cjs +456 -0
- package/dist/card-compute/index.cjs.map +1 -0
- package/dist/card-compute/index.d.cts +85 -0
- package/dist/card-compute/index.d.ts +85 -0
- package/dist/card-compute/index.js +451 -0
- package/dist/card-compute/index.js.map +1 -0
- package/dist/index.cjs +454 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +454 -7
- package/dist/index.js.map +1 -1
- package/package.json +8 -2
- package/schema/live-cards.schema.json +246 -0
|
@@ -0,0 +1,1381 @@
|
|
|
1
|
+
// live-cards.js — LiveCards v3: Node-based Board/Canvas engine
|
|
2
|
+
//
|
|
3
|
+
// Schema: Each node has { id, type, meta, data, view?, source?, state, compute? }
|
|
4
|
+
// type "card" — renderable node with view.elements[]
|
|
5
|
+
// type "source" — data-only node (no view, shown as pill in canvas)
|
|
6
|
+
//
|
|
7
|
+
// Uses Bootstrap 5 for layout/forms, optional Chart.js for charts.
|
|
8
|
+
// Uses CardCompute (card-compute.js) for declarative compute expressions.
|
|
9
|
+
//
|
|
10
|
+
// API:
|
|
11
|
+
// const engine = LiveCard.init({ resolve, onPatch, onPatchState, onRefresh, onChat, markdown, sanitize, chartLib });
|
|
12
|
+
// engine.render(node, el, opts?) — render a card node into a DOM element
|
|
13
|
+
// engine.update(nodeId, patch) — in-place update (status, re-render)
|
|
14
|
+
// engine.destroy(nodeId) — tear down one node
|
|
15
|
+
// engine.destroyAll() — tear down all
|
|
16
|
+
// engine.notify(nodeId, data?) — signal change → downstream recompute
|
|
17
|
+
// engine.subscribe(nodeId, cb) — listen for changes; returns unsub fn
|
|
18
|
+
// engine.appendChatMessage(nodeId, role, text)
|
|
19
|
+
// engine.registerRenderer(name, fn)
|
|
20
|
+
//
|
|
21
|
+
// const board = LiveCard.Board(engine, el, { nodes, positions?, mode, canvas })
|
|
22
|
+
// board.setMode('board'|'canvas'), board.autoLayout(), board.add(node), board.remove(id)
|
|
23
|
+
|
|
24
|
+
// eslint-disable-next-line no-unused-vars
|
|
25
|
+
var LiveCard = (function () {
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
// ===========================================================================
|
|
29
|
+
// CSS injection (once)
|
|
30
|
+
// ===========================================================================
|
|
31
|
+
|
|
32
|
+
let _cssInjected = false;
|
|
33
|
+
function _injectCSS() {
|
|
34
|
+
if (_cssInjected) return;
|
|
35
|
+
_cssInjected = true;
|
|
36
|
+
const s = document.createElement('style');
|
|
37
|
+
s.textContent = `
|
|
38
|
+
.lc-card { position:relative; }
|
|
39
|
+
.lc-status-dot { display:inline-block; width:8px; height:8px; border-radius:50%; flex-shrink:0; }
|
|
40
|
+
.lc-metric-value { font-size:2rem; font-weight:700; line-height:1.2; }
|
|
41
|
+
.lc-chart-wrap { position:relative; min-height:200px; max-height:400px; }
|
|
42
|
+
.lc-chat-messages { max-height:200px; overflow-y:auto; }
|
|
43
|
+
.lc-chat-msg { padding:0.25rem 0.5rem; margin:0.25rem 0; border-radius:0.5rem; max-width:85%; }
|
|
44
|
+
.lc-chat-user { background:var(--bs-primary-bg-subtle,#cfe2ff); margin-left:auto; }
|
|
45
|
+
.lc-chat-assistant { background:var(--bs-light,#f8f9fa); }
|
|
46
|
+
.lc-alert-dot { display:inline-block; width:14px; height:14px; border-radius:50%; flex-shrink:0; }
|
|
47
|
+
.lc-alert-green { background:var(--bs-success,#198754); }
|
|
48
|
+
.lc-alert-amber { background:var(--bs-warning,#ffc107); }
|
|
49
|
+
.lc-alert-red { background:var(--bs-danger,#dc3545); }
|
|
50
|
+
.lc-todo-item { display:flex; align-items:center; gap:0.5rem; min-height:44px; padding:0.25rem 0; border-bottom:1px solid var(--bs-border-color-translucent,#dee2e6); }
|
|
51
|
+
.lc-todo-item:last-child { border-bottom:none; }
|
|
52
|
+
.lc-notes-preview { min-height:80px; }
|
|
53
|
+
.lc-source-pill { display:inline-flex; align-items:center; gap:0.5rem; padding:0.5rem 0.75rem; border-radius:2rem; font-size:0.8rem; background:var(--bs-light,#f8f9fa); border:1px solid var(--bs-border-color,#dee2e6); }
|
|
54
|
+
@media (max-width:576px) {
|
|
55
|
+
.lc-metric-value { font-size:1.5rem; }
|
|
56
|
+
.lc-chart-wrap { min-height:150px; }
|
|
57
|
+
.lc-chat-msg { max-width:95%; }
|
|
58
|
+
}
|
|
59
|
+
`;
|
|
60
|
+
document.head.appendChild(s);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ===========================================================================
|
|
64
|
+
// Global utilities
|
|
65
|
+
// ===========================================================================
|
|
66
|
+
|
|
67
|
+
const _escMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
68
|
+
function _esc(str) {
|
|
69
|
+
if (!str) return '';
|
|
70
|
+
return String(str).replace(/[&<>"']/g, ch => _escMap[ch]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function _deepGet(obj, path) {
|
|
74
|
+
if (!path || !obj) return undefined;
|
|
75
|
+
const parts = path.split('.');
|
|
76
|
+
let cur = obj;
|
|
77
|
+
for (let i = 0; i < parts.length; i++) {
|
|
78
|
+
if (cur == null) return undefined;
|
|
79
|
+
cur = cur[parts[i]];
|
|
80
|
+
}
|
|
81
|
+
return cur;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function _deepSet(obj, path, value) {
|
|
85
|
+
const parts = path.split('.');
|
|
86
|
+
let cur = obj;
|
|
87
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
88
|
+
if (cur[parts[i]] == null || typeof cur[parts[i]] !== 'object') cur[parts[i]] = {};
|
|
89
|
+
cur = cur[parts[i]];
|
|
90
|
+
}
|
|
91
|
+
cur[parts[parts.length - 1]] = value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function _statusDot(status) {
|
|
95
|
+
const colors = { fresh: 'var(--bs-success)', stale: 'var(--bs-warning)', error: 'var(--bs-danger)', loading: 'var(--bs-info)' };
|
|
96
|
+
return `<span class="lc-status-dot" style="background:${colors[status] || 'var(--bs-secondary)'}" title="${_esc(status || 'unknown')}"></span>`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function _timeAgo(iso) {
|
|
100
|
+
if (!iso) return '';
|
|
101
|
+
const d = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
|
102
|
+
if (isNaN(d) || d < 0) return '';
|
|
103
|
+
if (d < 60) return d + 's ago';
|
|
104
|
+
if (d < 3600) return Math.floor(d / 60) + 'm ago';
|
|
105
|
+
if (d < 86400) return Math.floor(d / 3600) + 'h ago';
|
|
106
|
+
return Math.floor(d / 86400) + 'd ago';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function _parseThreshold(expr) {
|
|
110
|
+
const m = String(expr).match(/^(<=?|>=?|===?)\s*(.+)$/);
|
|
111
|
+
return m ? { op: m[1], value: parseFloat(m[2]) } : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function _evalThreshold(value, expr) {
|
|
115
|
+
const t = _parseThreshold(expr);
|
|
116
|
+
if (!t || isNaN(t.value)) return false;
|
|
117
|
+
switch (t.op) {
|
|
118
|
+
case '<': return value < t.value;
|
|
119
|
+
case '<=': return value <= t.value;
|
|
120
|
+
case '>': return value > t.value;
|
|
121
|
+
case '>=': return value >= t.value;
|
|
122
|
+
case '=': case '==': case '===': return value === t.value;
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function _detectChartType(data) {
|
|
128
|
+
if (!data.length) return 'bar';
|
|
129
|
+
const s = data[0];
|
|
130
|
+
if (s.label !== undefined && s.value !== undefined && !s.x && !s.date) return 'pie';
|
|
131
|
+
if (s.x !== undefined || s.date !== undefined) return 'line';
|
|
132
|
+
return 'bar';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const _chartColors = ['#0d6efd','#198754','#ffc107','#dc3545','#6f42c1','#0dcaf0','#fd7e14','#20c997','#d63384','#6c757d'];
|
|
136
|
+
|
|
137
|
+
// ===========================================================================
|
|
138
|
+
// init — creates isolated engine instance
|
|
139
|
+
// ===========================================================================
|
|
140
|
+
|
|
141
|
+
function init(config) {
|
|
142
|
+
_injectCSS();
|
|
143
|
+
|
|
144
|
+
const cfg = {
|
|
145
|
+
resolve: config.resolve,
|
|
146
|
+
onPatch: config.onPatch || function () {},
|
|
147
|
+
onPatchState: config.onPatchState || function () {},
|
|
148
|
+
onRefresh: config.onRefresh || null,
|
|
149
|
+
onChat: config.onChat || null,
|
|
150
|
+
markdown: config.markdown || null,
|
|
151
|
+
sanitize: config.sanitize || null,
|
|
152
|
+
chartLib: config.chartLib || null,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const _cleanup = {}; // nodeId → { ac, timers, charts, unsubs }
|
|
156
|
+
const _subs = {}; // nodeId → Set<callback>
|
|
157
|
+
const _renderers = {}; // kind → fn
|
|
158
|
+
const _nodeEls = {}; // nodeId → { container, resultEl, uid }
|
|
159
|
+
|
|
160
|
+
// ---- Helpers ----
|
|
161
|
+
|
|
162
|
+
function _renderMd(text) {
|
|
163
|
+
if (!text) return '';
|
|
164
|
+
const html = cfg.markdown ? cfg.markdown(text) : _esc(text);
|
|
165
|
+
return cfg.sanitize ? cfg.sanitize(html) : html;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function _getCleanup(id) {
|
|
169
|
+
if (!_cleanup[id]) _cleanup[id] = { ac: new AbortController(), timers: [], charts: [], unsubs: [] };
|
|
170
|
+
return _cleanup[id];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function _runCompute(node) {
|
|
174
|
+
if (!node.compute) return;
|
|
175
|
+
if (typeof CardCompute !== 'undefined') {
|
|
176
|
+
try { CardCompute.run(node); }
|
|
177
|
+
catch (e) { console.error('LiveCard compute error', node.id, e); }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function _resolveBind(node, bind) {
|
|
182
|
+
if (!bind) return undefined;
|
|
183
|
+
return _deepGet(node, bind);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---- Pub/sub ----
|
|
187
|
+
|
|
188
|
+
function notify(nodeId, data) {
|
|
189
|
+
const cbs = _subs[nodeId];
|
|
190
|
+
if (cbs) cbs.forEach(cb => { try { cb(nodeId, data); } catch (e) { console.error('LiveCard notify error', e); } });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function subscribe(nodeId, cb) {
|
|
194
|
+
if (!_subs[nodeId]) _subs[nodeId] = new Set();
|
|
195
|
+
_subs[nodeId].add(cb);
|
|
196
|
+
return () => _subs[nodeId].delete(cb);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function _autoSubscribe(node) {
|
|
200
|
+
const requires = (node.data && node.data.requires) || [];
|
|
201
|
+
if (!requires.length) return;
|
|
202
|
+
const cleanup = _getCleanup(node.id);
|
|
203
|
+
cleanup.unsubs = requires.map(upId => subscribe(upId, () => {
|
|
204
|
+
const info = _nodeEls[node.id];
|
|
205
|
+
if (!info || !info.resultEl) return;
|
|
206
|
+
const updated = cfg.resolve(node.id);
|
|
207
|
+
if (!updated) return;
|
|
208
|
+
_runCompute(updated);
|
|
209
|
+
_renderElements(updated, info.resultEl);
|
|
210
|
+
notify(node.id);
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ===========================================================================
|
|
215
|
+
// Element renderers — each: (data, el, elemDef, node)
|
|
216
|
+
// ===========================================================================
|
|
217
|
+
|
|
218
|
+
// ---- table ----
|
|
219
|
+
|
|
220
|
+
function _renderTable(data, el, elemDef, node) {
|
|
221
|
+
const ed = elemDef.data || {};
|
|
222
|
+
if (!Array.isArray(data) || !data.length) {
|
|
223
|
+
el.innerHTML = `<p class="text-muted small">${_esc(ed.placeholder || 'No data')}</p>`;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const limit = Math.min(data.length, ed.maxRows || 200);
|
|
228
|
+
const colSet = new Set();
|
|
229
|
+
for (let i = 0; i < Math.min(data.length, limit); i++) Object.keys(data[i]).forEach(k => colSet.add(k));
|
|
230
|
+
const cols = (ed.columns && ed.columns.length) ? ed.columns : [...colSet];
|
|
231
|
+
const sortable = ed.sortable !== false;
|
|
232
|
+
|
|
233
|
+
let sortCol = null, sortDir = 'asc';
|
|
234
|
+
const cleanup = _getCleanup(node.id);
|
|
235
|
+
|
|
236
|
+
function build() {
|
|
237
|
+
let rows = data.slice(0, limit);
|
|
238
|
+
if (sortCol !== null && sortable) {
|
|
239
|
+
rows = rows.slice().sort((a, b) => {
|
|
240
|
+
const av = a[cols[sortCol]], bv = b[cols[sortCol]];
|
|
241
|
+
if (av == null) return 1; if (bv == null) return -1;
|
|
242
|
+
if (typeof av === 'number' && typeof bv === 'number') return sortDir === 'asc' ? av - bv : bv - av;
|
|
243
|
+
return sortDir === 'asc' ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let h = '<div class="table-responsive"><table class="table table-sm table-striped table-hover mb-0"><thead><tr>';
|
|
248
|
+
cols.forEach((c, i) => {
|
|
249
|
+
const arrow = sortCol === i ? (sortDir === 'asc' ? ' ↑' : ' ↓') : '';
|
|
250
|
+
const cursor = sortable ? ' style="cursor:pointer"' : '';
|
|
251
|
+
h += `<th class="small text-nowrap"${cursor} data-col="${i}">${_esc(c)}${arrow}</th>`;
|
|
252
|
+
});
|
|
253
|
+
h += '</tr></thead><tbody>';
|
|
254
|
+
rows.forEach(row => {
|
|
255
|
+
h += '<tr>';
|
|
256
|
+
cols.forEach(c => { const v = row[c]; h += `<td class="small">${_esc(v != null ? String(v) : '')}</td>`; });
|
|
257
|
+
h += '</tr>';
|
|
258
|
+
});
|
|
259
|
+
h += '</tbody></table></div>';
|
|
260
|
+
if (data.length > limit) h += `<p class="text-muted small mt-1">Showing ${limit} of ${data.length} rows</p>`;
|
|
261
|
+
el.innerHTML = h;
|
|
262
|
+
|
|
263
|
+
if (sortable) {
|
|
264
|
+
el.querySelectorAll('th[data-col]').forEach(th => {
|
|
265
|
+
th.addEventListener('click', () => {
|
|
266
|
+
const c = parseInt(th.dataset.col);
|
|
267
|
+
if (sortCol === c) sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
|
268
|
+
else { sortCol = c; sortDir = 'asc'; }
|
|
269
|
+
build();
|
|
270
|
+
}, { signal: cleanup.ac.signal });
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
build();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---- filter ----
|
|
278
|
+
|
|
279
|
+
function _renderFilter(data, el, elemDef, node) {
|
|
280
|
+
const cleanup = _getCleanup(node.id);
|
|
281
|
+
const signal = cleanup.ac.signal;
|
|
282
|
+
const ed = elemDef.data || {};
|
|
283
|
+
const writeTo = ed.writeTo;
|
|
284
|
+
const values = writeTo ? (_resolveBind(node, writeTo) || {}) : {};
|
|
285
|
+
const fields = (ed.fields && ed.fields.properties) || {};
|
|
286
|
+
|
|
287
|
+
const keys = (data && typeof data === 'object' && !Array.isArray(data)) ? Object.keys(data) : [];
|
|
288
|
+
if (!keys.length) { el.innerHTML = '<p class="text-muted small">No filter options</p>'; return; }
|
|
289
|
+
|
|
290
|
+
let h = '<div class="row g-2">';
|
|
291
|
+
keys.forEach(key => {
|
|
292
|
+
const options = Array.isArray(data[key]) ? data[key] : [];
|
|
293
|
+
const label = (fields[key] && fields[key].title) || key;
|
|
294
|
+
h += `<div class="col-12 col-sm-6 col-md-4"><label class="form-label small mb-1">${_esc(label)}</label>`;
|
|
295
|
+
h += `<select class="form-select form-select-sm" data-fk="${_esc(key)}"><option value="">All</option>`;
|
|
296
|
+
options.forEach(opt => {
|
|
297
|
+
const sel = String(opt) === String(values[key] || '') ? ' selected' : '';
|
|
298
|
+
h += `<option value="${_esc(String(opt))}"${sel}>${_esc(String(opt))}</option>`;
|
|
299
|
+
});
|
|
300
|
+
h += '</select></div>';
|
|
301
|
+
});
|
|
302
|
+
h += '</div>';
|
|
303
|
+
el.innerHTML = h;
|
|
304
|
+
|
|
305
|
+
el.querySelectorAll('select[data-fk]').forEach(sel => {
|
|
306
|
+
sel.addEventListener('change', () => {
|
|
307
|
+
const nv = {};
|
|
308
|
+
el.querySelectorAll('select[data-fk]').forEach(s => { if (s.value) nv[s.dataset.fk] = s.value; });
|
|
309
|
+
if (writeTo) _deepSet(node, writeTo, nv);
|
|
310
|
+
cfg.onPatchState(node.id, { fieldValues: nv });
|
|
311
|
+
notify(node.id, nv);
|
|
312
|
+
}, { signal });
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---- metric ----
|
|
317
|
+
|
|
318
|
+
function _renderMetric(data, el, elemDef) {
|
|
319
|
+
let title = elemDef.label || '', value = '—', detail = '';
|
|
320
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
321
|
+
title = data.title || data.label || data.metric || title;
|
|
322
|
+
value = data.value != null ? String(data.value) : '—';
|
|
323
|
+
detail = data.detail || '';
|
|
324
|
+
} else if (data != null) {
|
|
325
|
+
value = String(data);
|
|
326
|
+
}
|
|
327
|
+
let h = '<div class="text-center py-2">';
|
|
328
|
+
if (title) h += `<div class="text-muted small">${_esc(title)}</div>`;
|
|
329
|
+
h += `<div class="lc-metric-value">${_esc(value)}</div>`;
|
|
330
|
+
if (detail) h += `<div class="small mt-1">${_renderMd(detail)}</div>`;
|
|
331
|
+
h += '</div>';
|
|
332
|
+
el.innerHTML = h;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---- list ----
|
|
336
|
+
|
|
337
|
+
function _renderList(data, el, elemDef, node) {
|
|
338
|
+
const ed = elemDef.data || {};
|
|
339
|
+
if (data == null) { el.innerHTML = ''; return; }
|
|
340
|
+
|
|
341
|
+
if (typeof data === 'object' && !Array.isArray(data)) {
|
|
342
|
+
let h = '<dl class="row mb-0">';
|
|
343
|
+
Object.entries(data).forEach(([k, v]) => {
|
|
344
|
+
h += `<dt class="col-sm-5 small text-muted text-truncate">${_esc(k)}</dt>`;
|
|
345
|
+
h += `<dd class="col-sm-7 small mb-1">${_esc(v != null ? String(v) : '—')}</dd>`;
|
|
346
|
+
});
|
|
347
|
+
el.innerHTML = h + '</dl>';
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (Array.isArray(data)) {
|
|
352
|
+
if (!data.length) { el.innerHTML = `<p class="text-muted small">${_esc(ed.placeholder || 'Empty')}</p>`; return; }
|
|
353
|
+
if (typeof data[0] === 'string' || typeof data[0] === 'number') {
|
|
354
|
+
const max = ed.maxRows || data.length;
|
|
355
|
+
let h = '<ul class="list-unstyled mb-0">';
|
|
356
|
+
data.slice(0, max).forEach(item => { h += `<li class="small mb-1">• ${_esc(String(item))}</li>`; });
|
|
357
|
+
el.innerHTML = h + '</ul>';
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
_renderTable(data, el, elemDef, node);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
el.innerHTML = `<div class="small">${_renderMd(String(data))}</div>`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---- chart ----
|
|
368
|
+
|
|
369
|
+
function _renderChart(data, el, elemDef, node) {
|
|
370
|
+
const ed = elemDef.data || {};
|
|
371
|
+
if (!cfg.chartLib) { _renderTable(data, el, elemDef, node); return; }
|
|
372
|
+
if (!Array.isArray(data) || !data.length) { el.innerHTML = '<p class="text-muted small">No chart data</p>'; return; }
|
|
373
|
+
|
|
374
|
+
const cleanup = _getCleanup(node.id);
|
|
375
|
+
const chartKey = elemDef.id || ('chart-' + Math.random().toString(36).slice(2, 8));
|
|
376
|
+
const existingIdx = cleanup.charts.findIndex(c => c.key === chartKey);
|
|
377
|
+
if (existingIdx >= 0) { cleanup.charts[existingIdx].inst.destroy(); cleanup.charts.splice(existingIdx, 1); }
|
|
378
|
+
|
|
379
|
+
const type = ed.chartType || _detectChartType(data);
|
|
380
|
+
el.innerHTML = '<div class="lc-chart-wrap"><canvas></canvas></div>';
|
|
381
|
+
const ctx = el.querySelector('canvas').getContext('2d');
|
|
382
|
+
|
|
383
|
+
let chartCfg;
|
|
384
|
+
if (type === 'pie' || type === 'doughnut') {
|
|
385
|
+
chartCfg = {
|
|
386
|
+
type,
|
|
387
|
+
data: {
|
|
388
|
+
labels: data.map(r => r.label || r.name || ''),
|
|
389
|
+
datasets: [{ data: data.map(r => r.value || 0), backgroundColor: _chartColors.slice(0, data.length) }],
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
} else if (type === 'line') {
|
|
393
|
+
chartCfg = {
|
|
394
|
+
type: 'line',
|
|
395
|
+
data: {
|
|
396
|
+
labels: data.map(r => r.x || r.date || r.label || ''),
|
|
397
|
+
datasets: [{ label: elemDef.label || 'Value', data: data.map(r => r.y || r.value || 0), borderColor: _chartColors[0], tension: 0.3, fill: false }],
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
} else {
|
|
401
|
+
const numKeys = Object.keys(data[0]).filter(k => typeof data[0][k] === 'number');
|
|
402
|
+
const labelKey = Object.keys(data[0]).find(k => typeof data[0][k] === 'string');
|
|
403
|
+
chartCfg = {
|
|
404
|
+
type: 'bar',
|
|
405
|
+
data: {
|
|
406
|
+
labels: data.map(r => r.label || r.name || (labelKey ? r[labelKey] : '')),
|
|
407
|
+
datasets: numKeys.map((k, i) => ({ label: k, data: data.map(r => r[k] || 0), backgroundColor: _chartColors[i % _chartColors.length] })),
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
chartCfg.options = Object.assign({
|
|
412
|
+
responsive: true,
|
|
413
|
+
maintainAspectRatio: false,
|
|
414
|
+
plugins: { legend: { position: data.length > 8 ? 'bottom' : 'right' } },
|
|
415
|
+
}, ed.chartOptions || {});
|
|
416
|
+
|
|
417
|
+
cleanup.charts.push({ key: chartKey, inst: new cfg.chartLib(ctx, chartCfg) });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---- form ----
|
|
421
|
+
|
|
422
|
+
function _renderForm(data, el, elemDef, node) {
|
|
423
|
+
const cleanup = _getCleanup(node.id);
|
|
424
|
+
const signal = cleanup.ac.signal;
|
|
425
|
+
const ed = elemDef.data || {};
|
|
426
|
+
const writeTo = ed.writeTo;
|
|
427
|
+
const schema = ed.fields || {};
|
|
428
|
+
const props = schema.properties || {};
|
|
429
|
+
const required = schema.required || [];
|
|
430
|
+
const values = writeTo ? (_resolveBind(node, writeTo) || {}) : (data && typeof data === 'object' ? data : {});
|
|
431
|
+
|
|
432
|
+
const form = document.createElement('form');
|
|
433
|
+
form.className = 'row g-2';
|
|
434
|
+
form.noValidate = true;
|
|
435
|
+
|
|
436
|
+
Object.keys(props).forEach(key => {
|
|
437
|
+
const prop = props[key];
|
|
438
|
+
const isReq = required.indexOf(key) >= 0;
|
|
439
|
+
const compact = ['number', 'integer', 'boolean'].includes(prop.type) || prop.enum || prop.format === 'date';
|
|
440
|
+
const col = document.createElement('div');
|
|
441
|
+
col.className = compact ? 'col-12 col-md-6' : 'col-12';
|
|
442
|
+
|
|
443
|
+
let input;
|
|
444
|
+
if (prop.type === 'boolean') {
|
|
445
|
+
const wrap = document.createElement('div');
|
|
446
|
+
wrap.className = 'form-check mt-3';
|
|
447
|
+
input = document.createElement('input');
|
|
448
|
+
input.type = 'checkbox'; input.className = 'form-check-input';
|
|
449
|
+
const lbl = document.createElement('label');
|
|
450
|
+
lbl.className = 'form-check-label small'; lbl.textContent = prop.title || key;
|
|
451
|
+
wrap.appendChild(input); wrap.appendChild(lbl); col.appendChild(wrap);
|
|
452
|
+
} else {
|
|
453
|
+
const lbl = document.createElement('label');
|
|
454
|
+
lbl.className = 'form-label small mb-1'; lbl.textContent = prop.title || key;
|
|
455
|
+
col.appendChild(lbl);
|
|
456
|
+
|
|
457
|
+
if (prop.enum) {
|
|
458
|
+
input = document.createElement('select');
|
|
459
|
+
input.className = 'form-select form-select-sm';
|
|
460
|
+
prop.enum.forEach(o => { const opt = document.createElement('option'); opt.value = o; opt.textContent = o; input.appendChild(opt); });
|
|
461
|
+
} else if (prop.type === 'number' || prop.type === 'integer') {
|
|
462
|
+
input = document.createElement('input');
|
|
463
|
+
input.type = 'number'; input.className = 'form-control form-control-sm';
|
|
464
|
+
if (prop.minimum != null) input.min = prop.minimum;
|
|
465
|
+
if (prop.maximum != null) input.max = prop.maximum;
|
|
466
|
+
if (prop.type === 'integer') input.step = '1';
|
|
467
|
+
} else if (prop.format === 'date') {
|
|
468
|
+
input = document.createElement('input');
|
|
469
|
+
input.type = 'date'; input.className = 'form-control form-control-sm';
|
|
470
|
+
} else {
|
|
471
|
+
input = document.createElement('input');
|
|
472
|
+
input.type = 'text'; input.className = 'form-control form-control-sm';
|
|
473
|
+
if (prop.placeholder) input.placeholder = prop.placeholder;
|
|
474
|
+
}
|
|
475
|
+
col.appendChild(input);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
input.dataset.key = key;
|
|
479
|
+
if (isReq) input.required = true;
|
|
480
|
+
if (values[key] != null) {
|
|
481
|
+
if (prop.type === 'boolean') input.checked = !!values[key];
|
|
482
|
+
else if (prop.format === 'date') input.value = String(values[key]).slice(0, 10);
|
|
483
|
+
else input.value = values[key];
|
|
484
|
+
}
|
|
485
|
+
form.appendChild(col);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
const btnCol = document.createElement('div');
|
|
489
|
+
btnCol.className = 'col-12 mt-1';
|
|
490
|
+
const btn = document.createElement('button');
|
|
491
|
+
btn.type = 'submit'; btn.className = 'btn btn-sm btn-primary'; btn.textContent = 'Submit';
|
|
492
|
+
btnCol.appendChild(btn);
|
|
493
|
+
form.appendChild(btnCol);
|
|
494
|
+
|
|
495
|
+
el.innerHTML = '';
|
|
496
|
+
el.appendChild(form);
|
|
497
|
+
|
|
498
|
+
form.addEventListener('submit', e => {
|
|
499
|
+
e.preventDefault();
|
|
500
|
+
if (!form.checkValidity()) { form.classList.add('was-validated'); return; }
|
|
501
|
+
const vals = {};
|
|
502
|
+
form.querySelectorAll('[data-key]').forEach(inp => {
|
|
503
|
+
const k = inp.dataset.key, p = props[k];
|
|
504
|
+
if (p.type === 'boolean') vals[k] = inp.checked;
|
|
505
|
+
else if (p.type === 'number' || p.type === 'integer') vals[k] = inp.value ? parseFloat(inp.value) : 0;
|
|
506
|
+
else vals[k] = inp.value;
|
|
507
|
+
});
|
|
508
|
+
if (writeTo) _deepSet(node, writeTo, vals);
|
|
509
|
+
cfg.onPatchState(node.id, { fieldValues: vals });
|
|
510
|
+
notify(node.id, vals);
|
|
511
|
+
btn.textContent = '✓ Saved';
|
|
512
|
+
setTimeout(() => { btn.textContent = 'Submit'; }, 1500);
|
|
513
|
+
}, { signal });
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ---- notes ----
|
|
517
|
+
|
|
518
|
+
function _renderNotes(data, el, elemDef, node) {
|
|
519
|
+
const cleanup = _getCleanup(node.id);
|
|
520
|
+
const signal = cleanup.ac.signal;
|
|
521
|
+
const ed = elemDef.data || {};
|
|
522
|
+
const writeTo = ed.writeTo;
|
|
523
|
+
const content = typeof data === 'string' ? data : '';
|
|
524
|
+
|
|
525
|
+
el.innerHTML = `
|
|
526
|
+
<div class="btn-group btn-group-sm mb-2" role="group">
|
|
527
|
+
<button class="btn btn-outline-secondary active lc-n-edit" type="button">Edit</button>
|
|
528
|
+
<button class="btn btn-outline-secondary lc-n-preview" type="button">Preview</button>
|
|
529
|
+
</div>
|
|
530
|
+
<textarea class="form-control form-control-sm lc-notes-textarea" rows="8" placeholder="Write markdown...">${_esc(content)}</textarea>
|
|
531
|
+
<div class="lc-notes-preview d-none border rounded p-2 small"></div>`;
|
|
532
|
+
|
|
533
|
+
const textarea = el.querySelector('.lc-notes-textarea');
|
|
534
|
+
const preview = el.querySelector('.lc-notes-preview');
|
|
535
|
+
const editBtn = el.querySelector('.lc-n-edit');
|
|
536
|
+
const previewBtn = el.querySelector('.lc-n-preview');
|
|
537
|
+
|
|
538
|
+
editBtn.addEventListener('click', () => {
|
|
539
|
+
textarea.classList.remove('d-none'); preview.classList.add('d-none');
|
|
540
|
+
editBtn.classList.add('active'); previewBtn.classList.remove('active');
|
|
541
|
+
}, { signal });
|
|
542
|
+
previewBtn.addEventListener('click', () => {
|
|
543
|
+
preview.innerHTML = _renderMd(textarea.value);
|
|
544
|
+
textarea.classList.add('d-none'); preview.classList.remove('d-none');
|
|
545
|
+
previewBtn.classList.add('active'); editBtn.classList.remove('active');
|
|
546
|
+
}, { signal });
|
|
547
|
+
|
|
548
|
+
let timer;
|
|
549
|
+
textarea.addEventListener('input', () => {
|
|
550
|
+
clearTimeout(timer);
|
|
551
|
+
timer = setTimeout(() => {
|
|
552
|
+
if (writeTo) _deepSet(node, writeTo, textarea.value);
|
|
553
|
+
cfg.onPatchState(node.id, { notes: textarea.value });
|
|
554
|
+
}, 800);
|
|
555
|
+
cleanup.timers.push(timer);
|
|
556
|
+
}, { signal });
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ---- todo ----
|
|
560
|
+
|
|
561
|
+
function _renderTodo(data, el, elemDef, node) {
|
|
562
|
+
const cleanup = _getCleanup(node.id);
|
|
563
|
+
const signal = cleanup.ac.signal;
|
|
564
|
+
const ed = elemDef.data || {};
|
|
565
|
+
const writeTo = ed.writeTo;
|
|
566
|
+
const items = Array.isArray(data) ? data : [];
|
|
567
|
+
|
|
568
|
+
function save() {
|
|
569
|
+
if (writeTo) _deepSet(node, writeTo, items);
|
|
570
|
+
cfg.onPatchState(node.id, { items });
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function build() {
|
|
574
|
+
let h = '<div class="lc-todo-list">';
|
|
575
|
+
items.forEach((item, i) => {
|
|
576
|
+
const chk = item.done ? ' checked' : '';
|
|
577
|
+
const strike = item.done ? ' text-decoration-line-through text-muted' : '';
|
|
578
|
+
h += `<div class="lc-todo-item">`;
|
|
579
|
+
h += `<input class="form-check-input flex-shrink-0" type="checkbox"${chk} data-idx="${i}">`;
|
|
580
|
+
h += `<span class="small flex-grow-1${strike}">${_esc(item.text)}</span>`;
|
|
581
|
+
h += `<button class="btn btn-sm btn-link text-danger p-0" data-rm="${i}" title="Remove">×</button></div>`;
|
|
582
|
+
});
|
|
583
|
+
h += '</div>';
|
|
584
|
+
h += '<div class="input-group input-group-sm mt-2"><input type="text" class="form-control" placeholder="Add item...">';
|
|
585
|
+
h += '<button class="btn btn-outline-secondary lc-todo-add">+</button></div>';
|
|
586
|
+
el.innerHTML = h;
|
|
587
|
+
|
|
588
|
+
el.querySelectorAll('input[data-idx]').forEach(cb => {
|
|
589
|
+
cb.addEventListener('change', () => { items[parseInt(cb.dataset.idx)].done = cb.checked; save(); build(); }, { signal });
|
|
590
|
+
});
|
|
591
|
+
el.querySelectorAll('[data-rm]').forEach(btn => {
|
|
592
|
+
btn.addEventListener('click', () => { items.splice(parseInt(btn.dataset.rm), 1); save(); build(); }, { signal });
|
|
593
|
+
});
|
|
594
|
+
const addInput = el.querySelector('.input-group input');
|
|
595
|
+
const addBtn = el.querySelector('.lc-todo-add');
|
|
596
|
+
const addItem = () => { const t = addInput.value.trim(); if (!t) return; items.push({ text: t, done: false }); save(); build(); };
|
|
597
|
+
addBtn.addEventListener('click', addItem, { signal });
|
|
598
|
+
addInput.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); addItem(); } }, { signal });
|
|
599
|
+
}
|
|
600
|
+
build();
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ---- alert ----
|
|
604
|
+
|
|
605
|
+
function _renderAlert(data, el, elemDef) {
|
|
606
|
+
const ed = elemDef.data || {};
|
|
607
|
+
const thresholds = ed.thresholds || {};
|
|
608
|
+
const value = typeof data === 'number' ? data : (data && data.value != null ? data.value : null);
|
|
609
|
+
|
|
610
|
+
let level = 'unknown', color = 'secondary';
|
|
611
|
+
if (value != null) {
|
|
612
|
+
if (thresholds.green && _evalThreshold(value, thresholds.green)) { level = 'green'; color = 'success'; }
|
|
613
|
+
else if (thresholds.amber && _evalThreshold(value, thresholds.amber)) { level = 'amber'; color = 'warning'; }
|
|
614
|
+
else { level = 'red'; color = 'danger'; }
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
el.innerHTML = `
|
|
618
|
+
<div class="d-flex align-items-center gap-3 py-2">
|
|
619
|
+
<span class="lc-alert-dot lc-alert-${level}"></span>
|
|
620
|
+
<div class="flex-grow-1">
|
|
621
|
+
<div class="fw-bold">${value != null ? _esc(String(value)) : '—'}</div>
|
|
622
|
+
${elemDef.label ? `<div class="text-muted small">${_esc(elemDef.label)}</div>` : ''}
|
|
623
|
+
</div>
|
|
624
|
+
<span class="badge bg-${color} fs-6">${_esc(level)}</span>
|
|
625
|
+
</div>`;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ---- narrative ----
|
|
629
|
+
|
|
630
|
+
function _renderNarrative(data, el) {
|
|
631
|
+
const text = typeof data === 'string' ? data : (data && data.text ? data.text : '');
|
|
632
|
+
if (!text) { el.innerHTML = '<p class="text-muted small fst-italic">No narrative yet. Click refresh to generate.</p>'; return; }
|
|
633
|
+
el.innerHTML = `<div class="small">${_renderMd(text)}</div>`;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ---- badge ----
|
|
637
|
+
|
|
638
|
+
function _renderBadge(data, el, elemDef) {
|
|
639
|
+
const ed = elemDef.data || {};
|
|
640
|
+
const map = ed.colorMap || {};
|
|
641
|
+
const val = data != null ? String(data) : '';
|
|
642
|
+
const bsMap = { green: 'success', amber: 'warning', red: 'danger', blue: 'primary' };
|
|
643
|
+
const bs = bsMap[map[val]] || map[val] || 'secondary';
|
|
644
|
+
el.innerHTML = `<span class="badge bg-${_esc(bs)}">${_esc(val)}</span>`;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// ---- text ----
|
|
648
|
+
|
|
649
|
+
function _renderText(data, el, elemDef) {
|
|
650
|
+
const ed = elemDef.data || {};
|
|
651
|
+
const style = ed.style || 'default';
|
|
652
|
+
const tag = style === 'heading' ? 'h4' : 'div';
|
|
653
|
+
const cls = style === 'muted' ? 'text-muted small' : (style === 'heading' ? 'fw-bold' : 'small');
|
|
654
|
+
el.innerHTML = `<${tag} class="${cls}">${_esc(data != null ? String(data) : '')}</${tag}>`;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ---- markdown ----
|
|
658
|
+
|
|
659
|
+
function _renderMarkdown(data, el) {
|
|
660
|
+
let text = '';
|
|
661
|
+
if (typeof data === 'string') text = data;
|
|
662
|
+
else if (data && typeof data === 'object' && data.text) text = data.text;
|
|
663
|
+
else if (data != null) text = JSON.stringify(data, null, 2);
|
|
664
|
+
el.innerHTML = text ? _renderMd(text) : '';
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ---- custom (fallback to JSON) ----
|
|
668
|
+
|
|
669
|
+
function _renderCustom(data, el) {
|
|
670
|
+
if (data == null) { el.innerHTML = ''; return; }
|
|
671
|
+
el.innerHTML = `<pre class="small mb-0">${_esc(JSON.stringify(data, null, 2))}</pre>`;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ---- Register built-in renderers ----
|
|
675
|
+
|
|
676
|
+
_renderers.table = _renderTable;
|
|
677
|
+
_renderers.filter = _renderFilter;
|
|
678
|
+
_renderers.metric = _renderMetric;
|
|
679
|
+
_renderers.list = _renderList;
|
|
680
|
+
_renderers.chart = _renderChart;
|
|
681
|
+
_renderers.form = _renderForm;
|
|
682
|
+
_renderers.notes = _renderNotes;
|
|
683
|
+
_renderers.todo = _renderTodo;
|
|
684
|
+
_renderers.alert = _renderAlert;
|
|
685
|
+
_renderers.narrative = _renderNarrative;
|
|
686
|
+
_renderers.badge = _renderBadge;
|
|
687
|
+
_renderers.text = _renderText;
|
|
688
|
+
_renderers.markdown = _renderMarkdown;
|
|
689
|
+
_renderers.custom = _renderCustom;
|
|
690
|
+
|
|
691
|
+
// ===========================================================================
|
|
692
|
+
// _renderElements — render all view.elements for a card node
|
|
693
|
+
// ===========================================================================
|
|
694
|
+
|
|
695
|
+
function _renderElements(node, containerEl) {
|
|
696
|
+
const view = node.view;
|
|
697
|
+
if (!view || !Array.isArray(view.elements)) { containerEl.innerHTML = ''; return; }
|
|
698
|
+
|
|
699
|
+
const container = document.createElement('div');
|
|
700
|
+
container.className = 'row g-2';
|
|
701
|
+
|
|
702
|
+
view.elements.forEach(elemDef => {
|
|
703
|
+
// Visibility gate
|
|
704
|
+
if (elemDef.visible) {
|
|
705
|
+
const vis = _resolveBind(node, elemDef.visible);
|
|
706
|
+
if (!vis) return;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const data = elemDef.data && elemDef.data.bind ? _resolveBind(node, elemDef.data.bind) : undefined;
|
|
710
|
+
const col = document.createElement('div');
|
|
711
|
+
col.className = elemDef.className || 'col-12';
|
|
712
|
+
|
|
713
|
+
// Element label (except metric which handles its own)
|
|
714
|
+
if (elemDef.label && elemDef.kind !== 'metric' && elemDef.kind !== 'alert') {
|
|
715
|
+
const label = document.createElement('div');
|
|
716
|
+
label.className = 'small text-muted fw-medium mb-1';
|
|
717
|
+
label.textContent = elemDef.label;
|
|
718
|
+
col.appendChild(label);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const inner = document.createElement('div');
|
|
722
|
+
col.appendChild(inner);
|
|
723
|
+
|
|
724
|
+
const renderer = _renderers[elemDef.kind] || _renderers.custom;
|
|
725
|
+
try {
|
|
726
|
+
renderer(data, inner, elemDef, node);
|
|
727
|
+
} catch (e) {
|
|
728
|
+
console.error('LiveCard render error', node.id, elemDef.kind, e);
|
|
729
|
+
inner.innerHTML = `<div class="text-danger small">Render error: ${_esc(e.message)}</div>`;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
container.appendChild(col);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
containerEl.innerHTML = '';
|
|
736
|
+
containerEl.appendChild(container);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ===========================================================================
|
|
740
|
+
// Core render
|
|
741
|
+
// ===========================================================================
|
|
742
|
+
|
|
743
|
+
function render(node, containerEl, opts) {
|
|
744
|
+
opts = opts || {};
|
|
745
|
+
destroy(node.id);
|
|
746
|
+
|
|
747
|
+
const cleanup = _getCleanup(node.id);
|
|
748
|
+
const signal = cleanup.ac.signal;
|
|
749
|
+
const uid = 'lc-' + (node.id || 'x');
|
|
750
|
+
const features = (node.view && node.view.features) || {};
|
|
751
|
+
|
|
752
|
+
// Run compute before render
|
|
753
|
+
_runCompute(node);
|
|
754
|
+
|
|
755
|
+
let h = `<div class="lc-card" id="${uid}">`;
|
|
756
|
+
|
|
757
|
+
// Header bar: status dot + time-ago + refresh button
|
|
758
|
+
const showRefresh = features.refresh !== false && cfg.onRefresh;
|
|
759
|
+
h += `<div class="d-flex align-items-center gap-2 mb-2">`;
|
|
760
|
+
h += _statusDot(node.state && node.state.status);
|
|
761
|
+
h += `<span class="text-muted small">${_timeAgo(node.state && node.state.lastRun)}</span>`;
|
|
762
|
+
if (node.state && node.state.status === 'error' && node.state.error) {
|
|
763
|
+
h += `<span class="badge bg-danger small" title="${_esc(node.state.error)}">Error</span>`;
|
|
764
|
+
}
|
|
765
|
+
if (showRefresh) {
|
|
766
|
+
h += `<button class="btn btn-sm btn-outline-secondary ms-auto" id="${uid}-refresh" title="Refresh">`;
|
|
767
|
+
h += '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>';
|
|
768
|
+
h += '</button>';
|
|
769
|
+
}
|
|
770
|
+
h += '</div>';
|
|
771
|
+
|
|
772
|
+
// Elements area
|
|
773
|
+
h += `<div class="lc-result" id="${uid}-result"></div>`;
|
|
774
|
+
|
|
775
|
+
// Notes section (feature toggle)
|
|
776
|
+
if (features.notes && opts.showNotes !== false) {
|
|
777
|
+
h += `<details class="mt-2"><summary class="small fw-medium">Notes</summary>`;
|
|
778
|
+
h += `<textarea class="form-control form-control-sm mt-1" id="${uid}-notes" rows="3" placeholder="Add notes...">${_esc((node.state && node.state._notes) || '')}</textarea></details>`;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Chat section (feature toggle)
|
|
782
|
+
if (features.chat && cfg.onChat && opts.showChat !== false) {
|
|
783
|
+
h += `<details class="mt-2"><summary class="small fw-medium">Chat</summary>`;
|
|
784
|
+
h += `<div class="lc-chat-messages" id="${uid}-chat"></div>`;
|
|
785
|
+
h += `<div class="input-group input-group-sm mt-1">`;
|
|
786
|
+
h += `<input type="text" class="form-control" id="${uid}-chatInput" placeholder="Ask about this card...">`;
|
|
787
|
+
h += `<button class="btn btn-outline-primary" id="${uid}-chatSend">`;
|
|
788
|
+
h += '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
|
|
789
|
+
h += '</button></div></details>';
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
h += '</div>';
|
|
793
|
+
containerEl.innerHTML = h;
|
|
794
|
+
|
|
795
|
+
// ---- Render elements ----
|
|
796
|
+
const resultEl = document.getElementById(uid + '-result');
|
|
797
|
+
_nodeEls[node.id] = { container: containerEl, resultEl, uid };
|
|
798
|
+
|
|
799
|
+
if (node.state && node.state.status === 'loading') {
|
|
800
|
+
resultEl.innerHTML = '<div class="d-flex align-items-center gap-2"><span class="spinner-border spinner-border-sm text-muted"></span><span class="text-muted small">Loading…</span></div>';
|
|
801
|
+
} else if (node.state && node.state.status === 'error' && node.state.error) {
|
|
802
|
+
resultEl.innerHTML = `<div class="text-danger small fw-semibold">Refresh failed</div><pre class="text-muted small mt-1" style="white-space:pre-wrap">${_esc(node.state.error)}</pre>`;
|
|
803
|
+
} else {
|
|
804
|
+
_renderElements(node, resultEl);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// ---- Wire refresh ----
|
|
808
|
+
const refreshBtn = document.getElementById(uid + '-refresh');
|
|
809
|
+
if (refreshBtn && cfg.onRefresh) {
|
|
810
|
+
refreshBtn.addEventListener('click', e => {
|
|
811
|
+
e.stopPropagation();
|
|
812
|
+
refreshBtn.disabled = true;
|
|
813
|
+
cfg.onRefresh(node.id);
|
|
814
|
+
}, { signal });
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// ---- Wire notes ----
|
|
818
|
+
const notesEl = document.getElementById(uid + '-notes');
|
|
819
|
+
if (notesEl) {
|
|
820
|
+
let nTimer;
|
|
821
|
+
notesEl.addEventListener('input', () => {
|
|
822
|
+
clearTimeout(nTimer);
|
|
823
|
+
nTimer = setTimeout(() => {
|
|
824
|
+
if (!node.state) node.state = {};
|
|
825
|
+
node.state._notes = notesEl.value;
|
|
826
|
+
cfg.onPatch(node.id, { _notes: notesEl.value });
|
|
827
|
+
}, 800);
|
|
828
|
+
cleanup.timers.push(nTimer);
|
|
829
|
+
}, { signal });
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ---- Wire chat ----
|
|
833
|
+
const chatInput = document.getElementById(uid + '-chatInput');
|
|
834
|
+
const chatSend = document.getElementById(uid + '-chatSend');
|
|
835
|
+
if (chatInput && chatSend && cfg.onChat) {
|
|
836
|
+
const send = () => {
|
|
837
|
+
const msg = chatInput.value.trim();
|
|
838
|
+
if (!msg) return;
|
|
839
|
+
chatInput.value = '';
|
|
840
|
+
appendChatMessage(node.id, 'user', msg);
|
|
841
|
+
cfg.onChat(node.id, msg);
|
|
842
|
+
};
|
|
843
|
+
chatSend.addEventListener('click', send, { signal });
|
|
844
|
+
chatInput.addEventListener('keydown', e => {
|
|
845
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
|
|
846
|
+
}, { signal });
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
_autoSubscribe(node);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// ===========================================================================
|
|
853
|
+
// In-place update
|
|
854
|
+
// ===========================================================================
|
|
855
|
+
|
|
856
|
+
function update(nodeId, patch) {
|
|
857
|
+
const info = _nodeEls[nodeId];
|
|
858
|
+
if (!info) return;
|
|
859
|
+
|
|
860
|
+
const refreshBtn = document.getElementById(info.uid + '-refresh');
|
|
861
|
+
if (refreshBtn) refreshBtn.disabled = false;
|
|
862
|
+
|
|
863
|
+
// Update status dot
|
|
864
|
+
if (patch.status) {
|
|
865
|
+
const dot = info.container.querySelector('.lc-status-dot');
|
|
866
|
+
if (dot) {
|
|
867
|
+
const c = { fresh: 'var(--bs-success)', stale: 'var(--bs-warning)', error: 'var(--bs-danger)', loading: 'var(--bs-info)' };
|
|
868
|
+
dot.style.background = c[patch.status] || 'var(--bs-secondary)';
|
|
869
|
+
dot.title = patch.status;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (patch.lastRun) {
|
|
874
|
+
const ts = info.container.querySelector('.lc-status-dot + .text-muted');
|
|
875
|
+
if (ts) ts.textContent = _timeAgo(patch.lastRun);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Merge into node state
|
|
879
|
+
const node = cfg.resolve(nodeId);
|
|
880
|
+
if (!node) return;
|
|
881
|
+
if (!node.state) node.state = {};
|
|
882
|
+
if (patch.status) node.state.status = patch.status;
|
|
883
|
+
if (patch.lastRun) node.state.lastRun = patch.lastRun;
|
|
884
|
+
if (patch.error !== undefined) node.state.error = patch.error;
|
|
885
|
+
|
|
886
|
+
if (node.state.status === 'loading') {
|
|
887
|
+
info.resultEl.innerHTML = '<div class="d-flex align-items-center gap-2"><span class="spinner-border spinner-border-sm text-muted"></span><span class="text-muted small">Loading…</span></div>';
|
|
888
|
+
} else if (node.state.status === 'error' && node.state.error) {
|
|
889
|
+
info.resultEl.innerHTML = `<div class="text-danger small fw-semibold">Refresh failed</div><pre class="text-muted small mt-1" style="white-space:pre-wrap">${_esc(node.state.error)}</pre>`;
|
|
890
|
+
} else {
|
|
891
|
+
_runCompute(node);
|
|
892
|
+
_renderElements(node, info.resultEl);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// ===========================================================================
|
|
897
|
+
// Lifecycle
|
|
898
|
+
// ===========================================================================
|
|
899
|
+
|
|
900
|
+
function destroy(nodeId) {
|
|
901
|
+
const c = _cleanup[nodeId];
|
|
902
|
+
if (c) {
|
|
903
|
+
c.ac.abort();
|
|
904
|
+
c.timers.forEach(t => clearTimeout(t));
|
|
905
|
+
c.charts.forEach(ch => { try { ch.inst.destroy(); } catch (_) {} });
|
|
906
|
+
if (c.unsubs) c.unsubs.forEach(u => u());
|
|
907
|
+
delete _cleanup[nodeId];
|
|
908
|
+
}
|
|
909
|
+
delete _nodeEls[nodeId];
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function destroyAll() {
|
|
913
|
+
Object.keys(_cleanup).forEach(destroy);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// ===========================================================================
|
|
917
|
+
// Chat
|
|
918
|
+
// ===========================================================================
|
|
919
|
+
|
|
920
|
+
function appendChatMessage(nodeId, role, text) {
|
|
921
|
+
const info = _nodeEls[nodeId];
|
|
922
|
+
if (!info) return;
|
|
923
|
+
const chatEl = info.container.querySelector('.lc-chat-messages');
|
|
924
|
+
if (!chatEl) return;
|
|
925
|
+
const msg = document.createElement('div');
|
|
926
|
+
msg.className = `lc-chat-msg small ${role === 'user' ? 'lc-chat-user' : 'lc-chat-assistant'}`;
|
|
927
|
+
msg.innerHTML = role === 'assistant' ? _renderMd(text) : _esc(text);
|
|
928
|
+
chatEl.appendChild(msg);
|
|
929
|
+
chatEl.scrollTop = chatEl.scrollHeight;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// ===========================================================================
|
|
933
|
+
// Return engine
|
|
934
|
+
// ===========================================================================
|
|
935
|
+
|
|
936
|
+
return {
|
|
937
|
+
render,
|
|
938
|
+
update,
|
|
939
|
+
destroy,
|
|
940
|
+
destroyAll,
|
|
941
|
+
notify,
|
|
942
|
+
subscribe,
|
|
943
|
+
appendChatMessage,
|
|
944
|
+
registerRenderer(name, fn) { _renderers[name] = fn; },
|
|
945
|
+
renderers: _renderers,
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// ===========================================================================
|
|
950
|
+
// Board — grid (board) and DAG (canvas) modes
|
|
951
|
+
// ===========================================================================
|
|
952
|
+
|
|
953
|
+
function Board(engine, containerEl, opts) {
|
|
954
|
+
opts = opts || {};
|
|
955
|
+
const mode = { current: opts.mode || 'board' };
|
|
956
|
+
const nodeList = [];
|
|
957
|
+
const nodeMap = {}; // id → { node, colEl, bodyEl }
|
|
958
|
+
const _positions = {}; // id → { x, y, w, h } for canvas mode
|
|
959
|
+
const showNotes = opts.showNotes !== false;
|
|
960
|
+
const showChat = opts.showChat || false;
|
|
961
|
+
const defaultCol = opts.defaultCol || 6;
|
|
962
|
+
|
|
963
|
+
// Canvas config
|
|
964
|
+
const co = opts.canvas || {};
|
|
965
|
+
const cvs = {
|
|
966
|
+
snap: co.snap || 20,
|
|
967
|
+
zoomMin: (co.zoom && co.zoom.min) || 0.25,
|
|
968
|
+
zoomMax: (co.zoom && co.zoom.max) || 2,
|
|
969
|
+
zoom: (co.zoom && co.zoom.initial) || 1,
|
|
970
|
+
edges: co.edges !== false,
|
|
971
|
+
minWidth: co.minWidth || 220,
|
|
972
|
+
maxWidth: co.maxWidth || 450,
|
|
973
|
+
defaultW: co.defaultW || 350,
|
|
974
|
+
gapX: co.gapX || 280,
|
|
975
|
+
gapY: co.gapY || 320,
|
|
976
|
+
padX: co.padX || 20,
|
|
977
|
+
padY: co.padY || 20,
|
|
978
|
+
cardMaxH: co.cardMaxH || 300,
|
|
979
|
+
panX: 0, panY: 0,
|
|
980
|
+
};
|
|
981
|
+
const ac = new AbortController();
|
|
982
|
+
const signal = ac.signal;
|
|
983
|
+
|
|
984
|
+
// DOM containers
|
|
985
|
+
const root = document.createElement('div');
|
|
986
|
+
root.className = 'lc-board';
|
|
987
|
+
containerEl.appendChild(root);
|
|
988
|
+
|
|
989
|
+
const gridEl = document.createElement('div');
|
|
990
|
+
gridEl.className = 'row g-3 lc-board-grid';
|
|
991
|
+
|
|
992
|
+
const canvasEl = document.createElement('div');
|
|
993
|
+
canvasEl.className = 'lc-canvas';
|
|
994
|
+
const canvasHeight = co.height || '600px';
|
|
995
|
+
const canvasOverflow = co.overflow || 'auto';
|
|
996
|
+
canvasEl.style.cssText = 'position:relative;overflow:' + canvasOverflow + ';width:100%;height:' + canvasHeight + ';';
|
|
997
|
+
const canvasInner = document.createElement('div');
|
|
998
|
+
canvasInner.className = 'lc-canvas-inner';
|
|
999
|
+
canvasInner.style.cssText = 'position:absolute;top:0;left:0;transform-origin:0 0;';
|
|
1000
|
+
canvasEl.appendChild(canvasInner);
|
|
1001
|
+
|
|
1002
|
+
// SVG overlay for edges
|
|
1003
|
+
const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
1004
|
+
svgEl.setAttribute('class', 'lc-canvas-edges');
|
|
1005
|
+
svgEl.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;overflow:visible;';
|
|
1006
|
+
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
|
1007
|
+
defs.innerHTML = '<marker id="lc-arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(--bs-secondary,#6c757d)"/></marker>';
|
|
1008
|
+
svgEl.appendChild(defs);
|
|
1009
|
+
canvasInner.appendChild(svgEl);
|
|
1010
|
+
|
|
1011
|
+
// Board/canvas CSS
|
|
1012
|
+
if (!document.getElementById('lc-board-css')) {
|
|
1013
|
+
const s = document.createElement('style');
|
|
1014
|
+
s.id = 'lc-board-css';
|
|
1015
|
+
s.textContent = `
|
|
1016
|
+
.lc-canvas-card { position:absolute; min-width:${cvs.minWidth}px; max-width:${cvs.maxWidth}px; cursor:grab; user-select:none; z-index:1; }
|
|
1017
|
+
.lc-canvas-card.lc-dragging { cursor:grabbing; z-index:10; box-shadow:0 8px 24px rgba(0,0,0,0.18)!important; }
|
|
1018
|
+
.lc-canvas-card .card-body { max-height:${cvs.cardMaxH}px; overflow:auto; }
|
|
1019
|
+
.lc-canvas-edges line { stroke:var(--bs-secondary,#6c757d); stroke-width:1.5; }
|
|
1020
|
+
.lc-source-node { position:absolute; cursor:grab; user-select:none; z-index:1; }
|
|
1021
|
+
.lc-source-node.lc-dragging { cursor:grabbing; z-index:10; }
|
|
1022
|
+
`;
|
|
1023
|
+
document.head.appendChild(s);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// ---- Helpers ----
|
|
1027
|
+
|
|
1028
|
+
function _colWidth(node) {
|
|
1029
|
+
if (node.view && node.view.layout && node.view.layout.board && node.view.layout.board.col) return node.view.layout.board.col;
|
|
1030
|
+
return defaultCol;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function _initPositions() {
|
|
1034
|
+
const explicit = opts.positions || {};
|
|
1035
|
+
nodeList.forEach((node, i) => {
|
|
1036
|
+
if (_positions[node.id]) return; // already set
|
|
1037
|
+
if (explicit[node.id]) {
|
|
1038
|
+
_positions[node.id] = Object.assign({}, explicit[node.id]);
|
|
1039
|
+
} else if (node.view && node.view.layout && node.view.layout.canvas && node.view.layout.canvas.x != null) {
|
|
1040
|
+
_positions[node.id] = Object.assign({}, node.view.layout.canvas);
|
|
1041
|
+
} else {
|
|
1042
|
+
const col = (i % 4);
|
|
1043
|
+
const row = Math.floor(i / 4);
|
|
1044
|
+
_positions[node.id] = { x: col * cvs.gapX + cvs.padX, y: row * cvs.gapY + cvs.padY, w: cvs.defaultW };
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function _getRequires(node) {
|
|
1050
|
+
return (node.data && node.data.requires) || [];
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function _buildCardWrapper(node) {
|
|
1054
|
+
const wrap = document.createElement('div');
|
|
1055
|
+
wrap.className = 'card shadow-sm h-100';
|
|
1056
|
+
const header = document.createElement('div');
|
|
1057
|
+
header.className = 'card-header d-flex align-items-center gap-2 py-2';
|
|
1058
|
+
const title = (node.meta && node.meta.title) || node.id;
|
|
1059
|
+
const tags = (node.meta && node.meta.tags) || [];
|
|
1060
|
+
let badgeHtml = '';
|
|
1061
|
+
if (node.type === 'source' && node.source) {
|
|
1062
|
+
badgeHtml = '<span class="badge bg-info text-dark ms-auto">' + _esc(node.source.kind || 'source') + '</span>';
|
|
1063
|
+
} else if (tags.length) {
|
|
1064
|
+
badgeHtml = tags.map(t => '<span class="badge bg-secondary ms-1">' + _esc(t) + '</span>').join('');
|
|
1065
|
+
}
|
|
1066
|
+
header.innerHTML = '<strong class="small">' + _esc(title) + '</strong>' + badgeHtml;
|
|
1067
|
+
const body = document.createElement('div');
|
|
1068
|
+
body.className = 'card-body p-2';
|
|
1069
|
+
wrap.appendChild(header);
|
|
1070
|
+
wrap.appendChild(body);
|
|
1071
|
+
return { wrap, header, body };
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function _buildSourcePill(node) {
|
|
1075
|
+
const el = document.createElement('div');
|
|
1076
|
+
el.className = 'lc-source-node';
|
|
1077
|
+
const status = (node.state && node.state.status) || 'fresh';
|
|
1078
|
+
const title = (node.meta && node.meta.title) || node.id;
|
|
1079
|
+
const kind = (node.source && node.source.kind) || 'source';
|
|
1080
|
+
el.innerHTML = `<div class="lc-source-pill shadow-sm">
|
|
1081
|
+
${_statusDot(status)}
|
|
1082
|
+
<span class="fw-medium">${_esc(title)}</span>
|
|
1083
|
+
<span class="badge bg-info text-dark">${_esc(kind)}</span>
|
|
1084
|
+
</div>`;
|
|
1085
|
+
return el;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// ---- Board mode ----
|
|
1089
|
+
|
|
1090
|
+
function _renderBoard() {
|
|
1091
|
+
root.innerHTML = '';
|
|
1092
|
+
root.appendChild(gridEl);
|
|
1093
|
+
gridEl.innerHTML = '';
|
|
1094
|
+
|
|
1095
|
+
// Only card nodes in board mode, sorted by order
|
|
1096
|
+
const cards = nodeList.filter(n => n.type === 'card').slice();
|
|
1097
|
+
cards.sort((a, b) => {
|
|
1098
|
+
const ao = (a.view && a.view.layout && a.view.layout.board && a.view.layout.board.order) || 0;
|
|
1099
|
+
const bo = (b.view && b.view.layout && b.view.layout.board && b.view.layout.board.order) || 0;
|
|
1100
|
+
return ao - bo;
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
cards.forEach(node => {
|
|
1104
|
+
const col = document.createElement('div');
|
|
1105
|
+
col.className = 'col-12 col-md-' + _colWidth(node);
|
|
1106
|
+
col.dataset.nodeId = node.id;
|
|
1107
|
+
const { wrap, body } = _buildCardWrapper(node);
|
|
1108
|
+
col.appendChild(wrap);
|
|
1109
|
+
gridEl.appendChild(col);
|
|
1110
|
+
nodeMap[node.id] = { node, colEl: col, bodyEl: body };
|
|
1111
|
+
engine.render(node, body, { showNotes, showChat });
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// ---- Canvas mode ----
|
|
1116
|
+
|
|
1117
|
+
function _applyTransform() {
|
|
1118
|
+
canvasInner.style.transform = `translate(${cvs.panX}px,${cvs.panY}px) scale(${cvs.zoom})`;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function _drawEdges() {
|
|
1122
|
+
svgEl.querySelectorAll('line').forEach(l => l.remove());
|
|
1123
|
+
if (!cvs.edges) return;
|
|
1124
|
+
|
|
1125
|
+
nodeList.forEach(node => {
|
|
1126
|
+
_getRequires(node).forEach(srcId => {
|
|
1127
|
+
const srcInfo = nodeMap[srcId];
|
|
1128
|
+
const tgtInfo = nodeMap[node.id];
|
|
1129
|
+
if (!srcInfo || !tgtInfo) return;
|
|
1130
|
+
const sEl = srcInfo.colEl;
|
|
1131
|
+
const tEl = tgtInfo.colEl;
|
|
1132
|
+
const sx = sEl.offsetLeft + sEl.offsetWidth;
|
|
1133
|
+
const sy = sEl.offsetTop + sEl.offsetHeight / 2;
|
|
1134
|
+
const tx = tEl.offsetLeft;
|
|
1135
|
+
const ty = tEl.offsetTop + tEl.offsetHeight / 2;
|
|
1136
|
+
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
1137
|
+
line.setAttribute('x1', sx); line.setAttribute('y1', sy);
|
|
1138
|
+
line.setAttribute('x2', tx); line.setAttribute('y2', ty);
|
|
1139
|
+
line.setAttribute('marker-end', 'url(#lc-arrow)');
|
|
1140
|
+
svgEl.appendChild(line);
|
|
1141
|
+
});
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function _makeDraggable(el, node) {
|
|
1146
|
+
let startX, startY, origX, origY, dragging = false;
|
|
1147
|
+
|
|
1148
|
+
el.addEventListener('pointerdown', e => {
|
|
1149
|
+
if (e.button !== 0) return;
|
|
1150
|
+
if (e.target.closest('input,textarea,select,button,a,.form-check-input')) return;
|
|
1151
|
+
dragging = true;
|
|
1152
|
+
el.classList.add('lc-dragging');
|
|
1153
|
+
el.setPointerCapture(e.pointerId);
|
|
1154
|
+
startX = e.clientX; startY = e.clientY;
|
|
1155
|
+
origX = el.offsetLeft; origY = el.offsetTop;
|
|
1156
|
+
e.preventDefault();
|
|
1157
|
+
}, { signal });
|
|
1158
|
+
|
|
1159
|
+
el.addEventListener('pointermove', e => {
|
|
1160
|
+
if (!dragging) return;
|
|
1161
|
+
const dx = (e.clientX - startX) / cvs.zoom;
|
|
1162
|
+
const dy = (e.clientY - startY) / cvs.zoom;
|
|
1163
|
+
el.style.left = (origX + dx) + 'px';
|
|
1164
|
+
el.style.top = (origY + dy) + 'px';
|
|
1165
|
+
_drawEdges();
|
|
1166
|
+
}, { signal });
|
|
1167
|
+
|
|
1168
|
+
el.addEventListener('pointerup', () => {
|
|
1169
|
+
if (!dragging) return;
|
|
1170
|
+
dragging = false;
|
|
1171
|
+
el.classList.remove('lc-dragging');
|
|
1172
|
+
let x = el.offsetLeft, y = el.offsetTop;
|
|
1173
|
+
if (cvs.snap > 1) { x = Math.round(x / cvs.snap) * cvs.snap; y = Math.round(y / cvs.snap) * cvs.snap; }
|
|
1174
|
+
el.style.left = x + 'px'; el.style.top = y + 'px';
|
|
1175
|
+
// Persist
|
|
1176
|
+
_positions[node.id] = Object.assign(_positions[node.id] || {}, { x, y });
|
|
1177
|
+
if (node.type === 'card' && node.view) {
|
|
1178
|
+
if (!node.view.layout) node.view.layout = {};
|
|
1179
|
+
if (!node.view.layout.canvas) node.view.layout.canvas = {};
|
|
1180
|
+
node.view.layout.canvas.x = x;
|
|
1181
|
+
node.view.layout.canvas.y = y;
|
|
1182
|
+
}
|
|
1183
|
+
engine.notify(node.id);
|
|
1184
|
+
_drawEdges();
|
|
1185
|
+
}, { signal });
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function _renderCanvas() {
|
|
1189
|
+
root.innerHTML = '';
|
|
1190
|
+
root.appendChild(canvasEl);
|
|
1191
|
+
canvasInner.querySelectorAll('.lc-canvas-card,.lc-source-node').forEach(el => el.remove());
|
|
1192
|
+
svgEl.querySelectorAll('line').forEach(l => l.remove());
|
|
1193
|
+
_initPositions();
|
|
1194
|
+
_applyTransform();
|
|
1195
|
+
|
|
1196
|
+
nodeList.forEach(node => {
|
|
1197
|
+
const pos = _positions[node.id] || { x: 0, y: 0 };
|
|
1198
|
+
|
|
1199
|
+
if (node.type === 'source') {
|
|
1200
|
+
const el = _buildSourcePill(node);
|
|
1201
|
+
el.dataset.nodeId = node.id;
|
|
1202
|
+
el.style.left = pos.x + 'px';
|
|
1203
|
+
el.style.top = pos.y + 'px';
|
|
1204
|
+
canvasInner.appendChild(el);
|
|
1205
|
+
nodeMap[node.id] = { node, colEl: el, bodyEl: null };
|
|
1206
|
+
_makeDraggable(el, node);
|
|
1207
|
+
} else {
|
|
1208
|
+
const el = document.createElement('div');
|
|
1209
|
+
el.className = 'lc-canvas-card card shadow-sm';
|
|
1210
|
+
el.dataset.nodeId = node.id;
|
|
1211
|
+
el.style.left = pos.x + 'px';
|
|
1212
|
+
el.style.top = pos.y + 'px';
|
|
1213
|
+
if (pos.w) el.style.width = pos.w + 'px';
|
|
1214
|
+
|
|
1215
|
+
const { wrap, body } = _buildCardWrapper(node);
|
|
1216
|
+
while (wrap.firstChild) el.appendChild(wrap.firstChild);
|
|
1217
|
+
canvasInner.appendChild(el);
|
|
1218
|
+
nodeMap[node.id] = { node, colEl: el, bodyEl: body };
|
|
1219
|
+
engine.render(node, body, { showNotes: false, showChat: false });
|
|
1220
|
+
_makeDraggable(el, node);
|
|
1221
|
+
}
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
_drawEdges();
|
|
1225
|
+
|
|
1226
|
+
// Pan: middle-click or Ctrl+drag on background
|
|
1227
|
+
let panning = false, panStartX, panStartY, panOrigX, panOrigY;
|
|
1228
|
+
canvasEl.addEventListener('pointerdown', e => {
|
|
1229
|
+
if (e.target !== canvasEl && e.target !== canvasInner) return;
|
|
1230
|
+
if (e.button === 1 || (e.button === 0 && e.ctrlKey)) {
|
|
1231
|
+
panning = true; canvasEl.setPointerCapture(e.pointerId);
|
|
1232
|
+
panStartX = e.clientX; panStartY = e.clientY;
|
|
1233
|
+
panOrigX = cvs.panX; panOrigY = cvs.panY;
|
|
1234
|
+
e.preventDefault();
|
|
1235
|
+
}
|
|
1236
|
+
}, { signal });
|
|
1237
|
+
canvasEl.addEventListener('pointermove', e => {
|
|
1238
|
+
if (!panning) return;
|
|
1239
|
+
cvs.panX = panOrigX + (e.clientX - panStartX);
|
|
1240
|
+
cvs.panY = panOrigY + (e.clientY - panStartY);
|
|
1241
|
+
_applyTransform();
|
|
1242
|
+
}, { signal });
|
|
1243
|
+
canvasEl.addEventListener('pointerup', () => { panning = false; }, { signal });
|
|
1244
|
+
|
|
1245
|
+
// Zoom: Ctrl+wheel
|
|
1246
|
+
canvasEl.addEventListener('wheel', e => {
|
|
1247
|
+
if (!e.ctrlKey) return;
|
|
1248
|
+
e.preventDefault();
|
|
1249
|
+
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
|
1250
|
+
cvs.zoom = Math.min(cvs.zoomMax, Math.max(cvs.zoomMin, cvs.zoom * delta));
|
|
1251
|
+
_applyTransform();
|
|
1252
|
+
}, { signal, passive: false });
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
function _render() {
|
|
1256
|
+
if (mode.current === 'canvas') _renderCanvas();
|
|
1257
|
+
else _renderBoard();
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// ---- Auto-layout (topological L → R) ----
|
|
1261
|
+
|
|
1262
|
+
function autoLayout() {
|
|
1263
|
+
const incoming = {};
|
|
1264
|
+
const levels = {};
|
|
1265
|
+
nodeList.forEach(n => { incoming[n.id] = []; levels[n.id] = 0; });
|
|
1266
|
+
nodeList.forEach(n => {
|
|
1267
|
+
_getRequires(n).forEach(srcId => {
|
|
1268
|
+
if (incoming[n.id]) incoming[n.id].push(srcId);
|
|
1269
|
+
});
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
let changed = true;
|
|
1273
|
+
while (changed) {
|
|
1274
|
+
changed = false;
|
|
1275
|
+
nodeList.forEach(n => {
|
|
1276
|
+
(incoming[n.id] || []).forEach(srcId => {
|
|
1277
|
+
if (levels[srcId] != null && levels[srcId] + 1 > levels[n.id]) {
|
|
1278
|
+
levels[n.id] = levels[srcId] + 1;
|
|
1279
|
+
changed = true;
|
|
1280
|
+
}
|
|
1281
|
+
});
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
const colCounts = {};
|
|
1286
|
+
nodeList.forEach(n => {
|
|
1287
|
+
const lv = levels[n.id] || 0;
|
|
1288
|
+
if (!colCounts[lv]) colCounts[lv] = 0;
|
|
1289
|
+
const row = colCounts[lv]++;
|
|
1290
|
+
_positions[n.id] = {
|
|
1291
|
+
x: lv * 400 + 40,
|
|
1292
|
+
y: row * 300 + 40,
|
|
1293
|
+
w: (_positions[n.id] && _positions[n.id].w) || cvs.defaultW,
|
|
1294
|
+
};
|
|
1295
|
+
// Sync to card nodes
|
|
1296
|
+
if (n.type === 'card' && n.view) {
|
|
1297
|
+
if (!n.view.layout) n.view.layout = {};
|
|
1298
|
+
n.view.layout.canvas = Object.assign({}, _positions[n.id]);
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
if (mode.current === 'canvas') _renderCanvas();
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// ---- Public API ----
|
|
1305
|
+
|
|
1306
|
+
function add(node) {
|
|
1307
|
+
if (nodeMap[node.id]) return;
|
|
1308
|
+
nodeList.push(node);
|
|
1309
|
+
_render();
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
function remove(nodeId) {
|
|
1313
|
+
engine.destroy(nodeId);
|
|
1314
|
+
const idx = nodeList.findIndex(n => n.id === nodeId);
|
|
1315
|
+
if (idx >= 0) nodeList.splice(idx, 1);
|
|
1316
|
+
delete nodeMap[nodeId];
|
|
1317
|
+
delete _positions[nodeId];
|
|
1318
|
+
_render();
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function reorder(ids) {
|
|
1322
|
+
nodeList.length = 0;
|
|
1323
|
+
ids.forEach(id => {
|
|
1324
|
+
const info = nodeMap[id];
|
|
1325
|
+
if (info) nodeList.push(info.node);
|
|
1326
|
+
});
|
|
1327
|
+
_render();
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function refresh() { _render(); }
|
|
1331
|
+
|
|
1332
|
+
function clear() {
|
|
1333
|
+
engine.destroyAll();
|
|
1334
|
+
nodeList.length = 0;
|
|
1335
|
+
Object.keys(nodeMap).forEach(k => delete nodeMap[k]);
|
|
1336
|
+
Object.keys(_positions).forEach(k => delete _positions[k]);
|
|
1337
|
+
root.innerHTML = '';
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function setMode(m) {
|
|
1341
|
+
if (m !== 'board' && m !== 'canvas') return;
|
|
1342
|
+
mode.current = m;
|
|
1343
|
+
_render();
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function destroy() {
|
|
1347
|
+
ac.abort();
|
|
1348
|
+
engine.destroyAll();
|
|
1349
|
+
nodeList.length = 0;
|
|
1350
|
+
Object.keys(nodeMap).forEach(k => delete nodeMap[k]);
|
|
1351
|
+
root.innerHTML = '';
|
|
1352
|
+
if (root.parentNode) root.parentNode.removeChild(root);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// ---- Init ----
|
|
1356
|
+
if (opts.nodes && opts.nodes.length) {
|
|
1357
|
+
opts.nodes.forEach(n => nodeList.push(n));
|
|
1358
|
+
}
|
|
1359
|
+
_render();
|
|
1360
|
+
|
|
1361
|
+
return {
|
|
1362
|
+
add,
|
|
1363
|
+
remove,
|
|
1364
|
+
reorder,
|
|
1365
|
+
refresh,
|
|
1366
|
+
clear,
|
|
1367
|
+
setMode,
|
|
1368
|
+
autoLayout,
|
|
1369
|
+
destroy,
|
|
1370
|
+
get mode() { return mode.current; },
|
|
1371
|
+
get nodes() { return nodeList.slice(); },
|
|
1372
|
+
get engine() { return engine; },
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// ===========================================================================
|
|
1377
|
+
// Module export
|
|
1378
|
+
// ===========================================================================
|
|
1379
|
+
|
|
1380
|
+
return { init, Board };
|
|
1381
|
+
})();
|