@kit-data-manager/visualization-component
Version:
The visualization-component is a dynamic, interactive graph component built using D3.js. It is designed to render graphs based on provided JSON data, making it ideal for visualizing complex relationships and networks in an intuitive manner.
544 lines (543 loc) • 25.4 kB
JavaScript
import * as d3 from "d3";
/**
* Class responsible for setting up and managing the D3.js graph visualization.
*
* @class
*/
export class GraphSetup {
constructor(hostElement) {
/**
* The size of the graph. Defaults to '1350px,650px'.
*
* @private
* @type {string}
*/
this.size = '1350px,650px';
this.legendNodeSize = 8;
this.legendTextSize = 14;
/**
* Default force properties for the graph simulation.
*
* @private
* @type {{
* center: { x: number, y: number },
* charge: { enabled: boolean, strength: number, distanceMin: number, distanceMax: number },
* link: { distance: number }
* }}
*/
this.forceProperties = {
center: {
x: 0,
y: 0,
},
charge: {
enabled: true,
strength: 0,
distanceMin: 40,
distanceMax: 2000,
},
link: {
distance: 70,
},
};
this.hostElement = hostElement;
}
/**
* Clears all elements inside the provided SVG element.
*
* @param {d3.Selection} svg - The SVG element to clear.
*/
clearSVG(svg) {
svg.selectAll('*').remove();
}
/**
* Initializes the SVG element for the graph based on the host element.
*
* @returns {{ svg: d3.Selection, numericWidth: number, numericHeight: number }} - The initialized SVG element and its dimensions.
*/
initializeSVG(numPrimaryNodes) {
const svg = d3.select(this.hostElement.shadowRoot.querySelector("#graph"));
const [width, height] = this.size.split(',').map(s => s.trim());
svg.attr("viewBox", `0 0 ${parseInt(width, 10)} ${parseInt(height, 10)}`).attr("preserveAspectRatio", "xMidYMid meet");
const numericWidth = parseInt(width, 10);
const numericHeight = parseInt(height, 10);
// Set up zoom behavior directly on the SVG element
const zoom = d3.zoom()
.extent([[1000, 1000], [numericWidth, numericHeight]])
.scaleExtent([0, 5]) // Adjust scale extent as needed
.on("zoom", (event) => {
svg.attr("transform", event.transform); // Apply zoom directly to SVG
});
const initialZoomScale = numPrimaryNodes > 30 ? 0.5 : 1;
svg.call(zoom).call(zoom.transform, d3.zoomIdentity.scale(initialZoomScale));
return { svg, numericWidth, numericHeight };
}
/**
* Creates a force simulation for the graph nodes and links.
*
* @param {any[]} nodes - The array of node data.
* @param {any[]} links - The array of link data.
* @param {number} numericWidth - The numeric width of the graph.
* @param {number} numericHeight - The numeric height of the graph.
* @returns {d3.Simulation<any, any>} - The configured force simulation.
*/
createForceSimulation(nodes, links, numericWidth, numericHeight) {
const simulation = d3
.forceSimulation(nodes)
.force('link', d3
.forceLink(links)
.id((d) => d.id)
.distance(d => (d.category === 'attribute' ? 35 : 70)))
.force('charge', d3.forceManyBody().strength(this.forceProperties.charge.strength).distanceMin(this.forceProperties.charge.distanceMin).distanceMax(this.forceProperties.charge.distanceMax))
.force('center', d3.forceCenter(numericWidth * this.forceProperties.center.x, numericHeight * this.forceProperties.center.y));
return simulation;
}
/**
* Updates the force simulation properties.
*
* @param {{ [forceName: string]: { [propName: string]: any } }} newProps - The new properties to update.
*/
updateForceProperties(newProps) {
// Iterate over the object to update force properties
for (const forceName of Object.keys(newProps)) {
for (const propName of Object.keys(newProps[forceName])) {
if (this.forceProperties[forceName]) {
this.forceProperties[forceName][propName] = newProps[forceName][propName];
}
}
}
}
/**
* Sets up attribute color mapping based on provided configurations if not provided then used default colors.
*
* @param {string[]} uniqueAttributeNames - The unique attribute names.
* @param {any[]} parsedConfig - The parsed configuration data.
* @returns {{ attributeColorMap: Map<string, string>, attributeColorScale: d3.ScaleOrdinal<string, string> }} - The attribute color map and scale.
*/
attributeColorSetup(uniqueAttributeNames, parsedConfig) {
// Prepare attribute color mapping based on config
let attributeColorMap = new Map();
const defaultColorScale = d3.scaleOrdinal(d3.schemeCategory10);
uniqueAttributeNames.forEach(attributeName => {
// Initialize an array to store properties for each matching attribute
let color;
// Search for the attribute in properties of each config item
parsedConfig.forEach(item => {
// console.log('parsedConfig',parsedConfig)
if (item.properties) {
item.properties.forEach(propertyObj => {
// Iterate over keys of each property object
Object.keys(propertyObj).forEach(key => {
// Check if the attributeName matches the key and if the property object has a color
if (key === attributeName && propertyObj[key].color) {
// Add property object to the properties array
color = propertyObj[key].color;
}
});
});
}
});
if (color && color.length > 0) {
// Use properties from matching attributes if available
attributeColorMap.set(attributeName, color);
}
else {
// Directly assign a color using the attribute name from default scale
const color = defaultColorScale(attributeName);
attributeColorMap.set(attributeName, color);
}
});
const attributeColorScale = d3.scaleOrdinal(uniqueAttributeNames, d3.schemeCategory10);
return { attributeColorMap, attributeColorScale };
}
/**
* Creates and appends link elements to the graph SVG.
*
* @param {d3.Selection} svg - The SVG element to which links will be appended.
* @param {any[]} links - The array of link data.
* @returns {d3.Selection} - The created link elements.
*/
createLinks(svg, links, colorType) {
// Create a group for each link
const linkGroup = svg.selectAll('.link').data(links).enter().append('g').attr('class', 'link');
// Append the line to each group
linkGroup
.append('line')
.attr('stroke-opacity', 1)
.attr('opacity', '1')
.attr('stroke', d => (d.category === 'non_attribute' ? colorType(d.relationType) : '#d3d3d3'))
.attr('marker-end', d => `url(#arrowhead-${d.relationType})`) // Add arrow marker
.attr('marker-start', d => `url(#arrowtail-${d.relationType})`)
.attr('stroke-dasharray', d => (d.relationType === 'someType' ? '0, 5' : 'none')); // Adjust condition as per your data
// Append the text to each group
linkGroup
.append('text')
.text(d => d.category === 'attribute' ? '' : d.relationType)
.attr('stroke-opacity', 0)
.attr('opacity', '1')
.attr('fill', 'black') // Style as needed
.attr('font-size', 3)
.attr('text-anchor', 'middle')
.attr('dy', d => {
// Calculate the vertical position of the text relative to the line
const yOffset = -5; // Adjust this value to control the vertical offset
return d.source.y < d.target.y ? yOffset : -yOffset; // Position above or below the line based on y-coordinates of source and target nodes
})
.attr('dx', d => {
// Calculate the horizontal position of the text relative to the line
const xOffset = 5; // Adjust this value to control the horizontal offset
return (d.source.x + d.target.x) / 2 > 0 ? xOffset : -xOffset; // Position to the right or left of the line based on the average x-coordinates of source and target nodes
});
return linkGroup;
}
createPrimaryNodeMap(nodes, primaryNodeConfig) {
let primaryNodeMap = new Map();
// Extract primaryNodeConfigurations from the configuration
// Loop through each node
nodes.forEach(node => {
if (node.category === 'non_attribute') {
// Loop through each primaryNodeConfiguration
primaryNodeConfig.forEach(primaryNode => {
const regex = new RegExp(primaryNode.typeRegEx, 'i');
if (regex.test(node.type)) {
// Store the node's ID, label, and color in the map
primaryNodeMap.set(node.id, {
nodeLabel: primaryNode.nodeLabel,
nodeColor: primaryNode.nodeColor,
matchedBy: primaryNode.typeRegEx
});
}
});
}
});
return primaryNodeMap;
}
/**
* Creates and appends node elements to the graph SVG.
*
* @param {d3.Selection} svg - The SVG element to which nodes will be appended.
* @param {any[]} nodes - The array of node data.
* @param {d3.ScaleOrdinal<string, string>} colorScale - The color scale for node colors.
* @returns {d3.Selection} - The created node elements.
*/
createNodes(svg, nodes, primaryNodeConfig, attributeColorMap, config) {
const userConfigs = Array.isArray(config) ? config : [];
// Extract primary values from the first configuration in the config array
const userConfig = userConfigs[0];
const primaryLabel = userConfig.label || '';
const primaryColor = userConfig.color || '#008080';
// Provide default values for the maps
let typeRegExColorMap = new Map([['defaultColor', primaryColor]]);
let typeRegExLabelMap = new Map([['defaultLabel', primaryLabel]]);
primaryNodeConfig.forEach(primaryNode => {
typeRegExColorMap.set(primaryNode.typeRegEx, primaryNode.nodeColor);
typeRegExLabelMap.set(primaryNode.typeRegEx, primaryNode.nodeLabel);
});
console.log('typeRegExColorMap', typeRegExColorMap);
// Extract unique attribute names from attribute nodes
// Create a color scale for attribute nodes
let defaultPrimaryNodeColor = '#add8e6';
let typeMatchedPrimaryNodes = [];
let nodesCreated = svg
.selectAll('.node')
.data(nodes)
.enter()
.append('circle')
.attr('class', 'node')
.attr('r', d => (d.category === 'attribute' ? 6 : 10)) // Smaller radius for attribute nodes
.attr('fill', d => {
if (d.category === 'attribute') {
// Assuming the attribute name is the second key of the node object
const attributeName = Object.keys(d)[1];
return attributeColorMap.get(attributeName); // Directly use color from attributeColorMap
}
else {
const color = typeRegExColorMap.get(d.type) || primaryColor;
const label = typeRegExLabelMap.get(d.type) || primaryLabel;
if (color && color !== primaryColor) {
typeMatchedPrimaryNodes.push({ node: d, color: color, label: label });
}
return color || defaultPrimaryNodeColor; // Use primaryNodeColor for non-attribute nodes
}
})
.attr('stroke', '#fff')
.attr('stroke-width', 1.5);
return { nodesCreated, typeMatchedPrimaryNodes };
}
/**
* Creates custom markers for links based on their types.
*
* @param {d3.Selection} svg - The SVG element.
* @param {any[]} links - The array of link data.
* @param {(type: string) => string} colorType - Function to retrieve color based on link type.
*/
createCustomMarkers(svg, links, colorType) {
let defs = svg.append('defs');
let set = [...new Set(links.filter(d => d.category === 'non_attribute').map(d => d.relationType))];
set.forEach(elem => {
// Marker for the end of the link (arrowhead)
defs
.append('svg:marker')
.attr('id', `arrowhead-${elem}`)
.attr('viewBox', '0 -5 10 10')
.attr('refX', 28)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', colorType(elem));
// Marker for the start of the link (reverse arrowhead)
defs
.append('svg:marker')
.attr('id', `arrowtail-${elem}`)
.attr('viewBox', '0 -5 10 10')
.attr('refX', -18) // Adjust for positioning the tail
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M10,-5L0,0L10,5')
.attr('fill', colorType(elem));
});
}
// /**
// * Creates a custom marker for use in SVG definitions.
// *
// * @param {SVGDefsElement} defs - The SVG definitions element.
// * @param {string} id - The ID of the marker.
// * @param {string} color - The color of the marker.
// */
// createMarker(defs, id, color) {
// defs
// .append('svg:marker')
// .attr('id', id)
// .attr('refX', 20)
// .attr('refY', 20)
// .attr('markerWidth', 40)
// .attr('markerHeight', 40)
// .attr('markerUnits', 'userSpaceOnUse')
// .attr('orient', 'auto')
// .append('path')
// .attr('d', 'M0,0Q15,0,20,10,15,20,0,20A1,1,0,000,0') //d3.line()([[0, 0], [0, 20], [20, 10]]))
// .style('fill', color);
// }
/**
* Applies the force simulation to update link and node positions on each simulation tick.
*
* @param {d3.Selection} nodes - The node elements in the graph.
* @param {d3.Selection} links - The link elements in the graph.
* @param {d3.Simulation<any, any>} simulation - The configured force simulation.
*/
applySimulation(nodes, links, simulation) {
const ticked = () => {
links
.select('line')
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
links
.select('text')
.attr('x', d => (d.source.x + d.target.x) / 2)
.attr('y', d => (d.source.y + d.target.y) / 2);
nodes.attr('cx', d => d.x).attr('cy', d => d.y);
};
simulation.on('tick', ticked);
}
/**
* Prepares legend data based on provided configurationsif not provided then by defualt colors.
*
* @param {string[]} uniqueAttributeNames - The unique attribute names.
* @param {any[]} config - The legend configuration data.
* @param {d3.ScaleOrdinal<string, string>} attributeColorScale - The attribute color scale.
* @returns {any[]} - The prepared legend data.
*/
prepareLegend(typeMatchedPrimaryNodes, uniqueAttributeNames, config, attributeColorScale) {
console.log('typeMatchedPrimaryNodes', typeMatchedPrimaryNodes);
const userConfigs = Array.isArray(config) ? config : [];
if (userConfigs.length === 0) {
return {
primaryConfigFallback: {
label: '',
color: 'grey'
},
legendConfigurations: uniqueAttributeNames.map(attributeName => ({
label: attributeName,
color: attributeColorScale(attributeName) || '#defaultColor',
attributeKey: attributeName
})),
};
}
// Extract primary values from the first configuration in the config array
const userConfig = userConfigs[0];
const primaryLabel = userConfig.label || '';
const primaryColor = userConfig.color || '#008080';
const primaryDescription = userConfig.description ? userConfig.description : '';
// Check for primaryNodeConfigurations
const primaryNodeConfig = userConfig.primaryNodeConfigurations || [];
console.log('primaryNodeConfig', primaryNodeConfig);
// Create legendPrimaryConfig
const legendPrimaryConfig = typeMatchedPrimaryNodes.length > 0 ?
typeMatchedPrimaryNodes.map(pNode => {
return {
label: pNode.label || primaryLabel,
color: pNode.color || primaryColor,
attributeKey: pNode.node.id || primaryDescription
};
}) : [{
label: primaryLabel,
color: primaryColor
}];
// Searching if attribute mentioned in configurations file matches to any attribute of our graph
const legendConfigurations = uniqueAttributeNames.map(attributeName => {
let customConfig = null;
for (const config of userConfigs) {
const matchingProperty = config.properties.find(property => attributeName in property);
if (matchingProperty) {
customConfig = matchingProperty[attributeName];
break; // Stop searching once a match is found
}
}
if (customConfig) {
return {
label: customConfig.label || attributeName,
color: customConfig.color || attributeColorScale(attributeName) || '#defaultColor',
attributeKey: attributeName,
description: customConfig.description
};
}
else {
return {
label: attributeName,
color: attributeColorScale(attributeName) || '#defaultColor',
attributeKey: attributeName
};
}
});
// Return an object containing legend configurations and primary values
return {
primaryConfigFallback: {
label: primaryLabel,
color: primaryColor,
description: primaryDescription,
},
legendPrimaryConfig: legendPrimaryConfig,
legendAttributesConfig: legendConfigurations,
};
}
/**
* Creates a legend for nodes in the graph.
*
* @param {d3.Selection} svg - The SVG element.
* @param {string} primaryNodeColor - The color for primary nodes.
* @param {boolean} showLegend - Whether to display the legend.
* @param {any[]} legendConfigurations - The legend configurations.
* @param {Map<string, string>} attributeColorMap - The attribute color map.
*/
createLegendNodes(svg, primaryNodeColor, showLegend, legendConfigurations, attributeColorMap, tooltip, legendPrimaryConfig) {
if (!showLegend) {
return; // Do not create the legend if showLegend is false
}
const svgWidth = parseInt(svg.style('width'));
const rightOffset = 50;
const legendX = svgWidth - rightOffset;
// Set a fixed size for the legend area and make it scrollable
const legendHeight = 200;
const legendWidth = 250;
// Create a container for the scrollable legend
const legendContainer = svg
.append('foreignObject')
.attr('x', legendX - legendWidth)
.attr('y', 420)
.attr('width', legendWidth)
.attr('height', legendHeight)
.append('xhtml:div')
.style('overflow', 'auto')
.style('height', `${legendHeight}px`)
.style('font-size', `${this.legendTextSize}px`); // Adjust font size using legendNodeSize variable
const legend = legendContainer.append('div').style('cursor', 'pointer');
// Extract unique label names and their corresponding items
const uniqueTypesMap = legendPrimaryConfig.reduce((map, item) => {
if (!map.has(item.label)) {
map.set(item.label, []);
}
map.get(item.label).push(item);
return map;
}, new Map());
// Iterate over unique labels and add items to the legend
uniqueTypesMap.forEach((items, label) => {
// Get the color of the first item in the array
const color = items[0].color;
const primarylegendItemTypes = this.addLegendItem(legend, color || primaryNodeColor, label || 'Primary Node', this.legendNodeSize, items[0].description || 'Primary');
// Event listener for item mouseover
primarylegendItemTypes.on('mouseover', event => {
if (items[0].description) {
tooltip
.html(`<div style="background-color: lightgray; padding: 5px; border-radius: 5px;"><span>${items[0].description}</span></div>`) // Content with span for text
.transition()
.duration(200)
.style('opacity', 1)
.style('left', `${event.pageX + 10}px`)
.style('top', `${event.pageY - 10}px`);
}
});
// Event listener for item mouseout
primarylegendItemTypes.on('mouseout', () => {
tooltip.style('opacity', 0);
tooltip.html(''); // Clear tooltip content
});
});
// Create legend attribute items from the configurations
legendConfigurations.forEach(({ attributeKey, label, description }) => {
const color = attributeColorMap.get(attributeKey) || primaryNodeColor; // Fallback to primaryNodeColor if not found
const item = this.addLegendItem(legend, color, label || attributeKey, this.legendNodeSize, description); // Use label or attributeKey if label not provided
// Event listener for legend item mouseover
item.on('mouseover', event => {
if (description) {
tooltip
.html(`<div style="background-color: lightgray; padding: 5px; border-radius: 5px;"><span>${description}</span></div>`) // Content with span for text
.transition()
.duration(200)
.style('opacity', 1)
.style('left', `${event.pageX + 10}px`)
.style('top', `${event.pageY - 10}px`);
}
});
// Event listener for legend item mouseout
item.on('mouseout', () => {
tooltip.style('opacity', 0);
tooltip.html(''); // Clear tooltip content
});
});
}
/**
* Adds an item to the legend with the specified color, label, and size.
*
* @param {HTMLElement} legend - The legend container element.
* @param {string} color - The color of the legend item.
* @param {string} label - The label for the legend item.
* @param {number} size - The size of the legend item.
*/
addLegendItem(legend, color, label, size, description) {
const item = legend.append('div').style('display', 'flex').style('align-items', 'center').style('margin-bottom', '10px'); // Increase spacing if needed
// Adjust the circle to reflect the node size
item
.append('svg')
.attr('width', size * 2) // Adjust width and height to match the square size
.attr('height', size * 2)
.attr('class', 'legend-item')
.append('rect') // Use rect instead of circle
.attr('x', 0) // Set x position to 0
.attr('y', 0) // Set y position to 0
.attr('width', size * 2) // Set width and height to match the square size
.attr('height', size * 2)
.style('fill', color)
.attr('data-description', description); // Set data attribute for description
item.append('span').style('margin-left', '10px').text(label); // The label already describes the node
return item;
}
}
//# sourceMappingURL=d3GraphSetup.js.map