react-lineage-map
Version:
Build out field level lineage mappings, visualizations, and transformations using react
1,130 lines (1,123 loc) • 48.6 kB
JavaScript
;
var React = require('react');
var d3 = require('d3');
function _interopNamespaceDefault(e) {
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var d3__namespace = /*#__PURE__*/_interopNamespaceDefault(d3);
class LineageMap {
constructor(container, options = {}) {
this.expandedTables = new Set();
this.highlightedRelatedFields = new Set();
this.showTableRelationships = false;
this.selectedField = null;
this.positions = new Map();
this.validationErrors = new Map();
this.currentGraph = null;
this.getOptimalTableY = (graph, tableId, positions) => {
var _a;
const incomingEdges = graph.edges.filter(e => e.target.startsWith(tableId));
if (incomingEdges.length === 0)
return null;
const edgeSourceSet = new Set();
const tableSourceSet = new Set();
let totalY = 0;
let minY = null;
for (const edge of incomingEdges) {
tableSourceSet.add(edge.source.split(":")[0]);
if (!edgeSourceSet.has(edge.source)) {
edgeSourceSet.add(edge.source);
const fieldPositionY = (_a = positions.get(edge.source)) === null || _a === undefined ? undefined : _a.y;
if (fieldPositionY) {
totalY += fieldPositionY;
if (minY === null || fieldPositionY < minY) {
minY = fieldPositionY;
}
}
}
}
// if theres only 1 table feeding into this table
// we dont want to have this tables y position start at the average
// incoming y position, so we put this tables y position to the min
// incoming y position.
if (tableSourceSet.size === 1) {
return minY;
}
else {
return totalY / edgeSourceSet.size;
}
};
this.container = container;
this.options = {
width: options.width || '100%',
height: options.height || '100%',
tableWidth: options.tableWidth || 150,
tableHeight: options.tableHeight || 40,
fieldHeight: options.fieldHeight || 20,
fieldSpacing: options.fieldSpacing || 4,
levelPadding: options.levelPadding || 100,
verticalPadding: options.verticalPadding || 50,
popUpWidth: options.popUpWidth || 300,
popUpFloat: options.popUpFloat || "high",
maxCurveOffset: options.maxCurveOffset || 100
};
this.init();
}
init() {
// Create SVG container
this.svg = d3__namespace.select(this.container)
.append('svg')
.style('width', this.options.width)
.style('height', this.options.height);
this.mainGroup = this.svg.append('g');
// Setup zoom behavior with correct types
this.zoom = d3__namespace.zoom()
.scaleExtent([0.1, 22])
.on('zoom', (event) => {
this.mainGroup.attr('transform', event.transform.toString());
});
this.svg.call(this.zoom)
.on('dblclick.zoom', null);
}
validateTransformations(graph) {
this.validationErrors.clear();
// Build an index of incoming edges for each field
const incomingEdges = new Map();
graph.edges.forEach(edge => {
var _a;
if (!incomingEdges.has(edge.target)) {
incomingEdges.set(edge.target, new Set());
}
(_a = incomingEdges.get(edge.target)) === null || _a === undefined ? undefined : _a.add(edge.source);
});
// Regular expression to find field references
const fieldRefRegex = /[a-zA-Z_]+:[a-zA-Z_]+\d*/g;
// Validate each node's transformation
graph.nodes.forEach(node => {
if (node.type === 'field' && node.transformation) {
const errors = [];
// Extract referenced fields from transformation
const referencedFields = (node.transformation.match(fieldRefRegex) || []);
// Ensure uniqueness of referenced fields
const referencedFieldSet = new Set(referencedFields);
// Check each referenced field
const incomingFields = incomingEdges.get(node.id) || new Set();
referencedFieldSet.forEach(fieldRef => {
if (!incomingFields.has(fieldRef)) {
errors.push(`Field "${fieldRef}" is used in transformation but has no edge connecting to "${node.id}".`);
}
});
// Check if there are edges that aren't used in the transformation
incomingFields.forEach(sourceField => {
if (!referencedFieldSet.has(sourceField)) {
errors.push(`Field "${sourceField}" has an edge but isn't used in the transformation.`);
}
});
if (errors.length > 0) {
this.validationErrors.set(node.id, errors);
}
}
});
return this.validationErrors;
}
renderTable(node, data) {
const { tableWidth, tableHeight } = this.options;
node.attr('data-table-id', data.id);
// Add drop shadow filter
const defs = node.append('defs');
const dropShadowId = `dropShadow-${data.id}`;
defs.append('filter')
.attr('id', dropShadowId)
.append('feDropShadow')
.attr('dx', '0')
.attr('dy', '2')
.attr('stdDeviation', '3')
.attr('flood-opacity', '0.15');
// Add table background
node.append('rect')
.attr('width', tableWidth)
.attr('height', tableHeight)
.attr('fill', '#ffffff')
.attr('stroke', '#E2E8F0')
.attr('stroke-width', '1')
.attr('rx', 6)
.style('filter', `url(#${dropShadowId})`);
// Add table header with gradient
const headerGradientId = `headerGradient-${data.id}`;
const headerGradient = defs.append('linearGradient')
.attr('id', headerGradientId)
.attr('x1', '0%')
.attr('y1', '0%')
.attr('x2', '0%')
.attr('y2', '100%');
headerGradient.append('stop')
.attr('offset', '0%')
.attr('stop-color', '#F8FAFC');
headerGradient.append('stop')
.attr('offset', '100%')
.attr('stop-color', '#F1F5F9');
node.append('rect')
.attr('class', 'table-header clickable-area')
.attr('width', tableWidth)
.attr('height', tableHeight)
.attr('fill', `url(#${headerGradientId})`)
.attr('stroke', '#E2E8F0')
.attr('rx', 6)
.attr('clip-path', `path('M0,0 h${tableWidth} v${tableHeight} h-${tableWidth} Z')`);
// Add table name
node.append('text')
.attr('class', 'clickable-area')
.attr('x', 16)
.attr('y', tableHeight / 2)
.attr('dy', '0.35em')
.attr('fill', '#1E293B')
.style('font-family', '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif')
.style('font-size', '13px')
.style('font-weight', '500')
.text(data.name)
.style('pointer-events', 'none');
// Add info button only if there's a note
if ('note' in data && data.note) {
const infoButtonSize = 20;
const infoButtonX = tableWidth - infoButtonSize - 12;
const infoButtonY = (tableHeight - infoButtonSize) / 2;
const infoButton = node.append('g')
.attr('class', 'table-info-button')
.attr('transform', `translate(${infoButtonX}, ${infoButtonY})`)
.style('cursor', 'pointer');
infoButton.append('rect')
.attr('width', infoButtonSize)
.attr('height', infoButtonSize)
.attr('rx', 4)
.attr('fill', '#F8FAFC')
.attr('stroke', '#CBD5E1')
.attr('stroke-width', '1');
infoButton.append('text')
.attr('x', infoButtonSize / 2)
.attr('y', infoButtonSize / 2)
.attr('text-anchor', 'middle')
.attr('dy', '0.35em')
.attr('fill', '#64748B')
.style('font-family', '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif')
.style('font-size', '12px')
.style('font-weight', '500')
.text('ⓘ')
.style('pointer-events', 'none');
}
}
showTableInfoPopup(table) {
this.hideTransformationPopup(); // Hide any existing popups
const pos = this.positions.get(table.id);
if (!pos)
return;
// Create popup container
const popup = this.mainGroup.append('g')
.attr('class', 'table-info-popup');
const padding = 10;
const maxWidth = this.options.popUpWidth;
const lineHeight = 20;
const codeLineHeight = 16;
const textWidth = maxWidth - (padding * 2);
// Create temporary text element for measurements
const tempText = popup.append('text')
.style('font-family', 'sans-serif')
.style('font-size', '12px');
// Add note if it exists
const lines = this.generateNote(table, textWidth, tempText);
tempText.remove();
const { boxWidth, boxHeight, popupX, popupY } = this.calculatePopupPosition(table, lines, codeLineHeight, lineHeight, padding, maxWidth);
// Add semi-transparent overlay
popup.append('rect')
.attr('class', 'popup-overlay')
.attr('x', popupX)
.attr('y', popupY)
.attr('width', boxWidth)
.attr('height', boxHeight)
.attr('fill', 'rgba(255, 255, 255, 0.95)')
.attr('stroke', '#dee2e6')
.attr('rx', 4)
.attr('filter', 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))');
// Add text
const textElement = popup.append('text')
.attr('x', popupX + padding)
.attr('y', popupY + padding + 12);
this.formatPopupLines(lines, textElement, popupX, padding, codeLineHeight, lineHeight);
}
generateNote(node, textWidth, tempText) {
const lines = [];
// node in this context can be either a table or field
if (node.note) {
lines.push({ text: `Note for ${node.name}: `, isError: false });
lines.push({ text: '', isError: false, extraSpace: true });
const blocks = this.extractTextBlocks(node.note);
blocks.forEach((block, index) => {
if (index > 0) {
// Add spacing between blocks
lines.push({ text: '', isError: false });
}
if (block.type === 'sql') {
// Format SQL blocks using monospace font and blue color
lines.push(...this.formatSQLBlock(block.content));
}
else {
// Format regular text blocks
lines.push(...this.formatTextBlock(block.content, textWidth, tempText));
}
});
}
return lines;
}
calculatePopupPosition(node, lines, codeLineHeight, lineHeight, padding, maxWidth) {
// Calculate box dimensions
const totalHeight = lines.reduce((acc, line) => {
if (line.isCode) {
return acc + codeLineHeight;
}
return acc + (line.text ? lineHeight : lineHeight / 2);
}, 0);
const boxHeight = totalHeight + padding * 2;
const boxWidth = Math.max(maxWidth, Math.max(...lines.filter(l => l.isCode).map(l => l.text.length * 7)) + padding * 2);
// Find the corresponding group element and its bounding box
const group = node.type === "field"
? document.querySelector(`.field-group[data-field-id="${node.id}"]`)
: document.querySelector(`g[data-table-id="${node.id}"]`);
if (!group) {
console.warn(`Group with id="${node.id}" not found`);
return { boxWidth, boxHeight, popupX: 0, popupY: 0 };
}
const bbox = group.getBoundingClientRect();
// Get bounding box of the d3 canvas
const svgNode = this.svg.node();
if (!svgNode) {
console.warn("SVG node is not available");
return { boxWidth, boxHeight, popupX: 0, popupY: 0 };
}
const svgBbox = svgNode.getBoundingClientRect();
// Get the current zoom/pan transformation
const transform = d3__namespace.zoomTransform(this.mainGroup.node());
// Convert screen coordinates to SVG space
const popupX = transform.invertX(bbox.right - svgBbox.left) + 5;
const popupY = this.options.popUpFloat === "high" ?
transform.invertY(bbox.top - svgBbox.top) - boxHeight + this.options.fieldHeight :
transform.invertY(bbox.top - svgBbox.top);
return { boxWidth, boxHeight, popupX, popupY };
}
formatPopupLines(lines, textElement, popupX, padding, codeLineHeight, lineHeight) {
let currentY = 0;
lines.forEach((line) => {
if (line.extraSpace) {
textElement.append('tspan')
.attr('x', popupX + padding)
.attr('dy', lineHeight) // Move down one line
.text('\u00A0') // Non-breaking space
.style('fill', 'white'); // Make it invisible
currentY += lineHeight * 2;
return;
}
const tspan = textElement.append('tspan')
.attr('x', popupX + padding)
.attr('dy', currentY === 0 ? 0 : (line.isCode ? codeLineHeight : lineHeight))
.text(line.text);
if (line.isCode) {
tspan
.style('font-family', 'monospace')
.style('font-size', '11px')
.style('fill', '#2563eb'); // Blue color for code
}
else {
tspan
.style('font-family', 'sans-serif')
.style('font-size', '12px')
.style('fill', line.isError ? '#ff9800' : '#1E293B');
}
currentY += line.isCode ? codeLineHeight : lineHeight;
});
}
renderField(node, data) {
const { tableWidth, fieldHeight } = this.options;
// Create a group for the field
const fieldGroup = node.append('g')
.attr('class', 'field-group');
// Add field background
const background = fieldGroup.append('rect')
.attr('class', 'field-row')
.attr('width', tableWidth)
.attr('height', fieldHeight)
.attr('fill', this.highlightedRelatedFields.has(data.id) ? '#e3f2fd' : '#ffffff')
.attr('stroke', '#eeeeee')
.style('cursor', 'pointer');
// If there are validation errors, add a warning indicator
if (this.validationErrors.has(data.id)) {
background.attr('stroke', '#ff9800')
.attr('stroke-width', '2');
fieldGroup.append('text')
.attr('class', 'warning-indicator')
.attr('x', tableWidth - 40)
.attr('y', fieldHeight / 2)
.attr('dy', '0.35em')
.attr('fill', '#ff9800')
.style('font-family', 'sans-serif')
.style('font-size', '11px')
.style('pointer-events', 'none')
.text('⚠');
}
// Add field name
fieldGroup.append('text')
.attr('class', 'field-text')
.attr('x', 10)
.attr('y', fieldHeight / 2)
.attr('dy', '0.35em')
.attr('fill', '#666666')
.style('font-family', 'sans-serif')
.style('font-size', '11px')
.style('pointer-events', 'none')
.text(data.name);
// Add transformation or note indicator
const fieldData = data;
if (fieldData.transformation || fieldData.note) {
const icon = fieldData.transformation ? 'ƒ' : 'ⓘ';
const color = this.validationErrors.has(data.id) ? '#ff9800' : '#666666';
fieldGroup.append('text')
.attr('class', fieldData.transformation ? 'transform-indicator' : 'note-indicator')
.attr('x', tableWidth - 20)
.attr('y', fieldHeight / 2)
.attr('dy', '0.35em')
.attr('fill', color)
.style('font-family', 'sans-serif')
.style('font-size', '11px')
.style('pointer-events', 'none')
.text(icon);
}
}
handleFieldClick(graph, fieldId) {
const field = graph.nodes.find(n => n.id === fieldId);
if (!(field && 'tableId' in field))
return;
// Narrowing `field` to a specific type
const fieldNode = field;
if (this.selectedField === fieldId) {
// Deselect if clicking the same field
this.selectedField = null;
this.hideTransformationPopup();
}
else {
this.selectedField = fieldId;
this.showTransformationPopup(fieldNode, graph);
this.highlightedRelatedFields = this.getRelatedFields(graph, fieldId);
this.renderHighlights(fieldId);
}
}
wrapText(text, maxWidth, textElement, isErrorText = false) {
const words = text.split(/\s+/);
let line = [];
let lines = [];
const startingBullet = text.startsWith('• ') ? '• ' : '';
// If there's a bullet point, handle the rest of the text
if (startingBullet) {
words[0] = words[0].substring(2);
}
const testText = textElement.append('tspan');
words.forEach(word => {
var _a;
line.push(word);
const testLine = (line.length === 1 && startingBullet)
? startingBullet + line.join(' ')
: line.join(' ');
testText.text(testLine);
if (((_a = testText.node()) === null || _a === undefined ? undefined : _a.getComputedTextLength()) > maxWidth) {
if (line.length > 1) {
line.pop();
const completedLine = (lines.length === 0 && startingBullet)
? startingBullet + line.join(' ')
: line.join(' ');
lines.push({
text: completedLine,
isError: isErrorText
});
line = [word];
}
else {
const completedLine = (lines.length === 0 && startingBullet)
? startingBullet + word
: word;
lines.push({
text: completedLine,
isError: isErrorText
});
line = [];
}
}
});
if (line.length > 0) {
const completedLine = (lines.length === 0 && startingBullet)
? startingBullet + line.join(' ')
: line.join(' ');
lines.push({
text: completedLine,
isError: isErrorText
});
}
testText.remove();
return lines;
}
extractTextBlocks(text) {
const blocks = [];
// Convert to lowercase for case-insensitive matching but keep original for content
const lowerText = text.toLowerCase();
let currentPos = 0;
while (true) {
const startTag = lowerText.indexOf(LineageMap.SQL_START_TAG, currentPos);
if (startTag === -1) {
// Add remaining text if there's any
const remaining = text.slice(currentPos).trim();
if (remaining) {
blocks.push({ type: 'text', content: remaining });
}
break;
}
// Add text before SQL block if there's any
const beforeSql = text.slice(currentPos, startTag).trim();
if (beforeSql) {
blocks.push({ type: 'text', content: beforeSql });
}
// Find the end of SQL block
const sqlStart = startTag + LineageMap.SQL_START_TAG.length;
const endTag = lowerText.indexOf(LineageMap.SQL_END_TAG, sqlStart);
if (endTag === -1) {
// If no end tag, treat rest as text
const remaining = text.slice(currentPos).trim();
if (remaining) {
blocks.push({ type: 'text', content: remaining });
}
break;
}
// Extract SQL content
const sql = text.slice(sqlStart, endTag).trim();
if (sql) {
blocks.push({ type: 'sql', content: sql });
}
currentPos = endTag + LineageMap.SQL_END_TAG.length;
}
return blocks;
}
formatTextBlock(text, maxWidth, tempText) {
// Split text by newline characters
const paragraphs = text.split('\n');
const result = [];
for (let i = 0; i < paragraphs.length; i++) {
// For non-empty paragraphs, wrap text to fit inside popup
if (paragraphs[i].trim() !== '') {
const wrappedLines = this.wrapText(paragraphs[i], maxWidth, tempText, false)
.map(line => (Object.assign(Object.assign({}, line), { isCode: false })));
result.push(...wrappedLines);
}
else {
// For empty paragraphs (just \n with nothing else), still add an empty line
result.push({ text: '', isError: false, isCode: false, extraSpace: true });
}
// Add an empty line between paragraphs (except after the last one)
if (i < paragraphs.length - 1) {
result.push({ text: '', isError: false, isCode: false });
}
}
return result;
}
formatSQLBlock(sql) {
return sql.split('\n')
.reduce((acc, line) => {
const trimmedLine = line.trimRight();
if (acc.length === 0 || !(trimmedLine === '' && acc[acc.length - 1] === '')) {
acc.push(trimmedLine);
}
return acc;
}, [])
.map(line => ({
text: line,
isError: false,
isCode: true
}));
}
showTransformationPopup(field, graph) {
this.hideTransformationPopup();
if (!field.transformation && !field.note)
return;
const pos = this.getFieldPosition(field.id);
if (!pos)
return;
// Create popup container
const popup = this.mainGroup.append('g')
.attr('class', 'transformation-popup');
const padding = 16;
const maxWidth = this.options.popUpWidth;
const lineHeight = 20;
const codeLineHeight = 16;
const textWidth = maxWidth - (padding * 2);
// Create temporary text element for measurements
const tempText = popup.append('text')
.style('font-family', 'monospace')
.style('font-size', '12px');
const lines = [];
// Add transformation if it exists
if (field.transformation) {
lines.push({ text: 'Transformation:', isError: false });
// Handle field references in transformation
const fieldRefRegex = /[a-zA-Z_]+:[a-zA-Z_]+\d*/g;
const fieldRefs = field.transformation.match(fieldRefRegex) || [];
let formattedText = field.transformation;
fieldRefs.forEach(fieldId => {
const referencedField = graph.nodes.find(n => n.id === fieldId && n.type === 'field');
if (referencedField) {
formattedText = formattedText.replace(new RegExp(`\\b${fieldId}\\b`, 'g'), referencedField.name);
}
});
lines.push({ text: formattedText, isError: false });
// Add a blank line if both transformation and note exist
if (field.note) {
lines.push({ text: '', isError: false });
}
}
// Add note if it exists
lines.push(...this.generateNote(field, textWidth, tempText));
// Add error messages if they exist
const errors = this.validationErrors.get(field.id);
if (errors && errors.length > 0) {
lines.push({ text: '', isError: false });
lines.push({ text: 'Validation Errors:', isError: true });
errors.forEach(error => {
const wrappedError = this.wrapText(`• ${error}`, textWidth, tempText, true);
lines.push(...wrappedError.map(line => (Object.assign(Object.assign({}, line), { isCode: false }))));
});
}
tempText.remove();
const { boxWidth, boxHeight, popupX, popupY } = this.calculatePopupPosition(field, lines, codeLineHeight, lineHeight, padding, maxWidth);
// Add semi-transparent overlay
popup.append('rect')
.attr('class', 'popup-overlay')
.attr('x', popupX)
.attr('y', popupY)
.attr('width', boxWidth)
.attr('height', boxHeight)
.attr('fill', 'rgba(255, 255, 255, 0.95)')
.attr('stroke', errors ? '#ff9800' : '#dee2e6')
.attr('rx', 4)
.attr('filter', 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))');
// Add text
const textElement = popup.append('text')
.attr('x', popupX + padding)
.attr('y', popupY + padding + 12);
this.formatPopupLines(lines, textElement, popupX, padding, codeLineHeight, lineHeight);
}
hideTransformationPopup() {
this.mainGroup.selectAll('.transformation-popup').remove();
}
getFieldPosition(fieldId) {
const pos = this.positions.get(fieldId);
if (!pos)
return null;
return {
x: pos.x,
y: pos.y + (this.options.fieldHeight / 2) // Center vertically in field
};
}
inferTableRelationships(graph) {
const tableRelations = new Set();
const inferredEdges = [];
graph.edges.forEach(edge => {
const sourceField = graph.nodes.find(n => n.id === edge.source);
const targetField = graph.nodes.find(n => n.id === edge.target);
if ((sourceField === null || sourceField === undefined ? undefined : sourceField.tableId) && (targetField === null || targetField === undefined ? undefined : targetField.tableId) && sourceField.tableId !== targetField.tableId) {
const relationKey = `${sourceField.tableId}->${targetField.tableId}`;
if (!tableRelations.has(relationKey)) {
tableRelations.add(relationKey);
inferredEdges.push({
id: `table-${relationKey}`,
source: sourceField.tableId,
target: targetField.tableId,
type: 'table-table'
});
}
}
});
return inferredEdges;
}
getTableLevels(graph) {
const upstreamTableDependencies = new Map();
const tableNodes = graph.nodes.filter(node => node.type === 'table');
const inferredEdges = this.inferTableRelationships(graph);
// Initialize dependencies for all tables
tableNodes.forEach(table => {
upstreamTableDependencies.set(table.id, new Set());
});
// Build dependencies based on inferred edges
inferredEdges.forEach(edge => {
const upstreamDeps = upstreamTableDependencies.get(edge.source);
if (upstreamDeps) {
upstreamDeps.add(edge.target);
}
});
const levels = [];
const processed = new Set();
// First, identify tables with no connections
const tablesWithNoConnections = tableNodes.filter(table => {
const hasIncomingEdges = inferredEdges.some(edge => edge.target === table.id);
const hasOutgoingEdges = inferredEdges.some(edge => edge.source === table.id);
return !hasIncomingEdges && !hasOutgoingEdges;
});
// Add tables with no connections to a separate level (level 0)
tablesWithNoConnections.forEach(table => {
levels.push({
id: table.id,
level: 0,
dependencies: []
});
processed.add(table.id);
});
// Process remaining tables with connections
let currentLevel = 1;
let maxLevel = 1;
while (processed.size < tableNodes.length) {
const currentLevelTables = Array.from(upstreamTableDependencies.entries())
.filter(([tableId, deps]) => (!processed.has(tableId) &&
Array.from(deps).every(dep => processed.has(dep))));
if (currentLevelTables.length === 0 && processed.size < tableNodes.length) {
// Handle any remaining tables (cyclic dependencies)
tableNodes
.filter(table => !processed.has(table.id))
.forEach(table => {
levels.push({
id: table.id,
level: currentLevel,
dependencies: Array.from(upstreamTableDependencies.get(table.id) || [])
});
processed.add(table.id);
});
}
else {
currentLevelTables.forEach(([tableId, deps]) => {
levels.push({
id: tableId,
level: currentLevel,
dependencies: Array.from(deps)
});
if (currentLevel > maxLevel)
maxLevel = currentLevel;
processed.add(tableId);
});
}
currentLevel++;
}
// Reverse the level numbers (excluding level 0)
// because the above logic assigns levels based on
// upstream dependencies (i.e., a table's dependencies
// determine its level). Reversing ensures that
// higher-level tables (with more dependencies)
// appear later in the hierarchy.
levels.forEach(node => {
if (node.level !== 0) {
node.level = maxLevel - node.level + 1;
}
});
// Group nodes by level
const levelMap = levels.reduce((map, node) => {
if (!map.has(node.level))
map.set(node.level, []);
map.get(node.level).push(node);
return map;
}, new Map());
// Sort levels in ascending order and flatten results
return [...levelMap.entries()]
.sort(([a], [b]) => a - b)
.flatMap(([_, nodes]) => nodes);
}
getRelatedFields(graph, fieldId) {
const related = new Set([fieldId]); // Only store fields that feed into the current field
const visited = new Set();
const traverse = (id) => {
if (visited.has(id))
return;
visited.add(id);
graph.edges.forEach(edge => {
if (edge.target === id) { // Only follow edges feeding into the current field
related.add(edge.source);
traverse(edge.source);
}
});
};
traverse(fieldId);
return related;
}
toggleTableExpansion(tableId) {
if (this.expandedTables.has(tableId)) {
this.expandedTables.delete(tableId);
}
else {
this.expandedTables.add(tableId);
}
if (this.currentGraph) {
this.render(this.currentGraph);
}
}
handleMouseEnterField(graph, fieldId) {
if (!this.selectedField) {
this.highlightedRelatedFields = this.getRelatedFields(graph, fieldId);
this.renderHighlights(fieldId);
}
}
handleMouseLeaveField() {
if (!this.selectedField) {
this.highlightedRelatedFields.clear();
this.renderHighlights(null);
}
}
renderHighlights(sourceFieldId) {
// Update field backgrounds
this.mainGroup.selectAll('.field-row')
.style('fill', d => {
// highlight related fields in blue
if (this.highlightedRelatedFields.has(d.id)) {
return '#e3f2fd'; // Blue
}
return '#ffffff'; // Default background
})
.style('stroke', d => {
// If this is the source field, use blue outline
if (sourceFieldId && d.id === sourceFieldId) {
return '#2196f3';
}
// If there are validation errors, use orange outline
else if (this.validationErrors.has(d.id)) {
return '#ff9800';
}
return '#eeeeee'; // default to grey outline
})
.style('stroke-width', d => {
// Source field or field with validation error gets 2px width
if (d.id === sourceFieldId || this.validationErrors.has(d.id)) {
return 2;
}
return 1; // default to 1px
});
// Update edges
this.mainGroup.selectAll('.edge')
.attr('stroke', (d, i, nodes) => {
// Cast the node to SVGElement to ensure getAttribute is available
const element = nodes[i];
const source = element.getAttribute('data-source');
const target = element.getAttribute('data-target');
if (source && target) {
const isHighlighted = this.highlightedRelatedFields.has(source) &&
this.highlightedRelatedFields.has(target);
return isHighlighted ? '#2196f3' : '#bbbbbb';
}
return '#bbbbbb';
})
.attr('stroke-width', (d, i, nodes) => {
// Cast the node to SVGElement to ensure getAttribute is available
const element = nodes[i];
const source = element.getAttribute('data-source');
const target = element.getAttribute('data-target');
if (source && target) {
const isHighlighted = this.highlightedRelatedFields.has(source) &&
this.highlightedRelatedFields.has(target);
return isHighlighted ? 2 : 1;
}
return 1;
});
}
renderBase(graph) {
this.currentGraph = graph;
// Expand all tables by default
graph.nodes
.filter(node => node.type === 'table')
.forEach(node => this.expandedTables.add(node.id));
this.render(graph);
}
render(graph) {
// Validate transformations before rendering
this.validateTransformations(graph);
// Continue with normal render
this.currentGraph = graph;
const tableLevels = this.getTableLevels(graph);
const positions = this.calculatePositions(graph, tableLevels);
this.mainGroup.selectAll('*').remove();
this.renderEdges(graph, positions);
this.renderNodes(graph, positions);
this.setupEventListeners();
}
calculatePositions(graph, tableLevels) {
const positions = new Map();
const { tableWidth, tableHeight, fieldHeight, fieldSpacing, levelPadding, verticalPadding } = this.options;
// Group tables by level
const tablesByLevel = new Map();
tableLevels.forEach((tableLevel) => {
if (!tablesByLevel.has(tableLevel.level)) {
tablesByLevel.set(tableLevel.level, []);
}
tablesByLevel.get(tableLevel.level).push(tableLevel.id);
});
// Calculate table heights (including expanded fields)
const getTableHeight = (tableId) => {
if (!this.expandedTables.has(tableId)) {
return tableHeight;
}
const fieldCount = graph.nodes.filter(n => n.type === "field" && n.tableId === tableId).length;
return tableHeight + (fieldCount * (fieldHeight + fieldSpacing));
};
// Calculate total height needed for each level
const levelHeights = new Map();
tablesByLevel.forEach((tablesInLevel, level) => {
const totalHeight = tablesInLevel.reduce((acc, tableId) => {
return acc + getTableHeight(tableId) + verticalPadding;
}, 0);
levelHeights.set(level, totalHeight);
});
// Find maximum level height for vertical centering
const maxLevelHeight = Math.max(...Array.from(levelHeights.values()));
tablesByLevel.forEach((tablesInLevel, level) => {
const levelX = level * (tableWidth + levelPadding);
const levelHeight = levelHeights.get(level) || 0;
let currentY = (maxLevelHeight - levelHeight) / 2;
tablesInLevel.forEach((tableId, idx) => {
const tableNode = graph.nodes.find(n => n.id === tableId);
if (!tableNode)
return;
// Determine Y position for the table
let yPosition = currentY + verticalPadding;
if (idx === 0) {
const optimalTableY = this.getOptimalTableY(graph, tableId, positions);
if (optimalTableY != null) {
yPosition = optimalTableY;
}
}
// Set table position
positions.set(tableId, { x: levelX, y: yPosition });
// Position fields if table is expanded
if (this.expandedTables.has(tableId)) {
const fields = graph.nodes.filter(n => n.type === "field" && n.tableId === tableId);
fields.forEach((field, index) => {
positions.set(field.id, {
x: levelX,
y: yPosition + tableHeight + index * (fieldHeight + fieldSpacing),
});
});
}
// Update Y position for next table
currentY = yPosition + getTableHeight(tableId) + verticalPadding;
});
});
// Add positions for any remaining nodes (if any)
graph.nodes.forEach(node => {
if (!positions.has(node.id)) {
// Set default position for any nodes not yet positioned
positions.set(node.id, { x: 0, y: 0 });
}
});
this.positions = positions;
return positions;
}
renderEdges(graph, positions) {
const edges = this.showTableRelationships ?
[...this.inferTableRelationships(graph), ...graph.edges] :
graph.edges;
// Group edges by source and target tables
const edgesByTables = new Map();
edges.forEach(edge => {
const sourceNode = graph.nodes.find(n => n.id === edge.source);
const targetNode = graph.nodes.find(n => n.id === edge.target);
if (!(sourceNode === null || sourceNode === undefined ? undefined : sourceNode.tableId) || !(targetNode === null || targetNode === undefined ? undefined : targetNode.tableId))
return;
if (!this.expandedTables.has(sourceNode.tableId) ||
!this.expandedTables.has(targetNode.tableId))
return;
const tableKey = `${sourceNode.tableId}-${targetNode.tableId}`;
if (!edgesByTables.has(tableKey)) {
edgesByTables.set(tableKey, []);
}
const tableEdges = edgesByTables.get(tableKey);
if (tableEdges) {
tableEdges.push(edge);
}
});
// Calculate control points for each table pair
edgesByTables.forEach((tableEdges, tableKey) => {
const tablePair = tableKey.split('-');
if (tablePair.length !== 2)
return;
const [sourceTableId, targetTableId] = tablePair;
const sourceTablePos = positions.get(sourceTableId);
const targetTablePos = positions.get(targetTableId);
if (!sourceTableId || !targetTableId || !sourceTablePos || !targetTablePos)
return;
// Calculate horizontal distance between tables
const horizontalDistance = targetTablePos.x - sourceTablePos.x;
// Draw edges
tableEdges.forEach((edge) => {
const sourcePos = positions.get(edge.source);
const targetPos = positions.get(edge.target);
if (!sourcePos || !targetPos)
return;
// Create the path element with guaranteed non-null source and target
this.mainGroup.append('path')
.attr('class', 'edge')
.attr('data-source', edge.source)
.attr('data-target', edge.target)
.attr('d', () => {
const start = [sourcePos.x + this.options.tableWidth, sourcePos.y + this.options.fieldHeight / 2];
const end = [targetPos.x, targetPos.y + this.options.fieldHeight / 2];
// Use fixed control point distances based on horizontal distance
const curveOffset = Math.min(horizontalDistance / 3, this.options.maxCurveOffset);
const ctrl1 = [start[0] + curveOffset, start[1]];
const ctrl2 = [end[0] - curveOffset, end[1]];
return `M ${start[0]},${start[1]}
C ${ctrl1[0]},${ctrl1[1]}
${ctrl2[0]},${ctrl2[1]}
${end[0]},${end[1]}`;
})
.attr('stroke', '#bbbbbb')
.attr('stroke-width', 1)
.attr('fill', 'none');
});
});
}
renderNodes(graph, positions) {
// Create node groups
const nodes = this.mainGroup
.selectAll('.node')
.data(graph.nodes)
.join('g')
.attr('class', 'node')
.attr('cursor', 'pointer')
.attr('transform', (d) => {
const pos = positions.get(d.id);
return pos ? `translate(${pos.x},${pos.y})` : '';
});
// Render tables
nodes.filter((d) => d.type === 'table')
.each((d, i, nodes) => {
const node = d3__namespace.select(nodes[i]);
this.renderTable(node, d);
});
// Render fields for expanded tables
nodes.filter((d) => d.type === 'field' && this.expandedTables.has(d.tableId))
.each((d, i, nodes) => {
const node = d3__namespace.select(nodes[i]);
this.renderField(node, d);
});
}
hideFieldDetails() {
this.hideTransformationPopup();
this.selectedField = null;
this.highlightedRelatedFields.clear();
this.renderHighlights(null);
}
setupEventListeners() {
this.mainGroup.selectAll('.table-header')
.style('cursor', 'pointer')
.on('click', (event, d) => this.toggleTableExpansion(d.id));
this.mainGroup.selectAll('.field-group')
.attr('data-field-id', (d) => d.id)
.on('mouseenter', (event, d) => {
if (this.currentGraph) {
this.handleMouseEnterField(this.currentGraph, d.id);
}
})
.on('mouseleave', () => {
if (this.currentGraph) {
this.handleMouseLeaveField();
}
})
.on('click', (event, d) => {
if (this.currentGraph) {
this.handleFieldClick(this.currentGraph, d.id);
}
});
this.mainGroup.selectAll('.table-info-button')
.on('click', (event, d) => {
const tableNode = d;
this.showTableInfoPopup(tableNode);
});
// Combined click handler for closing all popups when clicking outside
this.svg.on('click', (event) => {
const target = event.target;
const isClickingField = target.closest('.field-group');
const isClickingTablePopup = target.closest('.table-info-button');
// Close popups if clicking outside of relevant elements
if (isClickingField) {
// hide table info pop ups if were clicking on a field
this.mainGroup.selectAll('.table-info-popup').remove();
}
else if (isClickingTablePopup) {
// hide field details if were clicking on a table info button
this.hideFieldDetails();
}
else {
// hide field details and hide table info popup if we click anywhere else
this.mainGroup.selectAll('.table-info-popup').remove();
this.hideFieldDetails();
}
});
}
destroy() {
// Remove D3 events and clean up
if (this.svg) {
this.svg.selectAll('*').remove();
this.svg.remove();
}
// Clear any stored state
this.expandedTables.clear();
this.highlightedRelatedFields.clear();
this.positions.clear();
this.validationErrors.clear();
this.currentGraph = null;
}
}
LineageMap.SQL_START_TAG = '---startsql';
LineageMap.SQL_END_TAG = '---endsql';
const LineageMapComponent = ({ data, width = '100%', height = '800px', options = {}, className = '', }) => {
const containerRef = React.useRef(null);
const lineageMapRef = React.useRef(null);
data.edges.forEach(((edge, index) => edge.id = index.toString()));
data.nodes.forEach(((node) => {
if (node.type === "field") {
node.tableId = node.id.split(":")[0];
}
}));
const graph = data;
React.useEffect(() => {
if (containerRef.current && !lineageMapRef.current) {
const defaultOptions = Object.assign({ width,
height, tableWidth: 150, tableHeight: 40, fieldHeight: 20, fieldSpacing: 4, levelPadding: 100, verticalPadding: 50 }, options);
lineageMapRef.current = new LineageMap(containerRef.current, defaultOptions);
window.lineageMap = lineageMapRef.current;
lineageMapRef.current.renderBase(graph);
}
return () => {
if (lineageMapRef.current) {
lineageMapRef.current.destroy();
lineageMapRef.current = null;
}
if (containerRef.current) {
containerRef.current.innerHTML = '';
}
if (window.lineageMap === lineageMapRef.current) {
delete window.lineageMap;
}
};
}, [data, width, height, options]);
return (React.createElement("div", { ref: containerRef, className: `react-lineage-map ${className}`, style: { width, height } }));
};
exports.LineageMapComponent = LineageMapComponent;
//# sourceMappingURL=index.js.map