json-object-editor
Version:
JOE the Json Object Editor | Platform Edition
1,066 lines (938 loc) • 41.7 kB
JavaScript
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);