@oxeo/visual 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +99 -2
  2. package/bin/oxeo-visual.js +2 -0
  3. package/dist/build.d.ts +23 -0
  4. package/dist/build.js +58 -0
  5. package/dist/cli/build-command.d.ts +5 -0
  6. package/dist/cli/build-command.js +57 -0
  7. package/dist/cli/dev.d.ts +6 -0
  8. package/dist/cli/dev.js +274 -0
  9. package/dist/cli/index.d.ts +1 -0
  10. package/dist/cli/index.js +104 -0
  11. package/dist/cli/open.d.ts +1 -0
  12. package/dist/cli/open.js +21 -0
  13. package/dist/cli/output.d.ts +4 -0
  14. package/dist/cli/output.js +13 -0
  15. package/dist/cli/run.d.ts +7 -0
  16. package/dist/cli/run.js +55 -0
  17. package/dist/cli/worker.d.ts +20 -0
  18. package/dist/cli/worker.js +94 -0
  19. package/dist/config.d.ts +33 -0
  20. package/dist/config.js +26 -0
  21. package/dist/extract.d.ts +8 -0
  22. package/dist/extract.js +300 -0
  23. package/dist/index.d.ts +6 -0
  24. package/dist/index.js +24 -0
  25. package/dist/layout/cards.d.ts +43 -0
  26. package/dist/layout/cards.js +121 -0
  27. package/dist/layout/place.d.ts +40 -0
  28. package/dist/layout/place.js +197 -0
  29. package/dist/layout/route.d.ts +30 -0
  30. package/dist/layout/route.js +192 -0
  31. package/dist/model.d.ts +60 -0
  32. package/dist/model.js +2 -0
  33. package/dist/project.d.ts +14 -0
  34. package/dist/project.js +11 -0
  35. package/dist/render/client.js +871 -0
  36. package/dist/render/html.d.ts +8 -0
  37. package/dist/render/html.js +211 -0
  38. package/dist/render/palette.d.ts +4 -0
  39. package/dist/render/palette.js +66 -0
  40. package/dist/render/svg.d.ts +20 -0
  41. package/dist/render/svg.js +222 -0
  42. package/dist/scheme-meta.d.ts +90 -0
  43. package/dist/scheme-meta.js +2 -0
  44. package/dist/text.d.ts +2 -0
  45. package/dist/text.js +12 -0
  46. package/dist/types.d.ts +15 -0
  47. package/dist/types.js +143 -0
  48. package/package.json +36 -3
