UNPKG

json-object-editor

Version:

JOE the Json Object Editor | Platform Edition

1,066 lines (938 loc) 41.7 kB
class JoeMatrix extends HTMLElement { constructor() { super(); this.schemas = {}; this.nodes = []; this.links = []; this.simulation = null; this.svg = null; this.g = null; this.zoom = null; this.width = 0; this.height = 0; this.selectedNode = null; this.mode = 'schema-to-schema'; this.transform = { x: 0, y: 0, k: 1 }; this.appMap = {}; this.selectedApp = ''; } static get observedAttributes() { return ['mode']; } connectedCallback() { // CSS is loaded externally via link tag in the page // Ensure it's loaded if not already if (!document.querySelector('link[href*="joe-matrix.css"]')) { var link = document.createElement('link'); link.rel = 'stylesheet'; link.href = '/JsonObjectEditor/web-components/joe-matrix.css'; document.head.appendChild(link); } this.mode = this.getAttribute('mode') || 'schema-to-schema'; this.init(); } attributeChangedCallback(attr, oldValue, newValue) { if (attr === 'mode' && newValue !== oldValue) { this.mode = newValue; if (this.schemas && Object.keys(this.schemas).length > 0) { this.updateVisualization(); } } } async init() { // Wait for D3 to be available if (typeof d3 === 'undefined') { setTimeout(function() { this.init(); }.bind(this), 100); return; } this.setupCanvas(); await this.loadSchemas(); this.updateVisualization(); } setupCanvas() { // Get container dimensions var container = this.parentElement || document.body; this.width = container.clientWidth || window.innerWidth; this.height = container.clientHeight || window.innerHeight; // Create SVG this.svg = d3.select(this) .append('svg') .attr('class', 'matrix-canvas') .attr('width', this.width) .attr('height', this.height); // Create main group for zoom/pan this.g = this.svg.append('g'); // Create tooltip this.tooltip = d3.select('body') .append('div') .attr('class', 'matrix-tooltip'); // Setup zoom behavior var self = this; this.zoom = d3.behavior.zoom() .scaleExtent([0.1, 4]) .on('zoom', function() { self.transform = d3.event.translate; self.g.attr('transform', 'translate(' + d3.event.translate + ') scale(' + d3.event.scale + ')'); }); this.svg.call(this.zoom); // Add zoom controls this.addZoomControls(); // Add legend this.addLegend(); // Handle window resize var self = this; window.addEventListener('resize', function() { self.width = container.clientWidth || window.innerWidth; self.height = container.clientHeight || window.innerHeight; self.svg.attr('width', self.width).attr('height', self.height); if (self.simulation) { self.simulation.size([self.width, self.height]); } }); } addZoomControls() { var controls = d3.select(this) .append('div') .attr('class', 'matrix-zoom-controls'); var self = this; controls.append('button') .text('+') .on('click', function() { self.zoom.scale(self.zoom.scale() * 1.5); self.svg.transition().call(self.zoom.event); }); controls.append('button') .text('−') .on('click', function() { self.zoom.scale(self.zoom.scale() / 1.5); self.svg.transition().call(self.zoom.event); }); controls.append('button') .text('⌂') .attr('title', 'Reset view') .on('click', function() { self.resetView(); }); } addLegend() { var legend = d3.select(this) .append('div') .attr('class', 'matrix-legend'); legend.append('h4').text('Legend'); var items = [ { label: 'Schema Node', color: '#4a90e2', border: '#2c5aa0' }, { label: 'Selected Node', color: '#ff8804', border: '#cc6d03' }, { label: 'One-to-One', style: 'solid' }, { label: 'One-to-Many', style: 'dashed' } ]; items.forEach(item => { var itemDiv = legend.append('div').attr('class', 'matrix-legend-item'); if (item.color) { itemDiv.append('div') .attr('class', 'legend-color') .style('background-color', item.color) .style('border-color', item.border); } else { itemDiv.append('svg') .attr('width', 20) .attr('height', 20) .append('line') .attr('x1', 0) .attr('y1', 10) .attr('x2', 20) .attr('y2', 10) .style('stroke', '#999') .style('stroke-width', '2px') .style('stroke-dasharray', item.style === 'dashed' ? '5,5' : 'none'); } itemDiv.append('span').text(item.label); }); } async loadSchemas() { var self = this; try { // Get list of schemas using fetch (no jQuery dependency) var baseUrl = location.origin; console.log('[Matrix] Loading schemas from:', baseUrl + '/API/list/schemas'); var schemaNames = await fetch(baseUrl + '/API/list/schemas') .then(function(res) { return res.ok ? res.json() : []; }) .catch(function() { return []; }); console.log('[Matrix] Found', schemaNames.length, 'schemas:', schemaNames); // Load all schemas with summaries, then get menuicon from full schema var schemaPromises = schemaNames.map(function(name) { return fetch(baseUrl + '/API/schema/' + encodeURIComponent(name) + '?summaryOnly=true') .then(function(res) { return res.ok ? res.json() : null; }) .then(function(data) { if (data && !data.error) { // API returns {schemas: {schemaName: summary}, ...} // Extract the actual summary from the response var summary = (data.schemas && data.schemas[name]) || data[name] || data; if (summary) { self.schemas[name] = summary; // Debug: log relationship info for first few schemas if (schemaNames.indexOf(name) < 3) { console.log('[Matrix] Schema:', name); console.log(' - Raw API response keys:', Object.keys(data)); console.log(' - Has summary object:', !!summary); console.log(' - Summary keys:', summary ? Object.keys(summary) : 'none'); console.log(' - Has relationships:', !!(summary && summary.relationships)); if (summary && summary.relationships) { console.log(' - Has outbound:', !!summary.relationships.outbound); if (summary.relationships.outbound) { console.log(' - Outbound count:', summary.relationships.outbound.length); console.log(' - Outbound:', JSON.stringify(summary.relationships.outbound, null, 2)); } else { console.log(' - Outbound is:', summary.relationships.outbound); } } else { console.log(' - Relationships object:', summary ? summary.relationships : 'missing'); } } // Load full schema to get menuicon and default_schema (if not in summary) if (!summary.menuicon || summary.default_schema === undefined) { return fetch(baseUrl + '/API/schema/' + encodeURIComponent(name)) .then(function(res) { return res.ok ? res.json() : null; }) .then(function(fullData) { if (fullData && !fullData.error) { var fullSchema = (fullData.schemas && fullData.schemas[name]) || fullData[name] || fullData; if (fullSchema) { if (fullSchema.menuicon && !self.schemas[name].menuicon) { self.schemas[name].menuicon = fullSchema.menuicon; } if (fullSchema.default_schema !== undefined) { self.schemas[name].default_schema = fullSchema.default_schema; } } } }) .catch(function() { // Ignore errors loading full schema }); } } else { console.warn('[Matrix] No summary found for schema:', name, 'Response:', data); } } else { console.warn('[Matrix] Failed to load schema:', name, data); } }) .catch(function(err) { console.warn('[Matrix] Error loading schema', name + ':', err); }); }); await Promise.all(schemaPromises); // Load apps via MCP try { var baseUrl = location.origin; var mcpResponse = await fetch(baseUrl + '/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: String(Date.now()), method: 'listApps', params: {} }) }); if (mcpResponse.ok) { var mcpData = await mcpResponse.json(); var appData = (mcpData && (mcpData.result || mcpData)) || {}; self.appMap = (appData && appData.apps) || {}; // Determine which apps use each schema Object.keys(self.schemas).forEach(function(schemaName) { var usedBy = []; for (var appName in self.appMap) { var app = self.appMap[appName] || {}; var cols = Array.isArray(app.collections) ? app.collections : []; if (cols.indexOf(schemaName) !== -1) { usedBy.push(appName); } } // If schema is a default core schema, show only the JOE app var schema = self.schemas[schemaName]; if (schema && schema.default_schema) { usedBy = ['joe']; } self.schemas[schemaName].apps = usedBy.sort(); }); // Populate app filter dropdown self.populateAppFilter(); } } catch (err) { console.warn('[Matrix] Error loading apps:', err); self.appMap = {}; } console.log('[Matrix] Loaded', Object.keys(self.schemas).length, 'schemas total'); } catch (error) { console.error('[Matrix] Error loading schemas:', error); } } updateVisualization() { if (this.mode === 'schema-to-schema') { this.buildSchemaToSchemaGraph(); } else if (this.mode === 'object-relationships') { // TODO: Implement 3b console.log('Object relationships mode not yet implemented'); } else if (this.mode === 'aggregate-relationships') { // TODO: Implement 3c console.log('Aggregate relationships mode not yet implemented'); } } buildSchemaToSchemaGraph() { var self = this; // Clear existing visualization this.g.selectAll('*').remove(); this.nodes = []; this.links = []; // Build nodes from schemas (filtered by app if selected) var schemaNames = Object.keys(this.schemas).filter(function(name) { return self.shouldShowSchema(name); }); var nodeMap = {}; var self = this; schemaNames.forEach(function(name, i) { var schema = self.schemas[name]; var node = { id: name, name: name, schema: schema, // Store reference to schema (includes menuicon if loaded) type: 'schema', x: (i % 10) * 150 + 100, y: Math.floor(i / 10) * 150 + 100 }; self.nodes.push(node); nodeMap[name] = node; }); // Build links from relationships console.log('[Matrix] ===== Building Links from Relationships ====='); console.log('[Matrix] Total schemas loaded:', schemaNames.length); console.log('[Matrix] Available schema names:', schemaNames.slice(0, 20).join(', '), schemaNames.length > 20 ? '...' : ''); var totalRelationshipsFound = 0; var totalLinksCreated = 0; var missingTargets = []; schemaNames.forEach(function(name) { var schema = self.schemas[name]; // Debug specific schemas like 'page' and 'task' var isDebugSchema = (name === 'page' || name === 'task'); if (isDebugSchema) { console.log('[Matrix] === Debugging schema: ' + name + ' ==='); console.log(' - Schema object exists:', !!schema); console.log(' - Schema keys:', schema ? Object.keys(schema) : 'none'); // When summaryOnly=true, the API returns the summary directly, not wrapped // So schema IS the summary, not schema.summary console.log(' - Has relationships:', !!(schema && schema.relationships)); if (schema && schema.relationships) { console.log(' - Relationships keys:', Object.keys(schema.relationships)); } } // When summaryOnly=true, the API returns the summary object directly // So schema.relationships exists, not schema.summary.relationships if (schema && schema.relationships && schema.relationships.outbound) { var outbound = schema.relationships.outbound; totalRelationshipsFound += outbound.length; if (isDebugSchema) { console.log(' - Has outbound relationships:', outbound.length); console.log(' - Outbound data:', JSON.stringify(outbound, null, 4)); } outbound.forEach(function(rel) { if (isDebugSchema) { console.log(' - Processing relationship:', rel.field, '→', rel.targetSchema, '(' + rel.cardinality + ')'); } // Handle targetSchema that might be a string like "user|group" var targetSchemas = (rel.targetSchema || '').split('|'); if (isDebugSchema) { console.log(' Split into target schemas:', targetSchemas); } targetSchemas.forEach(function(targetSchema) { targetSchema = targetSchema.trim(); var targetExists = !!(targetSchema && nodeMap[targetSchema]); if (isDebugSchema) { console.log(' Checking target "' + targetSchema + '":', targetExists ? '✓ EXISTS' : '✗ NOT FOUND'); if (!targetExists && targetSchema) { var similar = schemaNames.filter(function(n) { return n.toLowerCase().indexOf(targetSchema.toLowerCase()) !== -1 || targetSchema.toLowerCase().indexOf(n.toLowerCase()) !== -1; }); if (similar.length > 0) { console.log(' Similar schema names found:', similar.join(', ')); } } } if (targetSchema && nodeMap[targetSchema]) { var link = { source: nodeMap[name], target: nodeMap[targetSchema], field: rel.field, cardinality: rel.cardinality || 'one', type: 'schema-relationship' }; self.links.push(link); totalLinksCreated++; if (isDebugSchema) { console.log(' ✓ Created link:', name, '→', targetSchema, 'via field "' + rel.field + '"'); } } else if (targetSchema) { missingTargets.push({ from: name, to: targetSchema, field: rel.field }); } }); }); } else { if (isDebugSchema) { console.log(' - ⚠ No outbound relationships found'); if (schema) { console.log(' Schema keys:', Object.keys(schema)); if (schema.relationships) { console.log(' Relationships keys:', Object.keys(schema.relationships)); console.log(' Relationships object:', schema.relationships); } else { console.log(' No relationships property found'); } } } } }); console.log('[Matrix] ===== Link Building Summary ====='); console.log('[Matrix] Total relationships found:', totalRelationshipsFound); console.log('[Matrix] Total links created:', totalLinksCreated); console.log('[Matrix] Missing target schemas:', missingTargets.length); if (missingTargets.length > 0) { console.log('[Matrix] Missing targets:', missingTargets.slice(0, 10)); } console.log('[Matrix] Final link count:', self.links.length); if (self.links.length === 0) { console.warn('[Matrix] ⚠ WARNING: No links were created!'); console.warn('[Matrix] Possible reasons:'); console.warn('[Matrix] 1. Schemas don\'t have summary.relationships.outbound'); console.warn('[Matrix] 2. Target schemas don\'t exist in loaded schemas'); console.warn('[Matrix] 3. Data structure is different than expected'); console.warn('[Matrix] Check the API response: /API/schema/page?summaryOnly=true'); } // Create force simulation (D3 v3 API) this.simulation = d3.layout.force() .nodes(this.nodes) .links(this.links) .size([this.width, this.height]) .linkDistance(150) .charge(-500) .gravity(0.1) .on('tick', function() { self.tick(); }); // Draw links FIRST (so they appear behind nodes) - make sure they're visible var link = this.g.selectAll('.matrix-link') .data(this.links) .enter() .append('line') .attr('class', function(d) { return 'matrix-link ' + (d.cardinality === 'many' ? 'many' : 'one'); }) .attr('x1', function(d) { return d.source.x || 0; }) .attr('y1', function(d) { return d.source.y || 0; }) .attr('x2', function(d) { return d.target.x || 0; }) .attr('y2', function(d) { return d.target.y || 0; }) .style('stroke', '#999') .style('stroke-width', function(d) { return d.cardinality === 'many' ? '1.5px' : '2px'; }) .style('stroke-opacity', '0.6') .style('stroke-dasharray', function(d) { return d.cardinality === 'many' ? '5,5' : 'none'; }) .style('fill', 'none') .style('pointer-events', 'none'); // Draw link labels - hidden by default, shown when node is selected var linkLabelGroup = this.g.selectAll('.matrix-link-label-group') .data(this.links) .enter() .append('g') .attr('class', 'matrix-link-label-group') .style('opacity', 0) .style('display', 'none'); // Add background rectangle for label linkLabelGroup.append('rect') .attr('class', 'matrix-link-label-bg') .attr('x', function(d) { return ((d.source.x || 0) + (d.target.x || 0)) / 2 - 20; }) .attr('y', function(d) { return ((d.source.y || 0) + (d.target.y || 0)) / 2 - 8; }) .attr('width', 40) .attr('height', 16) .attr('rx', 4) .style('fill', 'rgba(255, 255, 255, 0.9)') .style('stroke', '#ddd') .style('stroke-width', '1px'); // Add text label linkLabelGroup.append('text') .attr('class', 'matrix-link-label') .attr('x', function(d) { return ((d.source.x || 0) + (d.target.x || 0)) / 2; }) .attr('y', function(d) { return ((d.source.y || 0) + (d.target.y || 0)) / 2 + 4; }) .text(function(d) { return d.field.split('.').pop(); }) .style('pointer-events', 'none'); // Draw nodes AFTER links (so they appear on top) var node = this.g.selectAll('.matrix-node') .data(this.nodes) .enter() .append('g') .attr('class', 'matrix-node') .attr('transform', function(d) { return 'translate(' + (d.x || 0) + ',' + (d.y || 0) + ')'; }) .call(this.simulation.drag) .on('click', function(d) { self.selectNode(d); }) .on('mouseover', function(d) { self.showTooltip(d); }) .on('mouseout', function() { self.hideTooltip(); }); // Add icon if available, otherwise use circle node.each(function(d) { var nodeGroup = d3.select(this); var schema = d.schema; var menuicon = null; // Get menuicon from schema (loaded separately if not in summary) if (schema && schema.menuicon) { menuicon = schema.menuicon; } // Always add a background circle for clickability (larger hitbox) // Note: Base styles are in CSS, only pointer-events and cursor set here var bgCircle = nodeGroup.append('circle') .attr('class', 'matrix-node-bg') .attr('r', 16) // Larger radius for better clickability .attr('cx', 0) .attr('cy', 0) .style('pointer-events', 'all') .style('cursor', 'pointer'); // Fill, stroke, and stroke-width are handled by CSS if (menuicon && typeof menuicon === 'string' && menuicon.indexOf('<svg') !== -1) { // Use foreignObject to embed HTML SVG var iconGroup = nodeGroup.append('g') .attr('class', 'matrix-node-icon') .attr('transform', 'translate(-12,-12)') .style('pointer-events', 'none'); // Let clicks pass through to the circle var foreignObject = iconGroup.append('foreignObject') .attr('width', 24) .attr('height', 24) .attr('x', 0) .attr('y', 0) .style('pointer-events', 'none'); // Create a div to hold the SVG HTML var iconDiv = foreignObject.append('xhtml:div') .style('width', '24px') .style('height', '24px') .style('overflow', 'hidden') .style('line-height', '0') .style('pointer-events', 'none'); // Parse and modify the SVG to fit try { var tempDiv = document.createElement('div'); tempDiv.innerHTML = menuicon; var svgElement = tempDiv.querySelector('svg'); if (svgElement) { // Get viewBox or set default var viewBox = svgElement.getAttribute('viewBox') || '0 0 24 24'; svgElement.setAttribute('width', '24'); svgElement.setAttribute('height', '24'); svgElement.setAttribute('viewBox', viewBox); svgElement.style.width = '24px'; svgElement.style.height = '24px'; svgElement.style.display = 'block'; // Validate and fix SVG paths before setting var paths = svgElement.querySelectorAll('path'); var validElements = false; var nodeName = d.name || 'unknown'; paths.forEach(function(path) { var pathData = path.getAttribute('d'); if (pathData) { var trimmed = pathData.trim(); // Check if path starts with moveto command (M or m) if (!/^[Mm]/.test(trimmed)) { // Invalid path - try to fix by prepending M 0,0 console.warn('[Matrix] Invalid SVG path found for schema', nodeName + ', fixing:', trimmed.substring(0, 20)); // Try to fix by prepending a moveto path.setAttribute('d', 'M 0,0 ' + trimmed); validElements = true; } else { validElements = true; } } }); // Check for other valid elements if (!validElements) { validElements = !!(svgElement.querySelector('circle') || svgElement.querySelector('rect') || svgElement.querySelector('polygon') || svgElement.querySelector('polyline')); } // Only set if we have valid content if (validElements) { iconDiv.node().innerHTML = svgElement.outerHTML; } else { // No valid paths, just show the background circle console.warn('[Matrix] No valid SVG elements found for', d.name || 'unknown'); iconGroup.remove(); } } else { // No SVG element found, remove icon group iconGroup.remove(); } } catch (err) { console.warn('[Matrix] Error parsing SVG icon for', d.name || 'unknown' + ':', err); // Remove icon group on error, just show background circle iconGroup.remove(); } } // If no icon, we already have the background circle, so we're done }); node.append('text') .attr('dy', 20) .text(function(d) { return d.name; }); // Start simulation this.simulation.start(); } tick() { var self = this; this.g.selectAll('.matrix-link') .attr('x1', function(d) { return d.source.x; }) .attr('y1', function(d) { return d.source.y; }) .attr('x2', function(d) { return d.target.x; }) .attr('y2', function(d) { return d.target.y; }); // Update link label positions this.g.selectAll('.matrix-link-label-group') .each(function(d) { var group = d3.select(this); var x = (d.source.x + d.target.x) / 2; var y = (d.source.y + d.target.y) / 2; group.select('.matrix-link-label-bg') .attr('x', x - 20) .attr('y', y - 8); group.select('.matrix-link-label') .attr('x', x) .attr('y', y + 4); }); this.g.selectAll('.matrix-node') .attr('transform', function(d) { return 'translate(' + d.x + ',' + d.y + ')'; }); } selectNode(node) { // Remove previous selection this.g.selectAll('.matrix-node').classed('selected', false); // Select new node var self = this; var nodeElement = this.g.selectAll('.matrix-node') .filter(function(d) { return d.id === node.id; }) .classed('selected', true); this.selectedNode = node; // Show schema properties panel this.showSchemaProperties(node); // Highlight connected nodes and links this.highlightConnections(node); } highlightConnections(node) { var self = this; if (!node) { // Reset all to normal this.g.selectAll('.matrix-link').style('stroke-opacity', 0.6); this.g.selectAll('.matrix-node').style('opacity', 1); // Hide all link labels this.g.selectAll('.matrix-link-label-group') .style('opacity', 0) .style('display', 'none'); return; } // Reset all links this.g.selectAll('.matrix-link').style('stroke-opacity', 0.1); this.g.selectAll('.matrix-node').style('opacity', 0.3); // Hide all link labels initially this.g.selectAll('.matrix-link-label-group') .style('opacity', 0) .style('display', 'none'); // Highlight selected node this.g.selectAll('.matrix-node') .filter(function(d) { return d.id === node.id; }) .style('opacity', 1); // Highlight connected nodes and links, and show their labels var connectedNodeIds = {}; var connectedLinks = []; connectedNodeIds[node.id] = true; this.links.forEach(function(link) { var sourceId = (typeof link.source === 'object' ? link.source.id : link.source); var targetId = (typeof link.target === 'object' ? link.target.id : link.target); if (sourceId === node.id || targetId === node.id) { connectedNodeIds[sourceId] = true; connectedNodeIds[targetId] = true; connectedLinks.push(link); self.g.selectAll('.matrix-link') .filter(function(d) { var dSourceId = (typeof d.source === 'object' ? d.source.id : d.source); var dTargetId = (typeof d.target === 'object' ? d.target.id : d.target); return (dSourceId === sourceId && dTargetId === targetId) || (dSourceId === targetId && dTargetId === sourceId); }) .style('stroke-opacity', 1) .style('stroke-width', '2.5px'); } }); // Show labels for connected links connectedLinks.forEach(function(link) { self.g.selectAll('.matrix-link-label-group') .filter(function(d) { var dSourceId = (typeof d.source === 'object' ? d.source.id : d.source); var dTargetId = (typeof d.target === 'object' ? d.target.id : d.target); var linkSourceId = (typeof link.source === 'object' ? link.source.id : link.source); var linkTargetId = (typeof link.target === 'object' ? link.target.id : link.target); return (dSourceId === linkSourceId && dTargetId === linkTargetId) || (dSourceId === linkTargetId && dTargetId === linkSourceId); }) .style('opacity', 1) .style('display', 'block'); }); Object.keys(connectedNodeIds).forEach(function(id) { self.g.selectAll('.matrix-node') .filter(function(d) { return d.id === id; }) .style('opacity', 1); }); } showTooltip(node) { var self = this; var schema = node.schema; var tooltipText = '<div style="max-width:300px;">'; tooltipText += '<strong style="font-size:14px;color:#fff;">' + node.name + '</strong><br/>'; // When summaryOnly=true, schema IS the summary object if (schema) { if (schema.description) { tooltipText += '<div style="margin-top:6px;font-size:12px;color:#ddd;">' + schema.description + '</div>'; } if (schema.relationships && schema.relationships.outbound && schema.relationships.outbound.length > 0) { tooltipText += '<div style="margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.3);">'; tooltipText += '<strong style="font-size:12px;color:#fff;">Relationships (' + schema.relationships.outbound.length + '):</strong><br/>'; tooltipText += '<div style="margin-top:4px;font-size:11px;">'; schema.relationships.outbound.forEach(function(rel) { var targetSchemas = (rel.targetSchema || '').split('|'); var cardinalityIcon = rel.cardinality === 'many' ? '⟷' : '→'; targetSchemas.forEach(function(targetSchema) { targetSchema = targetSchema.trim(); var linkExists = self.links.some(function(link) { var sourceId = (typeof link.source === 'object' ? link.source.id : link.source); var targetId = (typeof link.target === 'object' ? link.target.id : link.target); return (sourceId === node.name && targetId === targetSchema); }); var existsMarker = linkExists ? '✓' : '⚠'; tooltipText += '<div style="margin:2px 0;">'; tooltipText += '<code style="background:rgba(255,255,255,0.2);padding:1px 4px;border-radius:2px;font-size:10px;">' + rel.field + '</code> '; tooltipText += '<span>' + cardinalityIcon + '</span> '; tooltipText += '<strong>' + targetSchema + '</strong> '; tooltipText += '<span style="color:#bbb;">(' + rel.cardinality + ')</span> '; tooltipText += '<span style="color:' + (linkExists ? '#4caf50' : '#ff9800') + ';">' + existsMarker + '</span>'; tooltipText += '</div>'; }); }); tooltipText += '</div></div>'; } else { 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>'; } } tooltipText += '</div>'; this.tooltip .html(tooltipText) .classed('visible', true) .style('left', (d3.event.pageX + 10) + 'px') .style('top', (d3.event.pageY - 10) + 'px'); } hideTooltip() { this.tooltip.classed('visible', false); } resetView() { this.zoom.scale(1); this.zoom.translate([0, 0]); this.svg.transition() .duration(750) .call(this.zoom.event); // Reset node highlighting this.g.selectAll('.matrix-link').style('stroke-opacity', 0.6).style('opacity', 1); this.g.selectAll('.matrix-node').style('opacity', 1).classed('selected', false); this.selectedNode = null; } fitToScreen() { if (this.nodes.length === 0) return; var bounds = this.nodes.reduce(function(acc, node) { return { minX: Math.min(acc.minX, node.x), maxX: Math.max(acc.maxX, node.x), minY: Math.min(acc.minY, node.y), maxY: Math.max(acc.maxY, node.y) }; }, { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity }); var graphWidth = bounds.maxX - bounds.minX; var graphHeight = bounds.maxY - bounds.minY; var scale = Math.min(this.width / graphWidth, this.height / graphHeight) * 0.8; var translateX = (this.width - (bounds.minX + bounds.maxX) * scale) / 2; var translateY = (this.height - (bounds.minY + bounds.maxY) * scale) / 2; this.zoom.scale(scale); this.zoom.translate([translateX, translateY]); this.svg.transition() .duration(750) .call(this.zoom.event); } showSchemaProperties(node) { var self = this; var schema = node.schema; // Remove existing properties panel var existing = document.getElementById('matrix-properties-panel'); if (existing) { existing.remove(); } // Create properties panel var panel = document.createElement('div'); panel.id = 'matrix-properties-panel'; var html = '<div class="matrix-props-header">'; // Add icon if available var iconHtml = ''; if (schema && schema.menuicon && typeof schema.menuicon === 'string' && schema.menuicon.indexOf('<svg') !== -1) { iconHtml = '<span class="matrix-props-title-icon">' + schema.menuicon + '</span>'; } html += '<h2 class="matrix-props-title">' + iconHtml + '<span class="matrix-props-title-text">' + node.name + '</span></h2>'; html += '<button id="close-properties">×</button>'; html += '</div>'; // When summaryOnly=true, schema IS the summary object if (schema) { if (schema.description) { html += '<div class="matrix-props-section"><strong>Description:</strong><br/><span class="matrix-props-text">' + (schema.description || 'N/A') + '</span></div>'; } if (schema.purpose) { html += '<div class="matrix-props-section"><strong>Purpose:</strong><br/><span class="matrix-props-text">' + (schema.purpose || 'N/A') + '</span></div>'; } if (schema.labelField) { html += '<div class="matrix-props-section"><strong>Label Field:</strong> <code>' + schema.labelField + '</code></div>'; } if (schema.source) { html += '<div class="matrix-props-section"><strong>Source:</strong> <span class="matrix-props-text">' + schema.source + '</span></div>'; } } // Relationships - Show prominently at the top // When summaryOnly=true, schema.relationships exists directly if (schema && schema.relationships && schema.relationships.outbound && schema.relationships.outbound.length > 0) { html += '<div class="matrix-props-relationships">'; html += '<strong class="matrix-props-relationships-title">Outbound Relationships (' + schema.relationships.outbound.length + '):</strong>'; html += '<div class="matrix-props-relationships-list">'; schema.relationships.outbound.forEach(function(rel) { var targetSchemas = (rel.targetSchema || '').split('|'); var cardinalityIcon = rel.cardinality === 'many' ? '⟷' : '→'; var cardinalityClass = rel.cardinality === 'many' ? 'many' : 'one'; targetSchemas.forEach(function(targetSchema) { targetSchema = targetSchema.trim(); var linkExists = self.links.some(function(link) { var sourceId = (typeof link.source === 'object' ? link.source.id : link.source); var targetId = (typeof link.target === 'object' ? link.target.id : link.target); return (sourceId === node.name && targetId === targetSchema) || (sourceId === targetSchema && targetId === node.name); }); var missingClass = linkExists ? '' : ' missing'; html += '<div class="matrix-props-relationship-item ' + cardinalityClass + missingClass + '">'; html += '<code class="matrix-props-relationship-field">' + rel.field + '</code>'; html += '<span class="matrix-props-relationship-arrow">' + cardinalityIcon + '</span>'; html += '<strong>' + targetSchema + '</strong>'; html += ' <span class="matrix-props-relationship-cardinality">(' + rel.cardinality + ')</span>'; if (!linkExists) { html += ' <span class="matrix-props-relationship-warning">[target not loaded]</span>'; } html += '</div>'; }); }); html += '</div></div>'; } else { html += '<div class="matrix-props-no-relationships">'; html += '<strong>No outbound relationships found</strong><br/>'; html += '<span class="matrix-props-no-relationships-text">This schema may not have relationships defined in its summary.</span>'; html += '</div>'; } // Fields if (schema && schema.fields && schema.fields.length > 0) { html += '<div class="matrix-props-section"><strong>Fields (' + schema.fields.length + '):</strong>'; html += '<div class="matrix-props-fields-container">'; schema.fields.forEach(function(field) { var fieldInfo = '<div class="matrix-props-field-item">'; fieldInfo += '<code>' + field.name + '</code>'; fieldInfo += ' <span class="matrix-props-field-type">(' + field.type; if (field.isArray) fieldInfo += '[]'; if (field.isReference) fieldInfo += ', ref'; if (field.targetSchema) fieldInfo += ' → ' + field.targetSchema; if (field.required) fieldInfo += ', required'; fieldInfo += ')</span>'; if (field.comment || field.tooltip) { fieldInfo += '<br/><span class="matrix-props-field-comment">' + (field.comment || field.tooltip || '') + '</span>'; } fieldInfo += '</div>'; html += fieldInfo; }); html += '</div></div>'; } // Searchable fields if (schema && schema.searchableFields && schema.searchableFields.length > 0) { html += '<div class="matrix-props-section"><strong>Searchable Fields:</strong><br/>'; html += '<div class="matrix-props-searchable-list">'; schema.searchableFields.forEach(function(field) { html += '<span class="matrix-props-searchable-tag">' + field + '</span>'; }); html += '</div></div>'; } panel.innerHTML = html; this.appendChild(panel); // Close button handler document.getElementById('close-properties').addEventListener('click', function() { panel.remove(); self.g.selectAll('.matrix-node').classed('selected', false); self.selectedNode = null; self.highlightConnections(null); }); } disconnectedCallback() { if (this.simulation) { this.simulation.stop(); } } populateAppFilter() { var appFilter = document.getElementById('app-filter'); if (!appFilter) return; // Clear existing options except "All" appFilter.innerHTML = '<option value="">All</option>'; // Add app options var appNames = Object.keys(this.appMap || {}).sort(); var self = this; appNames.forEach(function(appName) { var opt = document.createElement('option'); opt.value = appName; opt.textContent = appName; appFilter.appendChild(opt); }); } setAppFilter(appName) { this.selectedApp = appName || ''; // Rebuild the graph with filtered schemas if (this.mode === 'schema-to-schema') { this.buildSchemaToSchemaGraph(); } } shouldShowSchema(schemaName) { // Always show core schemas: user, tag, and status var coreSchemas = ['user', 'tag', 'status']; if (coreSchemas.indexOf(schemaName) !== -1) { return true; } if (!this.selectedApp) { return true; // Show all if no filter } var schema = this.schemas[schemaName]; if (!schema || !schema.apps) { return false; // Hide if no app info } return schema.apps.indexOf(this.selectedApp) !== -1; } } window.customElements.define('joe-matrix', JoeMatrix);