json-object-editor 0.10.636 → 0.10.639

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1065 @@
1
+ class JoeMatrix extends HTMLElement {
2
+ constructor() {
3
+ super();
4
+ this.schemas = {};
5
+ this.nodes = [];
6
+ this.links = [];
7
+ this.simulation = null;
8
+ this.svg = null;
9
+ this.g = null;
10
+ this.zoom = null;
11
+ this.width = 0;
12
+ this.height = 0;
13
+ this.selectedNode = null;
14
+ this.mode = 'schema-to-schema';
15
+ this.transform = { x: 0, y: 0, k: 1 };
16
+ this.appMap = {};
17
+ this.selectedApp = '';
18
+ }
19
+
20
+ static get observedAttributes() {
21
+ return ['mode'];
22
+ }
23
+
24
+ connectedCallback() {
25
+ // CSS is loaded externally via link tag in the page
26
+ // Ensure it's loaded if not already
27
+ if (!document.querySelector('link[href*="joe-matrix.css"]')) {
28
+ var link = document.createElement('link');
29
+ link.rel = 'stylesheet';
30
+ link.href = '/JsonObjectEditor/web-components/joe-matrix.css';
31
+ document.head.appendChild(link);
32
+ }
33
+
34
+ this.mode = this.getAttribute('mode') || 'schema-to-schema';
35
+ this.init();
36
+ }
37
+
38
+ attributeChangedCallback(attr, oldValue, newValue) {
39
+ if (attr === 'mode' && newValue !== oldValue) {
40
+ this.mode = newValue;
41
+ if (this.schemas && Object.keys(this.schemas).length > 0) {
42
+ this.updateVisualization();
43
+ }
44
+ }
45
+ }
46
+
47
+ async init() {
48
+ // Wait for D3 to be available
49
+ if (typeof d3 === 'undefined') {
50
+ setTimeout(function() { this.init(); }.bind(this), 100);
51
+ return;
52
+ }
53
+
54
+ this.setupCanvas();
55
+ await this.loadSchemas();
56
+ this.updateVisualization();
57
+ }
58
+
59
+ setupCanvas() {
60
+ // Get container dimensions
61
+ var container = this.parentElement || document.body;
62
+ this.width = container.clientWidth || window.innerWidth;
63
+ this.height = container.clientHeight || window.innerHeight;
64
+
65
+ // Create SVG
66
+ this.svg = d3.select(this)
67
+ .append('svg')
68
+ .attr('class', 'matrix-canvas')
69
+ .attr('width', this.width)
70
+ .attr('height', this.height);
71
+
72
+ // Create main group for zoom/pan
73
+ this.g = this.svg.append('g');
74
+
75
+ // Create tooltip
76
+ this.tooltip = d3.select('body')
77
+ .append('div')
78
+ .attr('class', 'matrix-tooltip');
79
+
80
+ // Setup zoom behavior
81
+ var self = this;
82
+ this.zoom = d3.behavior.zoom()
83
+ .scaleExtent([0.1, 4])
84
+ .on('zoom', function() {
85
+ self.transform = d3.event.translate;
86
+ self.g.attr('transform', 'translate(' + d3.event.translate + ') scale(' + d3.event.scale + ')');
87
+ });
88
+
89
+ this.svg.call(this.zoom);
90
+
91
+ // Add zoom controls
92
+ this.addZoomControls();
93
+
94
+ // Add legend
95
+ this.addLegend();
96
+
97
+ // Handle window resize
98
+ var self = this;
99
+ window.addEventListener('resize', function() {
100
+ self.width = container.clientWidth || window.innerWidth;
101
+ self.height = container.clientHeight || window.innerHeight;
102
+ self.svg.attr('width', self.width).attr('height', self.height);
103
+ if (self.simulation) {
104
+ self.simulation.size([self.width, self.height]);
105
+ }
106
+ });
107
+ }
108
+
109
+ addZoomControls() {
110
+ var controls = d3.select(this)
111
+ .append('div')
112
+ .attr('class', 'matrix-zoom-controls');
113
+
114
+ var self = this;
115
+ controls.append('button')
116
+ .text('+')
117
+ .on('click', function() {
118
+ self.zoom.scale(self.zoom.scale() * 1.5);
119
+ self.svg.transition().call(self.zoom.event);
120
+ });
121
+
122
+ controls.append('button')
123
+ .text('−')
124
+ .on('click', function() {
125
+ self.zoom.scale(self.zoom.scale() / 1.5);
126
+ self.svg.transition().call(self.zoom.event);
127
+ });
128
+
129
+ controls.append('button')
130
+ .text('⌂')
131
+ .attr('title', 'Reset view')
132
+ .on('click', function() {
133
+ self.resetView();
134
+ });
135
+ }
136
+
137
+ addLegend() {
138
+ var legend = d3.select(this)
139
+ .append('div')
140
+ .attr('class', 'matrix-legend');
141
+
142
+ legend.append('h4').text('Legend');
143
+
144
+ var items = [
145
+ { label: 'Schema Node', color: '#4a90e2', border: '#2c5aa0' },
146
+ { label: 'Selected Node', color: '#ff8804', border: '#cc6d03' },
147
+ { label: 'One-to-One', style: 'solid' },
148
+ { label: 'One-to-Many', style: 'dashed' }
149
+ ];
150
+
151
+ items.forEach(item => {
152
+ var itemDiv = legend.append('div').attr('class', 'matrix-legend-item');
153
+ if (item.color) {
154
+ itemDiv.append('div')
155
+ .attr('class', 'legend-color')
156
+ .style('background-color', item.color)
157
+ .style('border-color', item.border);
158
+ } else {
159
+ itemDiv.append('svg')
160
+ .attr('width', 20)
161
+ .attr('height', 20)
162
+ .append('line')
163
+ .attr('x1', 0)
164
+ .attr('y1', 10)
165
+ .attr('x2', 20)
166
+ .attr('y2', 10)
167
+ .style('stroke', '#999')
168
+ .style('stroke-width', '2px')
169
+ .style('stroke-dasharray', item.style === 'dashed' ? '5,5' : 'none');
170
+ }
171
+ itemDiv.append('span').text(item.label);
172
+ });
173
+ }
174
+
175
+ async loadSchemas() {
176
+ var self = this;
177
+ try {
178
+ // Get list of schemas using fetch (no jQuery dependency)
179
+ var baseUrl = location.origin;
180
+ console.log('[Matrix] Loading schemas from:', baseUrl + '/API/list/schemas');
181
+ var schemaNames = await fetch(baseUrl + '/API/list/schemas')
182
+ .then(function(res) { return res.ok ? res.json() : []; })
183
+ .catch(function() { return []; });
184
+
185
+ console.log('[Matrix] Found', schemaNames.length, 'schemas:', schemaNames);
186
+
187
+ // Load all schemas with summaries, then get menuicon from full schema
188
+ var schemaPromises = schemaNames.map(function(name) {
189
+ return fetch(baseUrl + '/API/schema/' + encodeURIComponent(name) + '?summaryOnly=true')
190
+ .then(function(res) {
191
+ return res.ok ? res.json() : null;
192
+ })
193
+ .then(function(data) {
194
+ if (data && !data.error) {
195
+ // API returns {schemas: {schemaName: summary}, ...}
196
+ // Extract the actual summary from the response
197
+ var summary = (data.schemas && data.schemas[name]) || data[name] || data;
198
+
199
+ if (summary) {
200
+ self.schemas[name] = summary;
201
+ // Debug: log relationship info for first few schemas
202
+ if (schemaNames.indexOf(name) < 3) {
203
+ console.log('[Matrix] Schema:', name);
204
+ console.log(' - Raw API response keys:', Object.keys(data));
205
+ console.log(' - Has summary object:', !!summary);
206
+ console.log(' - Summary keys:', summary ? Object.keys(summary) : 'none');
207
+ console.log(' - Has relationships:', !!(summary && summary.relationships));
208
+ if (summary && summary.relationships) {
209
+ console.log(' - Has outbound:', !!summary.relationships.outbound);
210
+ if (summary.relationships.outbound) {
211
+ console.log(' - Outbound count:', summary.relationships.outbound.length);
212
+ console.log(' - Outbound:', JSON.stringify(summary.relationships.outbound, null, 2));
213
+ } else {
214
+ console.log(' - Outbound is:', summary.relationships.outbound);
215
+ }
216
+ } else {
217
+ console.log(' - Relationships object:', summary ? summary.relationships : 'missing');
218
+ }
219
+ }
220
+
221
+ // Load full schema to get menuicon and default_schema (if not in summary)
222
+ if (!summary.menuicon || summary.default_schema === undefined) {
223
+ return fetch(baseUrl + '/API/schema/' + encodeURIComponent(name))
224
+ .then(function(res) {
225
+ return res.ok ? res.json() : null;
226
+ })
227
+ .then(function(fullData) {
228
+ if (fullData && !fullData.error) {
229
+ var fullSchema = (fullData.schemas && fullData.schemas[name]) || fullData[name] || fullData;
230
+ if (fullSchema) {
231
+ if (fullSchema.menuicon && !self.schemas[name].menuicon) {
232
+ self.schemas[name].menuicon = fullSchema.menuicon;
233
+ }
234
+ if (fullSchema.default_schema !== undefined) {
235
+ self.schemas[name].default_schema = fullSchema.default_schema;
236
+ }
237
+ }
238
+ }
239
+ })
240
+ .catch(function() {
241
+ // Ignore errors loading full schema
242
+ });
243
+ }
244
+ } else {
245
+ console.warn('[Matrix] No summary found for schema:', name, 'Response:', data);
246
+ }
247
+ } else {
248
+ console.warn('[Matrix] Failed to load schema:', name, data);
249
+ }
250
+ })
251
+ .catch(function(err) {
252
+ console.warn('[Matrix] Error loading schema', name + ':', err);
253
+ });
254
+ });
255
+
256
+ await Promise.all(schemaPromises);
257
+
258
+ // Load apps via MCP
259
+ try {
260
+ var baseUrl = location.origin;
261
+ var mcpResponse = await fetch(baseUrl + '/mcp', {
262
+ method: 'POST',
263
+ headers: { 'Content-Type': 'application/json' },
264
+ body: JSON.stringify({
265
+ jsonrpc: '2.0',
266
+ id: String(Date.now()),
267
+ method: 'listApps',
268
+ params: {}
269
+ })
270
+ });
271
+
272
+ if (mcpResponse.ok) {
273
+ var mcpData = await mcpResponse.json();
274
+ var appData = (mcpData && (mcpData.result || mcpData)) || {};
275
+ self.appMap = (appData && appData.apps) || {};
276
+
277
+ // Determine which apps use each schema
278
+ Object.keys(self.schemas).forEach(function(schemaName) {
279
+ var usedBy = [];
280
+ for (var appName in self.appMap) {
281
+ var app = self.appMap[appName] || {};
282
+ var cols = Array.isArray(app.collections) ? app.collections : [];
283
+ if (cols.indexOf(schemaName) !== -1) {
284
+ usedBy.push(appName);
285
+ }
286
+ }
287
+ // If schema is a default core schema, show only the JOE app
288
+ var schema = self.schemas[schemaName];
289
+ if (schema && schema.default_schema) {
290
+ usedBy = ['joe'];
291
+ }
292
+ self.schemas[schemaName].apps = usedBy.sort();
293
+ });
294
+
295
+ // Populate app filter dropdown
296
+ self.populateAppFilter();
297
+ }
298
+ } catch (err) {
299
+ console.warn('[Matrix] Error loading apps:', err);
300
+ self.appMap = {};
301
+ }
302
+
303
+ console.log('[Matrix] Loaded', Object.keys(self.schemas).length, 'schemas total');
304
+ } catch (error) {
305
+ console.error('[Matrix] Error loading schemas:', error);
306
+ }
307
+ }
308
+
309
+ updateVisualization() {
310
+ if (this.mode === 'schema-to-schema') {
311
+ this.buildSchemaToSchemaGraph();
312
+ } else if (this.mode === 'object-relationships') {
313
+ // TODO: Implement 3b
314
+ console.log('Object relationships mode not yet implemented');
315
+ } else if (this.mode === 'aggregate-relationships') {
316
+ // TODO: Implement 3c
317
+ console.log('Aggregate relationships mode not yet implemented');
318
+ }
319
+ }
320
+
321
+ buildSchemaToSchemaGraph() {
322
+ var self = this;
323
+
324
+ // Clear existing visualization
325
+ this.g.selectAll('*').remove();
326
+ this.nodes = [];
327
+ this.links = [];
328
+
329
+ // Build nodes from schemas (filtered by app if selected)
330
+ var schemaNames = Object.keys(this.schemas).filter(function(name) {
331
+ return self.shouldShowSchema(name);
332
+ });
333
+ var nodeMap = {};
334
+ var self = this;
335
+ schemaNames.forEach(function(name, i) {
336
+ var schema = self.schemas[name];
337
+ var node = {
338
+ id: name,
339
+ name: name,
340
+ schema: schema, // Store reference to schema (includes menuicon if loaded)
341
+ type: 'schema',
342
+ x: (i % 10) * 150 + 100,
343
+ y: Math.floor(i / 10) * 150 + 100
344
+ };
345
+ self.nodes.push(node);
346
+ nodeMap[name] = node;
347
+ });
348
+
349
+ // Build links from relationships
350
+ console.log('[Matrix] ===== Building Links from Relationships =====');
351
+ console.log('[Matrix] Total schemas loaded:', schemaNames.length);
352
+ console.log('[Matrix] Available schema names:', schemaNames.slice(0, 20).join(', '), schemaNames.length > 20 ? '...' : '');
353
+
354
+ var totalRelationshipsFound = 0;
355
+ var totalLinksCreated = 0;
356
+ var missingTargets = [];
357
+
358
+ schemaNames.forEach(function(name) {
359
+ var schema = self.schemas[name];
360
+
361
+ // Debug specific schemas like 'page' and 'task'
362
+ var isDebugSchema = (name === 'page' || name === 'task');
363
+
364
+ if (isDebugSchema) {
365
+ console.log('[Matrix] === Debugging schema: ' + name + ' ===');
366
+ console.log(' - Schema object exists:', !!schema);
367
+ console.log(' - Schema keys:', schema ? Object.keys(schema) : 'none');
368
+ // When summaryOnly=true, the API returns the summary directly, not wrapped
369
+ // So schema IS the summary, not schema.summary
370
+ console.log(' - Has relationships:', !!(schema && schema.relationships));
371
+ if (schema && schema.relationships) {
372
+ console.log(' - Relationships keys:', Object.keys(schema.relationships));
373
+ }
374
+ }
375
+
376
+ // When summaryOnly=true, the API returns the summary object directly
377
+ // So schema.relationships exists, not schema.summary.relationships
378
+ if (schema && schema.relationships && schema.relationships.outbound) {
379
+ var outbound = schema.relationships.outbound;
380
+ totalRelationshipsFound += outbound.length;
381
+
382
+ if (isDebugSchema) {
383
+ console.log(' - Has outbound relationships:', outbound.length);
384
+ console.log(' - Outbound data:', JSON.stringify(outbound, null, 4));
385
+ }
386
+
387
+ outbound.forEach(function(rel) {
388
+ if (isDebugSchema) {
389
+ console.log(' - Processing relationship:', rel.field, '→', rel.targetSchema, '(' + rel.cardinality + ')');
390
+ }
391
+
392
+ // Handle targetSchema that might be a string like "user|group"
393
+ var targetSchemas = (rel.targetSchema || '').split('|');
394
+
395
+ if (isDebugSchema) {
396
+ console.log(' Split into target schemas:', targetSchemas);
397
+ }
398
+
399
+ targetSchemas.forEach(function(targetSchema) {
400
+ targetSchema = targetSchema.trim();
401
+ var targetExists = !!(targetSchema && nodeMap[targetSchema]);
402
+
403
+ if (isDebugSchema) {
404
+ console.log(' Checking target "' + targetSchema + '":', targetExists ? '✓ EXISTS' : '✗ NOT FOUND');
405
+ if (!targetExists && targetSchema) {
406
+ var similar = schemaNames.filter(function(n) {
407
+ return n.toLowerCase().indexOf(targetSchema.toLowerCase()) !== -1 ||
408
+ targetSchema.toLowerCase().indexOf(n.toLowerCase()) !== -1;
409
+ });
410
+ if (similar.length > 0) {
411
+ console.log(' Similar schema names found:', similar.join(', '));
412
+ }
413
+ }
414
+ }
415
+
416
+ if (targetSchema && nodeMap[targetSchema]) {
417
+ var link = {
418
+ source: nodeMap[name],
419
+ target: nodeMap[targetSchema],
420
+ field: rel.field,
421
+ cardinality: rel.cardinality || 'one',
422
+ type: 'schema-relationship'
423
+ };
424
+ self.links.push(link);
425
+ totalLinksCreated++;
426
+
427
+ if (isDebugSchema) {
428
+ console.log(' ✓ Created link:', name, '→', targetSchema, 'via field "' + rel.field + '"');
429
+ }
430
+ } else if (targetSchema) {
431
+ missingTargets.push({ from: name, to: targetSchema, field: rel.field });
432
+ }
433
+ });
434
+ });
435
+ } else {
436
+ if (isDebugSchema) {
437
+ console.log(' - ⚠ No outbound relationships found');
438
+ if (schema) {
439
+ console.log(' Schema keys:', Object.keys(schema));
440
+ if (schema.relationships) {
441
+ console.log(' Relationships keys:', Object.keys(schema.relationships));
442
+ console.log(' Relationships object:', schema.relationships);
443
+ } else {
444
+ console.log(' No relationships property found');
445
+ }
446
+ }
447
+ }
448
+ }
449
+ });
450
+
451
+ console.log('[Matrix] ===== Link Building Summary =====');
452
+ console.log('[Matrix] Total relationships found:', totalRelationshipsFound);
453
+ console.log('[Matrix] Total links created:', totalLinksCreated);
454
+ console.log('[Matrix] Missing target schemas:', missingTargets.length);
455
+ if (missingTargets.length > 0) {
456
+ console.log('[Matrix] Missing targets:', missingTargets.slice(0, 10));
457
+ }
458
+ console.log('[Matrix] Final link count:', self.links.length);
459
+
460
+ if (self.links.length === 0) {
461
+ console.warn('[Matrix] ⚠ WARNING: No links were created!');
462
+ console.warn('[Matrix] Possible reasons:');
463
+ console.warn('[Matrix] 1. Schemas don\'t have summary.relationships.outbound');
464
+ console.warn('[Matrix] 2. Target schemas don\'t exist in loaded schemas');
465
+ console.warn('[Matrix] 3. Data structure is different than expected');
466
+ console.warn('[Matrix] Check the API response: /API/schema/page?summaryOnly=true');
467
+ }
468
+
469
+ // Create force simulation (D3 v3 API)
470
+ this.simulation = d3.layout.force()
471
+ .nodes(this.nodes)
472
+ .links(this.links)
473
+ .size([this.width, this.height])
474
+ .linkDistance(150)
475
+ .charge(-500)
476
+ .gravity(0.1)
477
+ .on('tick', function() {
478
+ self.tick();
479
+ });
480
+
481
+ // Draw links FIRST (so they appear behind nodes) - make sure they're visible
482
+ var link = this.g.selectAll('.matrix-link')
483
+ .data(this.links)
484
+ .enter()
485
+ .append('line')
486
+ .attr('class', function(d) {
487
+ return 'matrix-link ' + (d.cardinality === 'many' ? 'many' : 'one');
488
+ })
489
+ .attr('x1', function(d) { return d.source.x || 0; })
490
+ .attr('y1', function(d) { return d.source.y || 0; })
491
+ .attr('x2', function(d) { return d.target.x || 0; })
492
+ .attr('y2', function(d) { return d.target.y || 0; })
493
+ .style('stroke', '#999')
494
+ .style('stroke-width', function(d) {
495
+ return d.cardinality === 'many' ? '1.5px' : '2px';
496
+ })
497
+ .style('stroke-opacity', '0.6')
498
+ .style('stroke-dasharray', function(d) {
499
+ return d.cardinality === 'many' ? '5,5' : 'none';
500
+ })
501
+ .style('fill', 'none')
502
+ .style('pointer-events', 'none');
503
+
504
+ // Draw link labels - hidden by default, shown when node is selected
505
+ var linkLabelGroup = this.g.selectAll('.matrix-link-label-group')
506
+ .data(this.links)
507
+ .enter()
508
+ .append('g')
509
+ .attr('class', 'matrix-link-label-group')
510
+ .style('opacity', 0)
511
+ .style('display', 'none');
512
+
513
+ // Add background rectangle for label
514
+ linkLabelGroup.append('rect')
515
+ .attr('class', 'matrix-link-label-bg')
516
+ .attr('x', function(d) { return ((d.source.x || 0) + (d.target.x || 0)) / 2 - 20; })
517
+ .attr('y', function(d) { return ((d.source.y || 0) + (d.target.y || 0)) / 2 - 8; })
518
+ .attr('width', 40)
519
+ .attr('height', 16)
520
+ .attr('rx', 4)
521
+ .style('fill', 'rgba(255, 255, 255, 0.9)')
522
+ .style('stroke', '#ddd')
523
+ .style('stroke-width', '1px');
524
+
525
+ // Add text label
526
+ linkLabelGroup.append('text')
527
+ .attr('class', 'matrix-link-label')
528
+ .attr('x', function(d) { return ((d.source.x || 0) + (d.target.x || 0)) / 2; })
529
+ .attr('y', function(d) { return ((d.source.y || 0) + (d.target.y || 0)) / 2 + 4; })
530
+ .text(function(d) {
531
+ return d.field.split('.').pop();
532
+ })
533
+ .style('pointer-events', 'none');
534
+
535
+ // Draw nodes AFTER links (so they appear on top)
536
+ var node = this.g.selectAll('.matrix-node')
537
+ .data(this.nodes)
538
+ .enter()
539
+ .append('g')
540
+ .attr('class', 'matrix-node')
541
+ .attr('transform', function(d) { return 'translate(' + (d.x || 0) + ',' + (d.y || 0) + ')'; })
542
+ .call(this.simulation.drag)
543
+ .on('click', function(d) {
544
+ self.selectNode(d);
545
+ })
546
+ .on('mouseover', function(d) {
547
+ self.showTooltip(d);
548
+ })
549
+ .on('mouseout', function() {
550
+ self.hideTooltip();
551
+ });
552
+
553
+ // Add icon if available, otherwise use circle
554
+ node.each(function(d) {
555
+ var nodeGroup = d3.select(this);
556
+ var schema = d.schema;
557
+ var menuicon = null;
558
+
559
+ // Get menuicon from schema (loaded separately if not in summary)
560
+ if (schema && schema.menuicon) {
561
+ menuicon = schema.menuicon;
562
+ }
563
+
564
+ // Always add a background circle for clickability (larger hitbox)
565
+ // Note: Base styles are in CSS, only pointer-events and cursor set here
566
+ var bgCircle = nodeGroup.append('circle')
567
+ .attr('class', 'matrix-node-bg')
568
+ .attr('r', 16) // Larger radius for better clickability
569
+ .attr('cx', 0)
570
+ .attr('cy', 0)
571
+ .style('pointer-events', 'all')
572
+ .style('cursor', 'pointer');
573
+ // Fill, stroke, and stroke-width are handled by CSS
574
+
575
+ if (menuicon && typeof menuicon === 'string' && menuicon.indexOf('<svg') !== -1) {
576
+ // Use foreignObject to embed HTML SVG
577
+ var iconGroup = nodeGroup.append('g')
578
+ .attr('class', 'matrix-node-icon')
579
+ .attr('transform', 'translate(-12,-12)')
580
+ .style('pointer-events', 'none'); // Let clicks pass through to the circle
581
+
582
+ var foreignObject = iconGroup.append('foreignObject')
583
+ .attr('width', 24)
584
+ .attr('height', 24)
585
+ .attr('x', 0)
586
+ .attr('y', 0)
587
+ .style('pointer-events', 'none');
588
+
589
+ // Create a div to hold the SVG HTML
590
+ var iconDiv = foreignObject.append('xhtml:div')
591
+ .style('width', '24px')
592
+ .style('height', '24px')
593
+ .style('overflow', 'hidden')
594
+ .style('line-height', '0')
595
+ .style('pointer-events', 'none');
596
+
597
+ // Parse and modify the SVG to fit
598
+ try {
599
+ var tempDiv = document.createElement('div');
600
+ tempDiv.innerHTML = menuicon;
601
+ var svgElement = tempDiv.querySelector('svg');
602
+
603
+ if (svgElement) {
604
+ // Get viewBox or set default
605
+ var viewBox = svgElement.getAttribute('viewBox') || '0 0 24 24';
606
+ svgElement.setAttribute('width', '24');
607
+ svgElement.setAttribute('height', '24');
608
+ svgElement.setAttribute('viewBox', viewBox);
609
+ svgElement.style.width = '24px';
610
+ svgElement.style.height = '24px';
611
+ svgElement.style.display = 'block';
612
+
613
+ // Validate and fix SVG paths before setting
614
+ var paths = svgElement.querySelectorAll('path');
615
+ var validElements = false;
616
+ var nodeName = d.name || 'unknown';
617
+ paths.forEach(function(path) {
618
+ var pathData = path.getAttribute('d');
619
+ if (pathData) {
620
+ var trimmed = pathData.trim();
621
+ // Check if path starts with moveto command (M or m)
622
+ if (!/^[Mm]/.test(trimmed)) {
623
+ // Invalid path - try to fix by prepending M 0,0
624
+ console.warn('[Matrix] Invalid SVG path found for schema', nodeName + ', fixing:', trimmed.substring(0, 20));
625
+ // Try to fix by prepending a moveto
626
+ path.setAttribute('d', 'M 0,0 ' + trimmed);
627
+ validElements = true;
628
+ } else {
629
+ validElements = true;
630
+ }
631
+ }
632
+ });
633
+
634
+ // Check for other valid elements
635
+ if (!validElements) {
636
+ validElements = !!(svgElement.querySelector('circle') || svgElement.querySelector('rect') || svgElement.querySelector('polygon') || svgElement.querySelector('polyline'));
637
+ }
638
+
639
+ // Only set if we have valid content
640
+ if (validElements) {
641
+ iconDiv.node().innerHTML = svgElement.outerHTML;
642
+ } else {
643
+ // No valid paths, just show the background circle
644
+ console.warn('[Matrix] No valid SVG elements found for', d.name || 'unknown');
645
+ iconGroup.remove();
646
+ }
647
+ } else {
648
+ // No SVG element found, remove icon group
649
+ iconGroup.remove();
650
+ }
651
+ } catch (err) {
652
+ console.warn('[Matrix] Error parsing SVG icon for', d.name || 'unknown' + ':', err);
653
+ // Remove icon group on error, just show background circle
654
+ iconGroup.remove();
655
+ }
656
+ }
657
+ // If no icon, we already have the background circle, so we're done
658
+ });
659
+
660
+ node.append('text')
661
+ .attr('dy', 20)
662
+ .text(function(d) {
663
+ return d.name;
664
+ });
665
+
666
+ // Start simulation
667
+ this.simulation.start();
668
+ }
669
+
670
+ tick() {
671
+ var self = this;
672
+ this.g.selectAll('.matrix-link')
673
+ .attr('x1', function(d) { return d.source.x; })
674
+ .attr('y1', function(d) { return d.source.y; })
675
+ .attr('x2', function(d) { return d.target.x; })
676
+ .attr('y2', function(d) { return d.target.y; });
677
+
678
+ // Update link label positions
679
+ this.g.selectAll('.matrix-link-label-group')
680
+ .each(function(d) {
681
+ var group = d3.select(this);
682
+ var x = (d.source.x + d.target.x) / 2;
683
+ var y = (d.source.y + d.target.y) / 2;
684
+ group.select('.matrix-link-label-bg')
685
+ .attr('x', x - 20)
686
+ .attr('y', y - 8);
687
+ group.select('.matrix-link-label')
688
+ .attr('x', x)
689
+ .attr('y', y + 4);
690
+ });
691
+
692
+ this.g.selectAll('.matrix-node')
693
+ .attr('transform', function(d) { return 'translate(' + d.x + ',' + d.y + ')'; });
694
+ }
695
+
696
+ selectNode(node) {
697
+ // Remove previous selection
698
+ this.g.selectAll('.matrix-node').classed('selected', false);
699
+
700
+ // Select new node
701
+ var self = this;
702
+ var nodeElement = this.g.selectAll('.matrix-node')
703
+ .filter(function(d) { return d.id === node.id; })
704
+ .classed('selected', true);
705
+
706
+ this.selectedNode = node;
707
+
708
+ // Show schema properties panel
709
+ this.showSchemaProperties(node);
710
+
711
+ // Highlight connected nodes and links
712
+ this.highlightConnections(node);
713
+ }
714
+
715
+ highlightConnections(node) {
716
+ var self = this;
717
+
718
+ if (!node) {
719
+ // Reset all to normal
720
+ this.g.selectAll('.matrix-link').style('stroke-opacity', 0.6);
721
+ this.g.selectAll('.matrix-node').style('opacity', 1);
722
+ // Hide all link labels
723
+ this.g.selectAll('.matrix-link-label-group')
724
+ .style('opacity', 0)
725
+ .style('display', 'none');
726
+ return;
727
+ }
728
+
729
+ // Reset all links
730
+ this.g.selectAll('.matrix-link').style('stroke-opacity', 0.1);
731
+ this.g.selectAll('.matrix-node').style('opacity', 0.3);
732
+ // Hide all link labels initially
733
+ this.g.selectAll('.matrix-link-label-group')
734
+ .style('opacity', 0)
735
+ .style('display', 'none');
736
+
737
+ // Highlight selected node
738
+ this.g.selectAll('.matrix-node')
739
+ .filter(function(d) {
740
+ return d.id === node.id;
741
+ })
742
+ .style('opacity', 1);
743
+
744
+ // Highlight connected nodes and links, and show their labels
745
+ var connectedNodeIds = {};
746
+ var connectedLinks = [];
747
+ connectedNodeIds[node.id] = true;
748
+
749
+ this.links.forEach(function(link) {
750
+ var sourceId = (typeof link.source === 'object' ? link.source.id : link.source);
751
+ var targetId = (typeof link.target === 'object' ? link.target.id : link.target);
752
+
753
+ if (sourceId === node.id || targetId === node.id) {
754
+ connectedNodeIds[sourceId] = true;
755
+ connectedNodeIds[targetId] = true;
756
+ connectedLinks.push(link);
757
+
758
+ self.g.selectAll('.matrix-link')
759
+ .filter(function(d) {
760
+ var dSourceId = (typeof d.source === 'object' ? d.source.id : d.source);
761
+ var dTargetId = (typeof d.target === 'object' ? d.target.id : d.target);
762
+ return (dSourceId === sourceId && dTargetId === targetId) ||
763
+ (dSourceId === targetId && dTargetId === sourceId);
764
+ })
765
+ .style('stroke-opacity', 1)
766
+ .style('stroke-width', '2.5px');
767
+ }
768
+ });
769
+
770
+ // Show labels for connected links
771
+ connectedLinks.forEach(function(link) {
772
+ self.g.selectAll('.matrix-link-label-group')
773
+ .filter(function(d) {
774
+ var dSourceId = (typeof d.source === 'object' ? d.source.id : d.source);
775
+ var dTargetId = (typeof d.target === 'object' ? d.target.id : d.target);
776
+ var linkSourceId = (typeof link.source === 'object' ? link.source.id : link.source);
777
+ var linkTargetId = (typeof link.target === 'object' ? link.target.id : link.target);
778
+ return (dSourceId === linkSourceId && dTargetId === linkTargetId) ||
779
+ (dSourceId === linkTargetId && dTargetId === linkSourceId);
780
+ })
781
+ .style('opacity', 1)
782
+ .style('display', 'block');
783
+ });
784
+
785
+ Object.keys(connectedNodeIds).forEach(function(id) {
786
+ self.g.selectAll('.matrix-node')
787
+ .filter(function(d) {
788
+ return d.id === id;
789
+ })
790
+ .style('opacity', 1);
791
+ });
792
+ }
793
+
794
+ showTooltip(node) {
795
+ var self = this;
796
+ var schema = node.schema;
797
+ var tooltipText = '<div style="max-width:300px;">';
798
+ tooltipText += '<strong style="font-size:14px;color:#fff;">' + node.name + '</strong><br/>';
799
+
800
+ // When summaryOnly=true, schema IS the summary object
801
+ if (schema) {
802
+ if (schema.description) {
803
+ tooltipText += '<div style="margin-top:6px;font-size:12px;color:#ddd;">' + schema.description + '</div>';
804
+ }
805
+
806
+ if (schema.relationships && schema.relationships.outbound && schema.relationships.outbound.length > 0) {
807
+ tooltipText += '<div style="margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.3);">';
808
+ tooltipText += '<strong style="font-size:12px;color:#fff;">Relationships (' + schema.relationships.outbound.length + '):</strong><br/>';
809
+ tooltipText += '<div style="margin-top:4px;font-size:11px;">';
810
+
811
+ schema.relationships.outbound.forEach(function(rel) {
812
+ var targetSchemas = (rel.targetSchema || '').split('|');
813
+ var cardinalityIcon = rel.cardinality === 'many' ? '⟷' : '→';
814
+
815
+ targetSchemas.forEach(function(targetSchema) {
816
+ targetSchema = targetSchema.trim();
817
+ var linkExists = self.links.some(function(link) {
818
+ var sourceId = (typeof link.source === 'object' ? link.source.id : link.source);
819
+ var targetId = (typeof link.target === 'object' ? link.target.id : link.target);
820
+ return (sourceId === node.name && targetId === targetSchema);
821
+ });
822
+ var existsMarker = linkExists ? '✓' : '⚠';
823
+
824
+ tooltipText += '<div style="margin:2px 0;">';
825
+ tooltipText += '<code style="background:rgba(255,255,255,0.2);padding:1px 4px;border-radius:2px;font-size:10px;">' + rel.field + '</code> ';
826
+ tooltipText += '<span>' + cardinalityIcon + '</span> ';
827
+ tooltipText += '<strong>' + targetSchema + '</strong> ';
828
+ tooltipText += '<span style="color:#bbb;">(' + rel.cardinality + ')</span> ';
829
+ tooltipText += '<span style="color:' + (linkExists ? '#4caf50' : '#ff9800') + ';">' + existsMarker + '</span>';
830
+ tooltipText += '</div>';
831
+ });
832
+ });
833
+
834
+ tooltipText += '</div></div>';
835
+ } else {
836
+ tooltipText += '<div style="margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.3);color:#bbb;font-size:11px;">No relationships defined</div>';
837
+ }
838
+ }
839
+
840
+ tooltipText += '</div>';
841
+
842
+ this.tooltip
843
+ .html(tooltipText)
844
+ .classed('visible', true)
845
+ .style('left', (d3.event.pageX + 10) + 'px')
846
+ .style('top', (d3.event.pageY - 10) + 'px');
847
+ }
848
+
849
+ hideTooltip() {
850
+ this.tooltip.classed('visible', false);
851
+ }
852
+
853
+ resetView() {
854
+ this.zoom.scale(1);
855
+ this.zoom.translate([0, 0]);
856
+ this.svg.transition()
857
+ .duration(750)
858
+ .call(this.zoom.event);
859
+
860
+ // Reset node highlighting
861
+ this.g.selectAll('.matrix-link').style('stroke-opacity', 0.6).style('opacity', 1);
862
+ this.g.selectAll('.matrix-node').style('opacity', 1).classed('selected', false);
863
+ this.selectedNode = null;
864
+ }
865
+
866
+ fitToScreen() {
867
+ if (this.nodes.length === 0) return;
868
+
869
+ var bounds = this.nodes.reduce(function(acc, node) {
870
+ return {
871
+ minX: Math.min(acc.minX, node.x),
872
+ maxX: Math.max(acc.maxX, node.x),
873
+ minY: Math.min(acc.minY, node.y),
874
+ maxY: Math.max(acc.maxY, node.y)
875
+ };
876
+ }, { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity });
877
+
878
+ var graphWidth = bounds.maxX - bounds.minX;
879
+ var graphHeight = bounds.maxY - bounds.minY;
880
+ var scale = Math.min(this.width / graphWidth, this.height / graphHeight) * 0.8;
881
+ var translateX = (this.width - (bounds.minX + bounds.maxX) * scale) / 2;
882
+ var translateY = (this.height - (bounds.minY + bounds.maxY) * scale) / 2;
883
+
884
+ this.zoom.scale(scale);
885
+ this.zoom.translate([translateX, translateY]);
886
+ this.svg.transition()
887
+ .duration(750)
888
+ .call(this.zoom.event);
889
+ }
890
+
891
+ showSchemaProperties(node) {
892
+ var self = this;
893
+ var schema = node.schema;
894
+
895
+ // Remove existing properties panel
896
+ var existing = document.getElementById('matrix-properties-panel');
897
+ if (existing) {
898
+ existing.remove();
899
+ }
900
+
901
+ // Create properties panel
902
+ var panel = document.createElement('div');
903
+ panel.id = 'matrix-properties-panel';
904
+
905
+ var html = '<div class="matrix-props-header">';
906
+ // Add icon if available
907
+ var iconHtml = '';
908
+ if (schema && schema.menuicon && typeof schema.menuicon === 'string' && schema.menuicon.indexOf('<svg') !== -1) {
909
+ iconHtml = '<span class="matrix-props-title-icon">' + schema.menuicon + '</span>';
910
+ }
911
+ html += '<h2 class="matrix-props-title">' + iconHtml + '<span class="matrix-props-title-text">' + node.name + '</span></h2>';
912
+ html += '<button id="close-properties">×</button>';
913
+ html += '</div>';
914
+
915
+ // When summaryOnly=true, schema IS the summary object
916
+ if (schema) {
917
+ if (schema.description) {
918
+ html += '<div class="matrix-props-section"><strong>Description:</strong><br/><span class="matrix-props-text">' + (schema.description || 'N/A') + '</span></div>';
919
+ }
920
+ if (schema.purpose) {
921
+ html += '<div class="matrix-props-section"><strong>Purpose:</strong><br/><span class="matrix-props-text">' + (schema.purpose || 'N/A') + '</span></div>';
922
+ }
923
+ if (schema.labelField) {
924
+ html += '<div class="matrix-props-section"><strong>Label Field:</strong> <code>' + schema.labelField + '</code></div>';
925
+ }
926
+ if (schema.source) {
927
+ html += '<div class="matrix-props-section"><strong>Source:</strong> <span class="matrix-props-text">' + schema.source + '</span></div>';
928
+ }
929
+ }
930
+
931
+ // Relationships - Show prominently at the top
932
+ // When summaryOnly=true, schema.relationships exists directly
933
+ if (schema && schema.relationships && schema.relationships.outbound && schema.relationships.outbound.length > 0) {
934
+ html += '<div class="matrix-props-relationships">';
935
+ html += '<strong class="matrix-props-relationships-title">Outbound Relationships (' + schema.relationships.outbound.length + '):</strong>';
936
+ html += '<div class="matrix-props-relationships-list">';
937
+ schema.relationships.outbound.forEach(function(rel) {
938
+ var targetSchemas = (rel.targetSchema || '').split('|');
939
+ var cardinalityIcon = rel.cardinality === 'many' ? '⟷' : '→';
940
+ var cardinalityClass = rel.cardinality === 'many' ? 'many' : 'one';
941
+
942
+ targetSchemas.forEach(function(targetSchema) {
943
+ targetSchema = targetSchema.trim();
944
+ var linkExists = self.links.some(function(link) {
945
+ var sourceId = (typeof link.source === 'object' ? link.source.id : link.source);
946
+ var targetId = (typeof link.target === 'object' ? link.target.id : link.target);
947
+ return (sourceId === node.name && targetId === targetSchema) ||
948
+ (sourceId === targetSchema && targetId === node.name);
949
+ });
950
+ var missingClass = linkExists ? '' : ' missing';
951
+
952
+ html += '<div class="matrix-props-relationship-item ' + cardinalityClass + missingClass + '">';
953
+ html += '<code class="matrix-props-relationship-field">' + rel.field + '</code>';
954
+ html += '<span class="matrix-props-relationship-arrow">' + cardinalityIcon + '</span>';
955
+ html += '<strong>' + targetSchema + '</strong>';
956
+ html += ' <span class="matrix-props-relationship-cardinality">(' + rel.cardinality + ')</span>';
957
+ if (!linkExists) {
958
+ html += ' <span class="matrix-props-relationship-warning">[target not loaded]</span>';
959
+ }
960
+ html += '</div>';
961
+ });
962
+ });
963
+ html += '</div></div>';
964
+ } else {
965
+ html += '<div class="matrix-props-no-relationships">';
966
+ html += '<strong>No outbound relationships found</strong><br/>';
967
+ html += '<span class="matrix-props-no-relationships-text">This schema may not have relationships defined in its summary.</span>';
968
+ html += '</div>';
969
+ }
970
+
971
+ // Fields
972
+ if (schema && schema.fields && schema.fields.length > 0) {
973
+ html += '<div class="matrix-props-section"><strong>Fields (' + schema.fields.length + '):</strong>';
974
+ html += '<div class="matrix-props-fields-container">';
975
+ schema.fields.forEach(function(field) {
976
+ var fieldInfo = '<div class="matrix-props-field-item">';
977
+ fieldInfo += '<code>' + field.name + '</code>';
978
+ fieldInfo += ' <span class="matrix-props-field-type">(' + field.type;
979
+ if (field.isArray) fieldInfo += '[]';
980
+ if (field.isReference) fieldInfo += ', ref';
981
+ if (field.targetSchema) fieldInfo += ' → ' + field.targetSchema;
982
+ if (field.required) fieldInfo += ', required';
983
+ fieldInfo += ')</span>';
984
+ if (field.comment || field.tooltip) {
985
+ fieldInfo += '<br/><span class="matrix-props-field-comment">' + (field.comment || field.tooltip || '') + '</span>';
986
+ }
987
+ fieldInfo += '</div>';
988
+ html += fieldInfo;
989
+ });
990
+ html += '</div></div>';
991
+ }
992
+
993
+ // Searchable fields
994
+ if (schema && schema.searchableFields && schema.searchableFields.length > 0) {
995
+ html += '<div class="matrix-props-section"><strong>Searchable Fields:</strong><br/>';
996
+ html += '<div class="matrix-props-searchable-list">';
997
+ schema.searchableFields.forEach(function(field) {
998
+ html += '<span class="matrix-props-searchable-tag">' + field + '</span>';
999
+ });
1000
+ html += '</div></div>';
1001
+ }
1002
+
1003
+ panel.innerHTML = html;
1004
+ this.appendChild(panel);
1005
+
1006
+ // Close button handler
1007
+ document.getElementById('close-properties').addEventListener('click', function() {
1008
+ panel.remove();
1009
+ self.g.selectAll('.matrix-node').classed('selected', false);
1010
+ self.selectedNode = null;
1011
+ self.highlightConnections(null);
1012
+ });
1013
+ }
1014
+
1015
+ disconnectedCallback() {
1016
+ if (this.simulation) {
1017
+ this.simulation.stop();
1018
+ }
1019
+ }
1020
+
1021
+ populateAppFilter() {
1022
+ var appFilter = document.getElementById('app-filter');
1023
+ if (!appFilter) return;
1024
+
1025
+ // Clear existing options except "All"
1026
+ appFilter.innerHTML = '<option value="">All</option>';
1027
+
1028
+ // Add app options
1029
+ var appNames = Object.keys(this.appMap || {}).sort();
1030
+ var self = this;
1031
+ appNames.forEach(function(appName) {
1032
+ var opt = document.createElement('option');
1033
+ opt.value = appName;
1034
+ opt.textContent = appName;
1035
+ appFilter.appendChild(opt);
1036
+ });
1037
+ }
1038
+
1039
+ setAppFilter(appName) {
1040
+ this.selectedApp = appName || '';
1041
+ // Rebuild the graph with filtered schemas
1042
+ if (this.mode === 'schema-to-schema') {
1043
+ this.buildSchemaToSchemaGraph();
1044
+ }
1045
+ }
1046
+
1047
+ shouldShowSchema(schemaName) {
1048
+ // Always show core schemas: user, tag, and status
1049
+ var coreSchemas = ['user', 'tag', 'status'];
1050
+ if (coreSchemas.indexOf(schemaName) !== -1) {
1051
+ return true;
1052
+ }
1053
+
1054
+ if (!this.selectedApp) {
1055
+ return true; // Show all if no filter
1056
+ }
1057
+ var schema = this.schemas[schemaName];
1058
+ if (!schema || !schema.apps) {
1059
+ return false; // Hide if no app info
1060
+ }
1061
+ return schema.apps.indexOf(this.selectedApp) !== -1;
1062
+ }
1063
+ }
1064
+
1065
+ window.customElements.define('joe-matrix', JoeMatrix);