@@ -0,0 +1,871 @@
1
+ "use strict";
2
+ ;
3
+ (() => {
4
+ const DATA = JSON.parse(document.getElementById('xv-data').textContent);
5
+ const DEBUG = /[?&]debug\b/.test(location.search);
6
+ const errors = [];
7
+ window.addEventListener('error', (e) => errors.push(String(e.message)));
8
+ const $ = (selector, root = document) => root.querySelector(selector);
9
+ const esc = (value) => String(value).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
10
+ const md = (text) => esc(text).replace(/`([^`]+)`/g, '<code>$1</code>');
11
+ const plural = (n, forms) => {
12
+ const m10 = n % 10;
13
+ const m100 = n % 100;
14
+ const form = m10 === 1 && m100 !== 11 ? 0 : m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20) ? 1 : 2;
15
+ return `${n} ${forms[form]}`;
16
+ };
17
+ const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
18
+ const byKey = new Map(DATA.entities.map((e) => [e.key, e]));
19
+ const zoneById = new Map(DATA.zones.map((z) => [z.id, z]));
20
+ const hubs = new Set(DATA.hubs);
21
+ const outgoing = new Map(DATA.entities.map((e) => [e.key, []]));
22
+ const incoming = new Map(DATA.entities.map((e) => [e.key, []]));
23
+ for (const r of DATA.relations) {
24
+ outgoing.get(r.source).push(r);
25
+ incoming.get(r.target).push(r);
26
+ }
27
+ const ownerOf = new Map(DATA.relations.filter((r) => r.kind === 'own').map((r) => [r.target, r]));
28
+ const zoneColor = (key) => `var(${zoneById.get(DATA.zoneOf[key])?.colorVar ?? '--xv-muted'})`;
29
+ const relationOfRow = (key, path) => DATA.relations.find((r) => r.source === key && r.field === path) ??
30
+ DATA.relations.find((r) => r.kind === 'own' && r.target === key && r.ownerField === path);
31
+ const countFields = (fields) => fields.reduce((n, f) => n + (f.fields ? countFields(f.fields) : 1), 0);
32
+ const fieldCount = (e) => countFields(e.fields) + e.variants.reduce((n, v) => n + countFields(v.fields), 0);
33
+ const paths = (e) => {
34
+ const list = [];
35
+ const walk = (fields) => fields.forEach((f) => {
36
+ list.push(f.path.toLowerCase());
37
+ if (f.fields)
38
+ walk(f.fields);
39
+ });
40
+ walk(e.fields);
41
+ e.variants.forEach((v) => walk(v.fields));
42
+ return list;
43
+ };
44
+ const haystack = new Map(DATA.entities.map((e) => [e.key, [e.key.toLowerCase(), e.model.toLowerCase(), ...paths(e)]]));
45
+ const state = {
46
+ selected: null,
47
+ hover: null,
48
+ hoverEdge: null,
49
+ pin: null,
50
+ beforePin: null,
51
+ query: '',
52
+ hubMode: 'lines'
53
+ };
54
+ try {
55
+ const saved = localStorage.getItem('xv-hub-mode');
56
+ if (saved === 'lines' || saved === 'shortcuts')
57
+ state.hubMode = saved;
58
+ }
59
+ catch { }
60
+ const svg = $('.xv-map');
61
+ const stage = $('#xv-stage');
62
+ const tip = $('#xv-tip');
63
+ const search = $('#xv-search');
64
+ const cardEls = new Map();
65
+ svg.querySelectorAll('.xv-card').forEach((el) => cardEls.set(el.dataset.key, el));
66
+ const layer = (name) => svg.querySelector(`.xv-edges[data-layer="${name}"]`);
67
+ const activeLayer = () => layer(state.hubMode === 'lines' ? 'all' : 'nohub');
68
+ const view = { x: 0, y: 0, k: 1 };
69
+ const MIN_K = 0.08;
70
+ const MAX_K = 2.5;
71
+ const LOD_K = 0.5;
72
+ const applyView = () => {
73
+ const w = stage.clientWidth;
74
+ const h = stage.clientHeight;
75
+ svg.setAttribute('viewBox', `${view.x} ${view.y} ${w / view.k} ${h / view.k}`);
76
+ svg.classList.toggle('lod-far', view.k < LOD_K);
77
+ svg.style.setProperty('--xv-inv', String(1 / view.k));
78
+ const zoom = $('#xv-zoom');
79
+ if (zoom)
80
+ zoom.textContent = `${Math.round(view.k * 100)}%`;
81
+ };
82
+ let animation = 0;
83
+ const animateTo = (target) => {
84
+ cancelAnimationFrame(animation);
85
+ if (reducedMotion) {
86
+ Object.assign(view, target);
87
+ return applyView();
88
+ }
89
+ const from = { ...view };
90
+ const started = performance.now();
91
+ const step = (now) => {
92
+ const t = Math.min(1, (now - started) / 280);
93
+ const ease = 1 - Math.pow(1 - t, 3);
94
+ view.x = from.x + (target.x - from.x) * ease;
95
+ view.y = from.y + (target.y - from.y) * ease;
96
+ view.k = from.k + (target.k - from.k) * ease;
97
+ applyView();
98
+ if (t < 1)
99
+ animation = requestAnimationFrame(step);
100
+ };
101
+ animation = requestAnimationFrame(step);
102
+ };
103
+ const TOP_INSET = 56;
104
+ const fitBox = (box, padding = 40, maxK = MAX_K) => {
105
+ const w = stage.clientWidth;
106
+ const h = stage.clientHeight - TOP_INSET;
107
+ const k = Math.max(MIN_K, Math.min(maxK, (w - padding * 2) / box.w, (h - padding * 2) / box.h));
108
+ return { x: box.x + box.w / 2 - w / 2 / k, y: box.y + box.h / 2 - (TOP_INSET + h / 2) / k, k };
109
+ };
110
+ const fitAll = (animate = true) => {
111
+ const target = fitBox({ x: 0, y: 0, w: DATA.size.width, h: DATA.size.height }, 24);
112
+ if (animate)
113
+ animateTo(target);
114
+ else {
115
+ Object.assign(view, target);
116
+ applyView();
117
+ }
118
+ };
119
+ const cardBox = (key) => {
120
+ const el = cardEls.get(key);
121
+ if (!el)
122
+ return null;
123
+ const m = /translate\(([-\d.]+)[ ,]([-\d.]+)\)/.exec(el.getAttribute('transform') || '');
124
+ const bg = el.querySelector('.xv-card-bg');
125
+ return {
126
+ x: Number(m ? m[1] : 0),
127
+ y: Number(m ? m[2] : 0),
128
+ w: Number(bg.getAttribute('width')),
129
+ h: Number(bg.getAttribute('height'))
130
+ };
131
+ };
132
+ const reveal = (key) => {
133
+ const keys = [key, ...neighbors(key)];
134
+ const boxes = keys.map(cardBox).filter(Boolean);
135
+ const x1 = Math.min(...boxes.map((b) => b.x));
136
+ const y1 = Math.min(...boxes.map((b) => b.y));
137
+ const x2 = Math.max(...boxes.map((b) => b.x + b.w));
138
+ const y2 = Math.max(...boxes.map((b) => b.y + b.h));
139
+ animateTo(fitBox({ x: x1, y: y1, w: x2 - x1, h: y2 - y1 }, 60, 1.1));
140
+ };
141
+ const revealZone = (id) => {
142
+ const zone = svg.querySelector(`.xv-zone[data-zone="${CSS.escape(id)}"] .xv-zone-bg`);
143
+ if (!zone)
144
+ return;
145
+ const n = (name) => Number(zone.getAttribute(name));
146
+ animateTo(fitBox({ x: n('x'), y: n('y'), w: n('width'), h: n('height') }, 30));
147
+ };
148
+ const toMap = (clientX, clientY) => {
149
+ const rect = stage.getBoundingClientRect();
150
+ return { x: view.x + (clientX - rect.left) / view.k, y: view.y + (clientY - rect.top) / view.k };
151
+ };
152
+ stage.addEventListener('wheel', (e) => {
153
+ if (e.target.closest('.xv-legend, .xv-float'))
154
+ return;
155
+ e.preventDefault();
156
+ cancelAnimationFrame(animation);
157
+ const factor = Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015));
158
+ const k = Math.max(MIN_K, Math.min(MAX_K, view.k * factor));
159
+ const p = toMap(e.clientX, e.clientY);
160
+ const rect = stage.getBoundingClientRect();
161
+ view.k = k;
162
+ view.x = p.x - (e.clientX - rect.left) / k;
163
+ view.y = p.y - (e.clientY - rect.top) / k;
164
+ applyView();
165
+ }, { passive: false });
166
+ let drag = null;
167
+ stage.addEventListener('pointerdown', (e) => {
168
+ if (e.button !== 0 || e.target.closest('.xv-legend, .xv-float'))
169
+ return;
170
+ drag = { id: e.pointerId, x: e.clientX, y: e.clientY, vx: view.x, vy: view.y, moved: false };
171
+ });
172
+ window.addEventListener('pointermove', (e) => {
173
+ if (!drag || e.pointerId !== drag.id)
174
+ return;
175
+ const dx = e.clientX - drag.x;
176
+ const dy = e.clientY - drag.y;
177
+ if (!drag.moved && Math.hypot(dx, dy) < 4)
178
+ return;
179
+ if (!drag.moved) {
180
+ drag.moved = true;
181
+ stage.classList.add('is-panning');
182
+ hideTip();
183
+ }
184
+ view.x = drag.vx - dx / view.k;
185
+ view.y = drag.vy - dy / view.k;
186
+ applyView();
187
+ });
188
+ window.addEventListener('pointerup', (e) => {
189
+ if (!drag || e.pointerId !== drag.id)
190
+ return;
191
+ const moved = drag.moved;
192
+ drag = null;
193
+ stage.classList.remove('is-panning');
194
+ if (moved)
195
+ return;
196
+ const target = e.target;
197
+ const card = target.closest('.xv-card');
198
+ const edge = target.closest('.xv-edge');
199
+ const row = target.closest('.xv-row');
200
+ if (card && row) {
201
+ togglePin(card.dataset.key, row.dataset.path);
202
+ }
203
+ else if (card) {
204
+ const key = card.dataset.key;
205
+ select(state.selected === key && !state.pin ? null : key);
206
+ }
207
+ else if (edge) {
208
+ togglePin(edge.dataset.source, edge.dataset.field);
209
+ }
210
+ else if (stage.contains(target)) {
211
+ select(null);
212
+ }
213
+ });
214
+ function neighbors(key) {
215
+ const keys = new Set();
216
+ activeLayer()
217
+ .querySelectorAll('.xv-edge')
218
+ .forEach((el) => {
219
+ if (el.dataset.source === key)
220
+ keys.add(el.dataset.target);
221
+ if (el.dataset.target === key)
222
+ keys.add(el.dataset.source);
223
+ });
224
+ if (state.hubMode === 'shortcuts') {
225
+ for (const r of [...outgoing.get(key), ...incoming.get(key)])
226
+ keys.add(r.source === key ? r.target : r.source);
227
+ }
228
+ keys.delete(key);
229
+ return [...keys];
230
+ }
231
+ const relationsOfRow = (key, path) => {
232
+ const entity = byKey.get(key);
233
+ if (path.startsWith('@')) {
234
+ const variant = path.slice(1);
235
+ return DATA.relations.filter((r) => r.source === key && r.variant === variant);
236
+ }
237
+ const inside = (field) => field !== undefined && (field === path || field.startsWith(`${path}.`));
238
+ const list = DATA.relations.filter((r) => (r.source === key && inside(r.field)) ||
239
+ (r.kind === 'own' && r.target === key && inside(r.ownerField)));
240
+ if (entity && path === entity.idKey) {
241
+ list.push(...DATA.relations.filter((r) => r.target === key && r.kind !== 'own'));
242
+ }
243
+ return [...new Set(list)];
244
+ };
245
+ const relationEnds = (r) => {
246
+ if (r.kind === 'own') {
247
+ return [
248
+ { key: r.source, path: r.field },
249
+ { key: r.target, path: r.ownerField ?? byKey.get(r.target).idKey }
250
+ ];
251
+ }
252
+ return [
253
+ { key: r.source, path: r.field },
254
+ { key: r.target, path: byKey.get(r.target).idKey }
255
+ ];
256
+ };
257
+ const rowEl = (key, path) => cardEls.get(key)?.querySelector(`.xv-row[data-path="${CSS.escape(path)}"]`);
258
+ const isPinned = () => Boolean(state.pin || state.selected);
259
+ const applyFocus = () => {
260
+ svg
261
+ .querySelectorAll('.is-on, .is-selected, .is-pinned, .is-linked')
262
+ .forEach((el) => el.classList.remove('is-on', 'is-selected', 'is-pinned', 'is-linked'));
263
+ svg.classList.toggle('is-locked', isPinned());
264
+ if (state.selected)
265
+ cardEls.get(state.selected)?.classList.add('is-selected');
266
+ if (state.pin) {
267
+ const { key, path } = state.pin;
268
+ svg.classList.add('has-focus');
269
+ cardEls.get(key)?.classList.add('is-on');
270
+ rowEl(key, path)?.classList.add('is-pinned');
271
+ for (const r of relationsOfRow(key, path)) {
272
+ svg
273
+ .querySelectorAll(`.xv-edge[data-id="${CSS.escape(r.id)}"]`)
274
+ .forEach((el) => el.classList.add('is-on'));
275
+ for (const end of relationEnds(r)) {
276
+ cardEls.get(end.key)?.classList.add('is-on');
277
+ const el = rowEl(end.key, end.path);
278
+ if (el && !el.classList.contains('is-pinned'))
279
+ el.classList.add('is-linked');
280
+ }
281
+ }
282
+ return;
283
+ }
284
+ if (!isPinned() && state.hoverEdge) {
285
+ svg.classList.add('has-focus');
286
+ svg
287
+ .querySelectorAll(`.xv-edge[data-id="${CSS.escape(state.hoverEdge)}"]`)
288
+ .forEach((el) => {
289
+ el.classList.add('is-on');
290
+ cardEls.get(el.dataset.source)?.classList.add('is-on');
291
+ cardEls.get(el.dataset.target)?.classList.add('is-on');
292
+ });
293
+ return;
294
+ }
295
+ const key = isPinned() ? state.selected : state.hover;
296
+ svg.classList.toggle('has-focus', Boolean(key));
297
+ if (!key)
298
+ return;
299
+ cardEls.get(key)?.classList.add('is-on');
300
+ svg.querySelectorAll('.xv-edge').forEach((el) => {
301
+ if (el.dataset.source === key || el.dataset.target === key)
302
+ el.classList.add('is-on');
303
+ });
304
+ neighbors(key).forEach((k) => cardEls.get(k)?.classList.add('is-on'));
305
+ };
306
+ function togglePin(key, path) {
307
+ if (state.pin && state.pin.key === key && state.pin.path === path) {
308
+ const back = state.beforePin;
309
+ state.pin = null;
310
+ state.beforePin = null;
311
+ select(back);
312
+ return;
313
+ }
314
+ if (!state.pin)
315
+ state.beforePin = state.selected;
316
+ state.pin = { key, path };
317
+ state.selected = key;
318
+ try {
319
+ history.replaceState(null, '', `#${key}`);
320
+ }
321
+ catch { }
322
+ hideTip();
323
+ applyFocus();
324
+ }
325
+ svg.addEventListener('pointerover', (e) => {
326
+ if (drag?.moved)
327
+ return;
328
+ const target = e.target;
329
+ const row = target.closest('.xv-row');
330
+ const card = target.closest('.xv-card');
331
+ const edge = target.closest('.xv-edge');
332
+ const onHead = Boolean(card &&
333
+ !row &&
334
+ (target.closest('.xv-card-head, .xv-card-title, .xv-card-badge') ||
335
+ svg.classList.contains('lod-far')));
336
+ if (isPinned()) {
337
+ const live = (card && card.classList.contains('is-on')) || (!card && edge?.classList.contains('is-on'));
338
+ if (!live)
339
+ return hideTip();
340
+ if (row && card) {
341
+ const relation = relationOfRow(card.dataset.key, row.dataset.path);
342
+ const html = relation
343
+ ? relationTip(relation, fieldIndex.get(card.dataset.key)?.get(row.dataset.path))
344
+ : fieldTip(card.dataset.key, row.dataset.path);
345
+ if (html)
346
+ showTip(html, e);
347
+ else
348
+ hideTip();
349
+ }
350
+ else if (card && onHead) {
351
+ showTip(modelTip(card.dataset.key), e);
352
+ }
353
+ else if (edge && !card) {
354
+ const relation = DATA.relations.find((r) => r.id === edge.dataset.id);
355
+ if (relation)
356
+ showTip(relationTip(relation), e);
357
+ }
358
+ else
359
+ hideTip();
360
+ return;
361
+ }
362
+ if (row && card) {
363
+ const relation = relationOfRow(card.dataset.key, row.dataset.path);
364
+ state.hoverEdge = relation ? relation.id : null;
365
+ state.hover = relation ? null : card.dataset.key;
366
+ const html = relation
367
+ ? relationTip(relation, fieldIndex.get(card.dataset.key)?.get(row.dataset.path))
368
+ : fieldTip(card.dataset.key, row.dataset.path);
369
+ if (html)
370
+ showTip(html, e);
371
+ else
372
+ hideTip();
373
+ }
374
+ else if (card) {
375
+ state.hover = card.dataset.key;
376
+ state.hoverEdge = null;
377
+ if (onHead)
378
+ showTip(modelTip(card.dataset.key), e);
379
+ else
380
+ hideTip();
381
+ }
382
+ else if (edge) {
383
+ state.hoverEdge = edge.dataset.id;
384
+ state.hover = null;
385
+ const relation = DATA.relations.find((r) => r.id === edge.dataset.id);
386
+ if (relation)
387
+ showTip(relationTip(relation), e);
388
+ }
389
+ else {
390
+ state.hover = null;
391
+ state.hoverEdge = null;
392
+ hideTip();
393
+ }
394
+ applyFocus();
395
+ });
396
+ svg.addEventListener('pointerleave', () => {
397
+ state.hover = null;
398
+ state.hoverEdge = null;
399
+ hideTip();
400
+ applyFocus();
401
+ });
402
+ stage.addEventListener('pointermove', (e) => {
403
+ if (!tip.hidden)
404
+ moveTip(e.clientX, e.clientY);
405
+ });
406
+ const fieldIndex = new Map();
407
+ for (const entity of DATA.entities) {
408
+ const index = new Map();
409
+ const walk = (fields) => fields.forEach((f) => {
410
+ index.set(f.path, f);
411
+ if (f.fields)
412
+ walk(f.fields);
413
+ });
414
+ walk(entity.fields);
415
+ entity.variants.forEach((v) => walk(v.fields));
416
+ fieldIndex.set(entity.key, index);
417
+ }
418
+ const decoratorHtml = (decorators) => decorators?.length
419
+ ? `<div class="tip-code">${decorators
420
+ .map((d) => esc(d).replace(/^(@[\w.]+)/, '<span class="tip-dec">$1</span>'))
421
+ .join('<br>')}</div>`
422
+ : '';
423
+ const TS_TOKENS = /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|('(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*")|\b(interface|type|enum|extends|keyof|typeof|readonly|null|undefined|never|unknown|string|number|boolean|true|false)\b|\b([A-Z][A-Za-z0-9_]*)\b/g;
424
+ const highlightTs = (code) => {
425
+ let html = '';
426
+ let last = 0;
427
+ for (const m of code.matchAll(TS_TOKENS)) {
428
+ html += esc(code.slice(last, m.index));
429
+ const cls = m[1] ? 'c' : m[2] ? 's' : m[3] ? 'k' : 't';
430
+ html += `<span class="ts-${cls}">${esc(m[0])}</span>`;
431
+ last = m.index + m[0].length;
432
+ }
433
+ return html + esc(code.slice(last));
434
+ };
435
+ const MAX_CODE_LINES = 60;
436
+ const typeCodeHtml = (field) => {
437
+ if (!field.typeRefs)
438
+ return '';
439
+ const blocks = [`${field.name}: ${field.tsType ?? 'unknown'}`];
440
+ let lines = 1;
441
+ let skipped = 0;
442
+ for (const id of field.typeRefs) {
443
+ const text = DATA.types[id];
444
+ if (!text)
445
+ continue;
446
+ const count = text.split('\n').length;
447
+ if (lines + count > MAX_CODE_LINES) {
448
+ skipped++;
449
+ continue;
450
+ }
451
+ blocks.push(text);
452
+ lines += count + 1;
453
+ }
454
+ if (skipped)
455
+ blocks.push(`// … ещё ${plural(skipped, ['объявление', 'объявления', 'объявлений'])}`);
456
+ return `<pre class="tip-pre">${highlightTs(blocks.join('\n\n'))}</pre>`;
457
+ };
458
+ const fieldTip = (key, path) => {
459
+ const entity = byKey.get(key);
460
+ if (!entity)
461
+ return '';
462
+ if (path.startsWith('@')) {
463
+ const variant = entity.variants.find((v) => `@${v.value}` === path);
464
+ if (!variant)
465
+ return '';
466
+ return (`<b>${esc(entity.discriminatorKey)} = ${esc(variant.value)}</b> <span class="muted">${esc(variant.model)}</span>` +
467
+ `<div class="muted">поля ниже есть только у этого варианта</div>` +
468
+ decoratorHtml(variant.decorators) +
469
+ (variant.doc ? `<div>${md(variant.doc)}</div>` : ''));
470
+ }
471
+ const field = fieldIndex.get(key)?.get(path);
472
+ if (!field)
473
+ return '';
474
+ const type = field.kind === 'embedded'
475
+ ? `embedded · ${field.type ?? ''}`
476
+ : field.kind === 'id'
477
+ ? `PK · ${field.type ?? ''}`
478
+ : displayType(field);
479
+ return (`<b>${esc(key)}.${esc(path)}</b>${field.nullable ? '<span class="muted">?</span>' : ''} <span class="muted">${esc(type)}</span>` +
480
+ decoratorHtml(field.decorators) +
481
+ typeCodeHtml(field) +
482
+ (field.kind === 'embedded'
483
+ ? '<div class="muted">часть документа, не отдельная коллекция</div>'
484
+ : '') +
485
+ (field.values ? `<div class="muted">${field.values.map(esc).join(' | ')}</div>` : '') +
486
+ (field.doc ? `<div class="tip-doc">${md(field.doc)}</div>` : ''));
487
+ };
488
+ const DELETE_TEXT = {
489
+ cascade: 'удаление родителя удалит детей',
490
+ restrict: 'удаление цели запрещено, пока есть ссылка',
491
+ 'set-null': 'при удалении цели ссылка станет null',
492
+ unlink: 'при удалении цели id уберётся из набора'
493
+ };
494
+ const KIND_TEXT = { own: 'композиция', ref: 'ссылка', set: 'набор ссылок' };
495
+ const relationTip = (r, field = fieldIndex.get(r.source)?.get(r.field)) => {
496
+ const from = `${r.source}.${r.field}`;
497
+ const del = r.kind === 'own' && r.onDelete === 'restrict'
498
+ ? 'родителя нельзя удалить, пока есть дети'
499
+ : DELETE_TEXT[r.onDelete];
500
+ return (`<b>${esc(from)}</b> <span style="color:${zoneColor(r.kind === 'own' ? r.source : r.target)}">→ ${esc(r.target)}</span>` +
501
+ `<div class="muted">${KIND_TEXT[r.kind]}${r.variant ? ` · только ${esc(r.variant)}` : ''}${r.nullable ? ' · может быть null' : ''}</div>` +
502
+ `<div><span class="del ${esc(r.onDelete)}">${esc(r.onDelete)}</span> ${esc(del)}</div>` +
503
+ decoratorHtml(field?.decorators) +
504
+ (field?.doc ? `<div class="tip-doc">${md(field.doc)}</div>` : ''));
505
+ };
506
+ const moveTip = (x, y) => {
507
+ const rect = tip.getBoundingClientRect();
508
+ const left = Math.min(x + 14, window.innerWidth - rect.width - 8);
509
+ const top = y + 18 + rect.height > window.innerHeight ? y - rect.height - 12 : y + 18;
510
+ tip.style.left = `${Math.max(8, left)}px`;
511
+ tip.style.top = `${Math.max(8, top)}px`;
512
+ };
513
+ const showTip = (html, e) => {
514
+ tip.innerHTML = html;
515
+ tip.hidden = false;
516
+ moveTip(e.clientX, e.clientY);
517
+ };
518
+ function hideTip() {
519
+ tip.hidden = true;
520
+ }
521
+ const applySearch = () => {
522
+ const q = state.query.trim().toLowerCase();
523
+ svg.classList.toggle('has-search', q !== '');
524
+ let count = 0;
525
+ cardEls.forEach((el, key) => {
526
+ const match = q !== '' && haystack.get(key).some((s) => s.includes(q));
527
+ el.classList.toggle('match', match);
528
+ if (match)
529
+ count++;
530
+ el.querySelectorAll('.xv-row').forEach((row) => {
531
+ row.classList.toggle('match', q !== '' && (row.dataset.path || '').toLowerCase().includes(q));
532
+ });
533
+ });
534
+ $('#xv-search-count').textContent = q ? String(count) : '';
535
+ };
536
+ search.addEventListener('input', () => {
537
+ state.query = search.value;
538
+ applySearch();
539
+ });
540
+ search.addEventListener('keydown', (e) => {
541
+ if (e.key === 'Enter') {
542
+ const q = state.query.trim().toLowerCase();
543
+ const exact = DATA.entities.find((x) => x.key.toLowerCase() === q);
544
+ const first = exact ?? DATA.entities.find((x) => haystack.get(x.key).some((s) => s.includes(q)));
545
+ if (first)
546
+ select(first.key, { reveal: true });
547
+ }
548
+ else if (e.key === 'Escape') {
549
+ search.value = '';
550
+ state.query = '';
551
+ applySearch();
552
+ search.blur();
553
+ }
554
+ });
555
+ const applyHubMode = () => {
556
+ svg.classList.toggle('mode-shortcuts', state.hubMode === 'shortcuts');
557
+ document.querySelectorAll('[data-hub-mode]').forEach((b) => {
558
+ b.setAttribute('aria-pressed', String(b.dataset.hubMode === state.hubMode));
559
+ });
560
+ try {
561
+ localStorage.setItem('xv-hub-mode', state.hubMode);
562
+ }
563
+ catch { }
564
+ applyFocus();
565
+ };
566
+ document.querySelectorAll('[data-hub-mode]').forEach((b) => b.addEventListener('click', () => {
567
+ state.hubMode = b.dataset.hubMode;
568
+ applyHubMode();
569
+ }));
570
+ const displayType = (f) => {
571
+ const ts = f.tsType ? f.tsType.replace(/\s*\|\s*null$/, '') : '';
572
+ return ts && ts !== f.type ? ts : f.type || '';
573
+ };
574
+ const embeddedCount = (fields) => fields.reduce((n, f) => n + (f.kind === 'embedded' ? 1 + embeddedCount(f.fields || []) : 0), 0);
575
+ const listText = (items, max = 4) => items.length > max
576
+ ? `${items.slice(0, max).join(', ')} и ещё ${items.length - max}`
577
+ : items.join(', ');
578
+ const impactLines = (key) => {
579
+ const children = outgoing.get(key).filter((r) => r.kind === 'own');
580
+ const refsIn = incoming.get(key).filter((r) => r.kind !== 'own');
581
+ const blocked = [
582
+ ...children.filter((r) => r.onDelete === 'restrict').map((r) => r.target),
583
+ ...refsIn.filter((r) => r.onDelete === 'restrict').map((r) => `${r.source}.${r.field}`)
584
+ ];
585
+ const cascade = children.filter((r) => r.onDelete === 'cascade').map((r) => r.target);
586
+ const soft = refsIn.filter((r) => r.onDelete !== 'restrict').map((r) => `${r.source}.${r.field}`);
587
+ const lines = [];
588
+ if (blocked.length)
589
+ lines.push(`<span class="del restrict">restrict</span> запрещено, пока есть ${esc(listText(blocked))}`);
590
+ if (cascade.length)
591
+ lines.push(`<span class="del cascade">cascade</span> удалятся вместе: ${esc(listText(cascade))}`);
592
+ if (soft.length)
593
+ lines.push(`<span class="del set-null">set-null</span> обнулятся или отвяжутся: ${esc(listText(soft))}`);
594
+ return lines.length ? lines : ['удаляется без последствий: на неё не ссылаются, состава нет'];
595
+ };
596
+ const modelTip = (key) => {
597
+ const e = byKey.get(key);
598
+ if (!e)
599
+ return '';
600
+ const zone = zoneById.get(DATA.zoneOf[key]);
601
+ const blocks = embeddedCount(e.fields) + e.variants.reduce((n, v) => n + embeddedCount(v.fields), 0);
602
+ const meta = [
603
+ `ключ <code>${esc(e.idKey)}: ${esc(e.idType)}</code>`,
604
+ plural(fieldCount(e), ['поле', 'поля', 'полей']),
605
+ blocks ? plural(blocks, ['embedded-блок', 'embedded-блока', 'embedded-блоков']) : ''
606
+ ].filter(Boolean);
607
+ const links = `связей: исходящих ${outgoing.get(key).length}, входящих ${incoming.get(key).length}${hubs.has(key) ? `; хаб — ссылаются ${DATA.sources[key]} коллекций` : ''}`;
608
+ return (`<b>${esc(key)}</b>` +
609
+ (zone
610
+ ? ` <span class="tip-zone" style="color: var(${zone.colorVar})">${esc(zone.id)} · ${esc(zone.title)}</span>`
611
+ : '') +
612
+ `<div class="muted">модель ${esc(e.model)} · ${esc(e.file)}${e.namespace ? ` · ${esc(e.namespace)}` : ''}</div>` +
613
+ decoratorHtml(e.decorators) +
614
+ (e.doc ? `<div class="tip-doc">${md(e.doc)}</div>` : '') +
615
+ `<div class="tip-meta">${meta.join(' · ')}</div>` +
616
+ `<div class="tip-meta">${links}</div>` +
617
+ (e.variants.length
618
+ ? `<div class="tip-meta">варианты <code>${esc(e.discriminatorKey)}</code>: ${e.variants
619
+ .map((v) => `${esc(v.value)} — ${esc(v.model)}`)
620
+ .join(', ')}</div>`
621
+ : '') +
622
+ `<div class="tip-impact"><div class="muted">при удалении записи</div>${impactLines(key)
623
+ .map((line) => `<div>${line}</div>`)
624
+ .join('')}</div>`);
625
+ };
626
+ const legendEl = $('#xv-legend');
627
+ const legendButton = $('#xv-legend-toggle');
628
+ const legendHtml = () => {
629
+ const sample = (inner) => `<svg width="44" height="14" viewBox="0 0 44 14" aria-hidden="true">${inner}</svg>`;
630
+ const line = (dash = '') => `<line x1="2" y1="7" x2="33" y2="7" stroke="currentColor" stroke-width="1.4"${dash}/>`;
631
+ const arrow = (fill) => `<path d="M42,7 L33,2.5 L33,11.5 Z" fill="${fill}" stroke="currentColor" stroke-width="1.2"/>`;
632
+ const diamond = (fill) => `<path d="M2,7 L9.5,2.5 L17,7 L9.5,11.5 Z" fill="${fill}" stroke="currentColor"/><line x1="17" y1="7" x2="42" y2="7" stroke="currentColor" stroke-width="2"/>`;
633
+ const items = [
634
+ [
635
+ sample(line() + arrow('currentColor')),
636
+ 'Ссылка',
637
+ 'обязательная, удаление цели запрещено (restrict)'
638
+ ],
639
+ [sample(line() + arrow('var(--xv-surface)')), 'Полая стрелка', 'ссылка может быть null'],
640
+ [
641
+ sample(line(' stroke-dasharray="5 4"') + arrow('var(--xv-surface)')),
642
+ 'Пунктир',
643
+ 'при удалении цели ссылка обнулится или уйдёт из набора'
644
+ ],
645
+ [
646
+ sample(`<path d="M2,2 L12,7 L2,12 M2,7 L12,7" fill="none" stroke="currentColor" stroke-width="1.2"/>` +
647
+ `<line x1="12" y1="7" x2="33" y2="7" stroke="currentColor" stroke-width="1.4"/>` +
648
+ arrow('currentColor')),
649
+ 'Воронья лапка',
650
+ 'набор ссылок: массив id в поле'
651
+ ],
652
+ [sample(diamond('currentColor')), 'Ромб', 'композиция: дети удалятся вместе с родителем'],
653
+ [
654
+ sample(diamond('var(--xv-surface)')),
655
+ 'Полый ромб',
656
+ 'композиция: родителя нельзя удалить, пока есть дети'
657
+ ],
658
+ [
659
+ sample(`<defs><pattern id="xv-lg-hatch" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="5" stroke="currentColor" stroke-width="1.4" stroke-opacity="0.45"/></pattern></defs>` +
660
+ `<rect x="1.5" y="1.5" width="41" height="11" rx="1" fill="url(#xv-lg-hatch)" stroke="currentColor" stroke-opacity="0.5"/>`),
661
+ 'Штриховка',
662
+ 'embedded-блок: часть документа, не отдельная коллекция'
663
+ ],
664
+ [
665
+ sample(`<rect x="1.5" y="1.5" width="41" height="11" rx="1" fill="currentColor" fill-opacity="0.18"/>`),
666
+ 'Сплошная полоса',
667
+ 'вариант дискриминатора: ниже поля только этого типа'
668
+ ]
669
+ ];
670
+ let html = `<div class="lg-head"><b>Легенда</b><button type="button" class="lg-close" data-close aria-label="Закрыть легенду">×</button></div>`;
671
+ html += `<p class="quiet">${plural(DATA.entities.length, ['коллекция', 'коллекции', 'коллекций'])}, ${plural(DATA.relations.length, ['связь', 'связи', 'связей'])}. Линия выходит из строки поля и окрашена в цвет зоны, куда ведёт.</p>`;
672
+ html += '<section><h3>Обозначения</h3><div class="legend">';
673
+ html += items
674
+ .map(([svgHtml, title, hint]) => `<div class="lg">${svgHtml}<span>${title}<small>${hint}</small></span></div>`)
675
+ .join('');
676
+ html += '</div></section><section><h3>Зоны</h3><div class="zones">';
677
+ html += DATA.zones
678
+ .map((z) => {
679
+ const count = DATA.entities.filter((e) => DATA.zoneOf[e.key] === z.id).length;
680
+ return `<button type="button" class="zone-row" data-zone="${esc(z.id)}" style="--t: var(${z.colorVar})"><span class="zone-dot"></span><span>${esc(z.id)} · ${esc(z.title)}</span><span class="quiet">${count}</span></button>`;
681
+ })
682
+ .join('');
683
+ html += '</div></section>';
684
+ if (hubs.size) {
685
+ html += `<section><h3>Хабы</h3><p class="quiet">${[...hubs]
686
+ .map((h) => `<button type="button" class="ent-link" data-key="${esc(h)}" style="--t:${zoneColor(h)}">${esc(h)}</button> — ${DATA.sources[h]} источников`)
687
+ .join(', ')}. В режиме «Ярлыки» их связи не рисуются линиями: цель видна в строке поля, а линия появляется при выделении.</p></section>`;
688
+ }
689
+ html +=
690
+ '<section><h3>Как пользоваться</h3><ul class="keys">' +
691
+ '<li>наведение на шапку модели — описание модели, на поле — тип, декоратор и JSDoc</li>' +
692
+ '<li>клик по шапке — закрепить связи модели, по полю — связи поля; повторный клик снимает</li>' +
693
+ '<li><kbd>/</kbd>поиск по коллекциям и полям</li><li><kbd>0</kbd>вписать карту</li>' +
694
+ '<li><kbd>?</kbd>легенда</li><li><kbd>Esc</kbd>сбросить выделение</li></ul></section>';
695
+ html += `<p class="quiet foot">Собрано ${esc(new Date(DATA.generatedAt).toLocaleString('ru-RU'))}</p>`;
696
+ return html;
697
+ };
698
+ function setLegend(open) {
699
+ legendEl.hidden = !open;
700
+ legendButton.setAttribute('aria-expanded', String(open));
701
+ try {
702
+ localStorage.setItem('xv-legend', open ? 'open' : 'closed');
703
+ }
704
+ catch { }
705
+ }
706
+ legendEl.innerHTML = legendHtml();
707
+ legendButton.addEventListener('click', () => setLegend(legendEl.hidden));
708
+ legendEl.addEventListener('click', (e) => {
709
+ const target = e.target;
710
+ if (target.closest('[data-close]'))
711
+ return setLegend(false);
712
+ const zone = target.closest('[data-zone]');
713
+ if (zone)
714
+ return revealZone(zone.dataset.zone);
715
+ const item = target.closest('[data-key]');
716
+ if (item)
717
+ select(item.dataset.key, { reveal: true });
718
+ });
719
+ function select(key, options = {}) {
720
+ state.pin = null;
721
+ state.beforePin = null;
722
+ state.hover = null;
723
+ state.hoverEdge = null;
724
+ state.selected = key && byKey.has(key) ? key : null;
725
+ try {
726
+ history.replaceState(null, '', state.selected ? `#${state.selected}` : location.pathname + location.search);
727
+ }
728
+ catch { }
729
+ applyFocus();
730
+ if (state.selected && options.reveal)
731
+ reveal(state.selected);
732
+ }
733
+ document.addEventListener('keydown', (e) => {
734
+ const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || '');
735
+ if (typing)
736
+ return;
737
+ if (e.key === '/') {
738
+ e.preventDefault();
739
+ search.focus();
740
+ search.select();
741
+ }
742
+ else if (e.key === 'Escape') {
743
+ select(null);
744
+ }
745
+ else if (e.key === '?') {
746
+ setLegend(legendEl.hidden);
747
+ }
748
+ else if (e.key === '0') {
749
+ fitAll();
750
+ }
751
+ else if (e.key === '+' || e.key === '=' || e.key === '-') {
752
+ const factor = e.key === '-' ? 1 / 1.25 : 1.25;
753
+ const w = stage.clientWidth / view.k;
754
+ const h = stage.clientHeight / view.k;
755
+ const k = Math.max(MIN_K, Math.min(MAX_K, view.k * factor));
756
+ animateTo({
757
+ x: view.x + w / 2 - stage.clientWidth / 2 / k,
758
+ y: view.y + h / 2 - stage.clientHeight / 2 / k,
759
+ k
760
+ });
761
+ }
762
+ });
763
+ const onTheme = () => applyView();
764
+ matchMedia('(prefers-color-scheme: dark)').addEventListener('change', onTheme);
765
+ let legendOpen = false;
766
+ try {
767
+ legendOpen = localStorage.getItem('xv-legend') === 'open';
768
+ }
769
+ catch { }
770
+ setLegend(legendOpen);
771
+ let restore = null;
772
+ try {
773
+ restore = JSON.parse(sessionStorage.getItem('xv-restore') || 'null');
774
+ sessionStorage.removeItem('xv-restore');
775
+ }
776
+ catch { }
777
+ applyHubMode();
778
+ if (restore?.view) {
779
+ Object.assign(view, restore.view);
780
+ applyView();
781
+ }
782
+ else
783
+ fitAll(false);
784
+ let stageSize = { w: stage.clientWidth, h: stage.clientHeight };
785
+ new ResizeObserver(() => {
786
+ const w = stage.clientWidth;
787
+ const h = stage.clientHeight;
788
+ if (!w || !h)
789
+ return;
790
+ view.x += (stageSize.w - w) / 2 / view.k;
791
+ view.y += (stageSize.h - h) / 2 / view.k;
792
+ stageSize = { w, h };
793
+ applyView();
794
+ }).observe(stage);
795
+ const params = new URLSearchParams(location.search);
796
+ const query = restore ? restore.query : params.get('q');
797
+ if (query) {
798
+ search.value = query;
799
+ state.query = query;
800
+ applySearch();
801
+ }
802
+ if (restore) {
803
+ if (restore.pin && byKey.has(restore.pin.key)) {
804
+ togglePin(restore.pin.key, restore.pin.path);
805
+ state.beforePin = restore.beforePin ?? null;
806
+ }
807
+ else if (restore.selected && byKey.has(restore.selected)) {
808
+ select(restore.selected);
809
+ }
810
+ }
811
+ else {
812
+ const fromHash = decodeURIComponent(location.hash.slice(1));
813
+ if (byKey.has(fromHash))
814
+ select(fromHash, { reveal: true });
815
+ const pinParam = params.get('pin');
816
+ if (pinParam && pinParam.includes('.')) {
817
+ const dot = pinParam.indexOf('.');
818
+ if (byKey.has(pinParam.slice(0, dot)))
819
+ togglePin(pinParam.slice(0, dot), pinParam.slice(dot + 1));
820
+ }
821
+ }
822
+ if (params.get('hubs') === 'shortcuts' || params.get('hubs') === 'lines') {
823
+ state.hubMode = params.get('hubs');
824
+ applyHubMode();
825
+ }
826
+ ;
827
+ window.__xv = {
828
+ state: () => ({
829
+ view: { ...view },
830
+ selected: state.selected,
831
+ pin: state.pin,
832
+ beforePin: state.beforePin,
833
+ query: state.query
834
+ })
835
+ };
836
+ if (DEBUG) {
837
+ const out = $('#xv-debug');
838
+ const sample = DATA.entities[0];
839
+ out.hidden = false;
840
+ out.textContent = JSON.stringify({
841
+ errors,
842
+ cards: cardEls.size,
843
+ edgesAll: layer('all').querySelectorAll('.xv-edge').length,
844
+ edgesNohub: layer('nohub').querySelectorAll('.xv-edge').length,
845
+ selected: state.selected,
846
+ pin: state.pin,
847
+ locked: svg.classList.contains('is-locked'),
848
+ focusedEdges: svg.querySelectorAll('.xv-edge.is-on').length,
849
+ focusedCards: svg.querySelectorAll('.xv-card.is-on').length,
850
+ searchMatches: svg.querySelectorAll('.xv-card.match').length,
851
+ restored: Boolean(restore),
852
+ floats: [...document.querySelectorAll('.xv-float')].map((el) => {
853
+ const r = el.getBoundingClientRect();
854
+ return [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)];
855
+ }),
856
+ stage: { w: stage.clientWidth, h: stage.clientHeight },
857
+ view: { ...view },
858
+ lod: svg.classList.contains('lod-far'),
859
+ hubMode: state.hubMode,
860
+ legend: legendEl.hidden ? 'closed' : 'open',
861
+ blockRows: svg.querySelectorAll('.xv-row.block').length,
862
+ variantRows: svg.querySelectorAll('.xv-row.variant').length,
863
+ modelTip: sample
864
+ ? modelTip(sample.key)
865
+ .replace(/<[^>]+>/g, ' ')
866
+ .replace(/\s+/g, ' ')
867
+ .slice(0, 300)
868
+ : ''
869
+ });
870
+ }
871
+ })();