davidardz07's picture
Colores de nodos desde CSV
d9f4ab9
// DOM Elements
const sidebar = document.getElementById('sidebar');
const toggleSidebarBtn = document.getElementById('toggle-sidebar');
const settingsBtn = document.getElementById('settings-btn');
const settingsDropdown = document.getElementById('settings-dropdown');
// Modal Elements
const editModal = document.getElementById('edit-modal');
const modalTitle = document.getElementById('modal-title');
const nodeEditForm = document.getElementById('node-edit-form');
const linkEditForm = document.getElementById('link-edit-form');
const editNodeName = document.getElementById('edit-node-name');
const editLinkSource = document.getElementById('edit-link-source');
const editLinkTarget = document.getElementById('edit-link-target');
const editLinkValue = document.getElementById('edit-link-value');
const saveEditBtn = document.getElementById('save-edit');
const cancelEditBtn = document.getElementById('cancel-edit');
const closeModalBtn = document.getElementById('close-modal');
let currentEditItem = null; // Store the item being edited
// Modal Functions
function openModal() {
editModal.classList.remove('hidden');
feather.replace();
}
function closeModal() {
editModal.classList.add('hidden');
currentEditItem = null;
nodeEditForm.classList.add('hidden');
linkEditForm.classList.add('hidden');
}
function populateNodeForm(node) {
modalTitle.textContent = 'Edit Node';
nodeEditForm.classList.remove('hidden');
linkEditForm.classList.add('hidden');
editNodeName.value = node.name;
}
function populateLinkForm(link) {
modalTitle.textContent = 'Edit Link';
linkEditForm.classList.remove('hidden');
nodeEditForm.classList.add('hidden');
// Clear and populate source/target dropdowns
editLinkSource.innerHTML = '';
editLinkTarget.innerHTML = '';
sankeyData.nodes.forEach(node => {
const sourceOption = document.createElement('option');
sourceOption.value = node.name;
sourceOption.textContent = node.name;
editLinkSource.appendChild(sourceOption.cloneNode(true));
editLinkTarget.appendChild(sourceOption);
});
// Handle different link data structures
let sourceName, targetName, linkValue;
if (link.originalSource) {
// Case: clicked from diagram
sourceName = typeof link.originalSource === 'object' ? link.originalSource.name : sankeyData.nodes[link.originalSource].name;
targetName = typeof link.originalTarget === 'object' ? link.originalTarget.name : sankeyData.nodes[link.originalTarget].name;
linkValue = link.originalValue;
} else {
// Case: clicked from list
sourceName = typeof link.source === 'object' ? link.source.name : sankeyData.nodes[link.source].name;
targetName = typeof link.target === 'object' ? link.target.name : sankeyData.nodes[link.target].name;
linkValue = link.value;
}
editLinkSource.value = sourceName;
editLinkTarget.value = targetName;
editLinkValue.value = linkValue;
}
// Event Handlers for Modal
closeModalBtn.addEventListener('click', closeModal);
cancelEditBtn.addEventListener('click', closeModal);
// Click outside modal to close
editModal.addEventListener('click', (e) => {
if (e.target === editModal) {
closeModal();
}
});
// Toggle sidebar
toggleSidebarBtn.addEventListener('click', () => {
sidebar.classList.toggle('collapsed');
});
// Toggle settings dropdown
let isSettingsOpen = false;
settingsBtn.addEventListener('click', (e) => {
e.stopPropagation();
isSettingsOpen = !isSettingsOpen;
settingsDropdown.classList.toggle('hidden', !isSettingsOpen);
});
// Close settings dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!settingsDropdown.contains(e.target) && !settingsBtn.contains(e.target)) {
settingsDropdown.classList.add('hidden');
isSettingsOpen = false;
}
});
// Prevent settings dropdown from closing when clicking inside it
settingsDropdown.addEventListener('click', (e) => {
e.stopPropagation();
});
// Initialize Feather icons
document.addEventListener('DOMContentLoaded', () => {
feather.replace();
});
// Define the d3.sankey layout function
d3.sankey = function() {
var sankey = {},
nodeWidth = 100,
nodePadding = 80,
size = [1, 1],
nodes = [],
links = [];
sankey.nodeWidth = function(_) {
if (!arguments.length) return nodeWidth;
nodeWidth = +_;
return sankey;
};
sankey.nodePadding = function(_) {
if (!arguments.length) return nodePadding;
nodePadding = +_;
return sankey;
};
sankey.nodes = function(_) {
if (!arguments.length) return nodes;
nodes = _;
return sankey;
};
sankey.links = function(_) {
if (!arguments.length) return links;
links = _;
return sankey;
};
sankey.size = function(_) {
if (!arguments.length) return size;
size = _;
return sankey;
};
sankey.layout = function(iterations) {
if (!nodes.length) return sankey;
computeNodeLinks();
computeNodeValues();
computeNodeBreadths();
computeNodeDepths(iterations || 32);
computeLinkDepths();
return sankey;
};
sankey.relayout = function() {
computeLinkDepths();
return sankey;
};
sankey.link = function() {
var curvature = .5;
function link(d) {
// Calculate node centers
var sourceCenter = d.source.dx / 2,
targetCenter = d.target.dx / 2;
var x0 = d.source.x + sourceCenter,
x1 = d.target.x + targetCenter,
xi = d3.interpolateNumber(x0, x1),
x2 = xi(curvature),
x3 = xi(1 - curvature),
y0 = d.source.y + d.sy + d.dy / 2,
y1 = d.target.y + d.ty + d.dy / 2;
return "M" + x0 + "," + y0
+ "C" + x2 + "," + y0
+ " " + x3 + "," + y1
+ " " + x1 + "," + y1;
}
link.curvature = function(_) {
if (!arguments.length) return curvature;
curvature = +_;
return link;
};
return link;
};
// Populate the sourceLinks and targetLinks for each node.
function computeNodeLinks() {
nodes.forEach(function(node) {
node.sourceLinks = [];
node.targetLinks = [];
});
links.forEach(function(link) {
var source = link.source,
target = link.target;
if (typeof source === "number") source = link.source = nodes[link.source];
if (typeof target === "number") target = link.target = nodes[link.target];
source.sourceLinks.push(link);
target.targetLinks.push(link);
});
}
// Compute the value (size) of each node by summing the associated links.
function computeNodeValues() {
nodes.forEach(function(node) {
node.value = Math.max(
d3.sum(node.sourceLinks, value),
d3.sum(node.targetLinks, value)
);
});
}
function computeNodeBreadths() {
// Separate linked and unlinked nodes
var linkedNodes = nodes.filter(function(node) {
return node.sourceLinks.length > 0 || node.targetLinks.length > 0;
});
var unlinkedNodes = nodes.filter(function(node) {
return node.sourceLinks.length === 0 && node.targetLinks.length === 0;
});
// Process only linked nodes for layout
var remainingNodes = linkedNodes,
nextNodes,
x = 0;
while (remainingNodes.length) {
nextNodes = [];
remainingNodes.forEach(function(node) {
node.x = x;
node.dx = nodeWidth;
node.sourceLinks.forEach(function(link) {
if (nextNodes.indexOf(link.target) < 0) {
nextNodes.push(link.target);
}
});
});
remainingNodes = nextNodes;
++x;
}
// Move sinks right
moveSinksRight(x);
// Scale the layout for linked nodes
scaleNodeBreadths((size[0] - nodeWidth) / (x - 1));
// Position unlinked nodes off-screen or at a specific position
unlinkedNodes.forEach(function(node) {
node.x = -1; // Position off-screen
node.dx = 0; // No width
});
}
function moveSinksRight(x) {
nodes.forEach(function(node) {
if (!node.sourceLinks.length) {
node.x = x - 1;
}
});
}
function scaleNodeBreadths(kx) {
nodes.forEach(function(node) {
node.x *= kx;
});
}
function computeNodeDepths(iterations) {
// Group nodes by x-coordinate
var nodesByBreadth = d3.group(nodes, d => d.x);
// Convert Map to Array and ensure it's sorted
nodesByBreadth = Array.from(nodesByBreadth.values());
initializeNodeDepth();
resolveCollisions();
for (var alpha = 1; iterations > 0; --iterations) {
relaxRightToLeft(alpha *= .99);
resolveCollisions();
relaxLeftToRight(alpha);
resolveCollisions();
}
function initializeNodeDepth() {
// Calculate vertical scaling factor
var ky = d3.min(nodesByBreadth, function(nodes) {
return (size[1] - (nodes.length - 1) * nodePadding) / d3.sum(nodes, value);
});
nodesByBreadth.forEach(function(nodes) {
nodes.forEach(function(node, i) {
node.y = i;
node.dy = node.value * ky;
});
});
links.forEach(function(link) {
link.dy = link.value * ky;
});
}
function relaxLeftToRight(alpha) {
nodesByBreadth.forEach(function(nodes) {
nodes.forEach(function(node) {
if (node.targetLinks.length) {
var y = d3.sum(node.targetLinks, weightedSource) / d3.sum(node.targetLinks, value);
node.y += (y - center(node)) * alpha;
}
});
});
function weightedSource(link) {
return center(link.source) * link.value;
}
}
function relaxRightToLeft(alpha) {
nodesByBreadth.slice().reverse().forEach(function(nodes) {
nodes.forEach(function(node) {
if (node.sourceLinks.length) {
var y = d3.sum(node.sourceLinks, weightedTarget) / d3.sum(node.sourceLinks, value);
node.y += (y - center(node)) * alpha;
}
});
});
function weightedTarget(link) {
return center(link.target) * link.value;
}
}
function resolveCollisions() {
nodesByBreadth.forEach(function(nodes) {
var node,
dy,
y0 = 0,
n = nodes.length,
i;
nodes.sort(ascendingDepth);
for (i = 0; i < n; ++i) {
node = nodes[i];
dy = y0 - node.y;
if (dy > 0) node.y += dy;
y0 = node.y + node.dy + nodePadding;
}
dy = y0 - nodePadding - size[1];
if (dy > 0) {
y0 = node.y -= dy;
for (i = n - 2; i >= 0; --i) {
node = nodes[i];
dy = node.y + node.dy + nodePadding - y0;
if (dy > 0) node.y -= dy;
y0 = node.y;
}
}
});
}
function ascendingDepth(a, b) {
return a.y - b.y;
}
}
function computeLinkDepths() {
nodes.forEach(function(node) {
node.sourceLinks.sort(ascendingTargetDepth);
node.targetLinks.sort(ascendingSourceDepth);
});
nodes.forEach(function(node) {
var sy = 0, ty = 0;
node.sourceLinks.forEach(function(link) {
link.sy = sy;
sy += link.dy;
});
node.targetLinks.forEach(function(link) {
link.ty = ty;
ty += link.dy;
});
});
function ascendingSourceDepth(a, b) {
return a.source.y - b.source.y;
}
function ascendingTargetDepth(a, b) {
return a.target.y - b.target.y;
}
}
function value(link) {
return link.value;
}
function center(node) {
return node.y + node.dy / 2;
}
return sankey;
};
// Sankey diagram data structure with example data
let sankeyData = {
nodes: [
{ name: "Source", color: "#4f46e5" },
{ name: "Process", color: "#4f46e5" },
{ name: "Output A", color: "#4f46e5" },
{ name: "Output B", color: "#4f46e5" }
],
links: [
{ source: 0, target: 1, value: 100 },
{ source: 1, target: 2, value: 60 },
{ source: 1, target: 3, value: 40 }
]
};
// Load saved data from localStorage
const saved = localStorage.getItem('sankeyData');
if (saved) {
sankeyData = JSON.parse(saved);
}
// Store node positions for dragging
let nodePositions = {};
// Utility function to convert hex to RGB
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : { r: 0, g: 0, b: 0 };
}
// Save data to localStorage
function saveData() {
localStorage.setItem('sankeyData', JSON.stringify(sankeyData));
}
// Edit node or link
function handleEditNode(node, index) {
currentEditItem = { type: 'node', index };
populateNodeForm(node);
openModal();
}
function handleEditLink(link, index) {
currentEditItem = { type: 'link', index };
populateLinkForm(link);
openModal();
}
// Save changes from modal
saveEditBtn.addEventListener('click', () => {
if (currentEditItem) {
if (currentEditItem.type === 'node') {
const newName = editNodeName.value.trim();
if (newName) {
sankeyData.nodes[currentEditItem.index].name = newName;
}
} else if (currentEditItem.type === 'link') {
const sourceNode = editLinkSource.value;
const targetNode = editLinkTarget.value;
const value = parseInt(editLinkValue.value);
// Validate the index exists
if (currentEditItem.index >= 0 && currentEditItem.index < sankeyData.links.length) {
// Find the indices of source and target nodes
const sourceIndex = sankeyData.nodes.findIndex(n => n.name === sourceNode);
const targetIndex = sankeyData.nodes.findIndex(n => n.name === targetNode);
if (sourceIndex !== -1 && targetIndex !== -1 && value > 0) {
// Update the link data
sankeyData.links[currentEditItem.index] = {
source: sourceIndex,
target: targetIndex,
value: value
};
}
}
}
// Clear diagram cache to force complete redraw
nodePositions = {};
// Update everything
updateLists();
initDiagram();
saveData();
closeModal();
}
});
// Initialize the diagram
function initDiagram() {
const svg = d3.select("#sankey-diagram");
svg.selectAll("*").remove();
// Early return if SVG container doesn't exist
if (!svg.node()) return;
// Get the container dimensions
const container = document.getElementById('diagram-container');
const width = container.clientWidth;
const containerHeight = container.clientHeight;
// Set up the sankey diagram properties
const baseHeight = parseInt(document.getElementById('diagram-height').value);
const textPosition = document.getElementById('text-position').value;
const textPadding = textPosition !== 'middle' ? 50 : 0; // Extra space for text above/below
// Use the larger of container height or base height to prevent shrinking
const height = Math.max(containerHeight, baseHeight + textPadding);
const linkAlpha = parseFloat(document.getElementById('link-alpha').value);
// Update SVG dimensions
svg.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("preserveAspectRatio", "xMinYMin");
// Create Sankey generator with dynamic padding based on node count
const nodeCount = sankeyData.nodes.length;
const dynamicPadding = Math.max(20, Math.min(120, Math.floor((height - textPadding) / (nodeCount * 1.5))));
const sankey = d3.sankey()
.nodeWidth(parseInt(document.getElementById('node-width').value))
.nodePadding(dynamicPadding)
.size([width - textPadding, height - textPadding]); // Create a copy of the data
const graph = {
nodes: sankeyData.nodes.map(d => Object.assign({}, d)),
links: sankeyData.links.map(d => Object.assign({}, d))
};
// Generate initial layout
sankey.nodes(graph.nodes).links(graph.links).layout(32);
// Filter only connected nodes for ky calculation
const nodesForKy = graph.nodes.filter(node =>
node.sourceLinks.length > 0 || node.targetLinks.length > 0
);
// Group connected nodes by x coordinate
const nodesByX = d3.groups(nodesForKy, d => d.x);
const ky = d3.min(nodesByX, ([x, nodes]) =>
(height - (nodes.length - 1) * sankey.nodePadding()) /
d3.sum(nodes, d => d.value)
);
// Apply the scaling to nodes and links
graph.nodes.forEach(node => {
// Only apply height to connected nodes
if (node.sourceLinks.length > 0 || node.targetLinks.length > 0) {
const nodeHeight = node.value * ky;
node.dy = nodeHeight;
} else {
node.dy = 0; // Unlinked nodes get zero height
}
});
graph.links.forEach(link => {
link.dy = link.value * ky;
});
// Relayout to apply the new heights
sankey.layout(1);
const nodes = sankey.nodes();
const links = sankey.links();
// Clear previous diagram
svg.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto;");
// Add links
const link = svg.append("g")
.attr("fill", "none")
.selectAll("path")
.data(links)
.join("path")
.attr("d", sankey.link())
link.attr("stroke", d => {
const linkColorMode = document.getElementById('link-color-mode').value;
let color;
if (linkColorMode === 'static') {
color = document.getElementById('static-link-color').value;
} else {
color = linkColorMode === 'source'
? d.source.color
: d.target.color;
}
const finalColor = color || "#93c5fd";
const rgb = hexToRgb(finalColor);
return `rgba(${rgb.r},${rgb.g},${rgb.b},${linkAlpha})`;
})
.attr("stroke-width", d => Math.max(1, d.dy))
.style("mix-blend-mode", "multiply")
.attr("cursor", "pointer")
.on("mouseover", function() {
d3.select(this).attr("stroke-opacity", 0.8);
})
.on("mouseout", function() {
d3.select(this).attr("stroke-opacity", linkAlpha);
})
.on("dblclick", function(event, d) {
// Find the link index by matching source name and target name
const index = sankeyData.links.findIndex(link => {
const sourceName = typeof d.source === 'object' ? d.source.name : sankeyData.nodes[d.source].name;
const targetName = typeof d.target === 'object' ? d.target.name : sankeyData.nodes[d.target].name;
const linkSourceName = typeof link.source === 'object' ?
link.source.name : sankeyData.nodes[link.source].name;
const linkTargetName = typeof link.target === 'object' ?
link.target.name : sankeyData.nodes[link.target].name;
return sourceName === linkSourceName &&
targetName === linkTargetName &&
link.value === d.value;
});
// Store the link data for the modal
const linkData = {
source: sankeyData.links[index].source,
target: sankeyData.links[index].target,
value: sankeyData.links[index].value,
sourceName: typeof d.source === 'object' ? d.source.name : sankeyData.nodes[d.source].name,
targetName: typeof d.target === 'object' ? d.target.name : sankeyData.nodes[d.target].name
};
handleEditLink(linkData, index);
});
// Filter out nodes that have no connections
const connectedNodes = nodes.filter(node =>
(node.sourceLinks && node.sourceLinks.length > 0) ||
(node.targetLinks && node.targetLinks.length > 0)
);
// Update diagram container background
const backgroundColor = document.getElementById('background-color').value;
document.getElementById('diagram-container').style.backgroundColor = backgroundColor;
// Add nodes with better styling
const node = svg.append("g")
.selectAll("g")
.data(connectedNodes)
.join("g")
.attr("transform", d => `translate(${d.x},${d.y})`);
// Apply stored positions if they exist
node.attr("transform", d => {
const pos = nodePositions[d.index];
return pos ? `translate(${pos.x},${pos.y})` : `translate(${d.x},${d.y})`;
});
const nodeEdgeColor = document.getElementById('node-edge-color').value;
// Add node rectangles with click interaction
node.append("rect")
.attr("height", d => Math.max(d.dy, 1)) // Ensure minimum height of 1px
.attr("width", d => d.dx)
.attr("fill", d => d.color || "#4f46e5")
.attr("stroke", nodeEdgeColor)
.attr("stroke-width", 1)
.attr("rx", 3) // Rounded corners
.attr("ry", 3)
.attr("cursor", "pointer")
.on("dblclick", function(event, d) {
handleEditNode(d, sankeyData.nodes.findIndex(node => node.name === d.name));
});
// Add drag behavior
node.call(d3.drag()
.subject(function(event, d) {
// Get mouse position relative to the node
const mousePoint = d3.pointer(event, this);
return {
x: d.x, // Keep x position fixed
y: d.y + mousePoint[1] // Add relative y position
};
})
.on("start", function(event) {
d3.select(this).raise();
})
.on("drag", function(event, d) {
// Calculate new position based on mouse movement
const yOffset = event.dy; // Use the change in y position
d.y += yOffset; // Update position incrementally
nodePositions[d.index] = { x: d.x, y: d.y };
d3.select(this).attr("transform", `translate(${d.x},${d.y})`);
// Update links
link.attr("d", sankey.link());
// After dragging, relayout to maintain proper link positions
sankey.relayout();
})
);
// Add text with customizable position
let textX, textY, textAnchor;
// Get the text padding value from the settings
const textPaddingValue = parseInt(document.getElementById('text-padding').value);
function getDefaultTextPosition(node) {
const diagramWidth = width - textPadding;
const xRatio = node.x / diagramWidth;
if (xRatio < 0.33) { // Left nodes
return {
x: node.dx + textPaddingValue, // Position text on right side
y: node.dy / 2,
anchor: "start"
};
} else if (xRatio > 0.66) { // Right nodes
return {
x: -textPaddingValue, // Position text on left side
y: node.dy / 2,
anchor: "end"
};
} else { // Middle nodes
return {
x: node.dx + textPaddingValue, // Position text on right side
y: node.dy / 2,
anchor: "start"
};
}
}
switch(textPosition) {
case 'default':
// Text position will be determined per node
textX = d => getDefaultTextPosition(d).x;
textY = d => getDefaultTextPosition(d).y;
textAnchor = d => getDefaultTextPosition(d).anchor;
break;
case 'left':
textX = -textPaddingValue;
textY = d => d.dy / 2;
textAnchor = "end";
break;
case 'right':
textX = d => d.dx + textPaddingValue;
textY = d => d.dy / 2;
textAnchor = "start";
break;
case 'top':
textX = d => d.dx / 2;
textY = -textPaddingValue;
textAnchor = "middle";
break;
case 'bottom':
textX = d => d.dx / 2;
textY = d => d.dy + textPaddingValue;
textAnchor = "middle";
break;
}
// Get font size from settings
const fontSizeMap = {
'small': '10px',
'medium': '12px',
'large': '14px'
};
const fontSize = fontSizeMap[document.getElementById('font-size').value] || '12px';
// Get text shadow settings
const textShadowMap = {
'none': 'none',
'light': '1px 1px 2px rgba(0,0,0,0.2)',
'medium': '2px 2px 4px rgba(0,0,0,0.3)',
'dark': '3px 3px 6px rgba(0,0,0,0.4)'
};
const textShadow = textShadowMap[document.getElementById('text-shadow').value] || 'none';
node.append("text")
.attr("x", textX)
.attr("y", textY || (d => d.dy / 2))
.attr("dy", d => {
// Adjust vertical alignment based on position
if (textPosition === 'top') return '-0.5em';
if (textPosition === 'bottom') return '1em';
return '.35em';
})
.attr("text-anchor", typeof textAnchor === 'function' ? textAnchor : d => textAnchor)
.text(d => d.name)
.attr("fill", document.getElementById('text-color').value)
.attr("font-size", fontSize)
.attr("font-weight", "bold")
.style("text-shadow", textShadow)
.attr("pointer-events", "none");
}
// Update UI lists
function updateLists() {
// Nodes list
const nodesList = document.getElementById('nodes-list');
nodesList.innerHTML = '';
sankeyData.nodes.forEach((node, index) => {
const nodeElement = document.createElement('div');
nodeElement.className = 'flex items-center justify-between bg-white p-2 rounded border border-gray-200 cursor-pointer';
nodeElement.innerHTML = `
<span>${node.name}</span>
<div class="flex items-center space-x-2">
<input type="color" class="node-color-picker w-6 h-6" data-index="${index}" value="${node.color || '#4f46e5'}">
<button class="text-red-500 hover:text-red-700 delete-node" data-index="${index}">
<i data-feather="trash-2" width="16"></i>
</button>
</div>
`;
// Add double-click handler for editing
nodeElement.addEventListener('dblclick', (e) => {
if (!e.target.classList.contains('node-color-picker') && !e.target.closest('.delete-node')) {
handleEditNode(node, index);
}
});
nodesList.appendChild(nodeElement);
});
// Links list
const linksList = document.getElementById('links-list');
linksList.innerHTML = '';
sankeyData.links.forEach((link, index) => {
const sourceNode = sankeyData.nodes[link.source]?.name || 'Unknown';
const targetNode = sankeyData.nodes[link.target]?.name || 'Unknown';
const linkElement = document.createElement('div');
linkElement.className = 'flex items-center justify-between bg-white p-2 rounded border border-gray-200 cursor-pointer';
linkElement.innerHTML = `
<span>${sourceNode}${targetNode} (${link.value})</span>
<button class="text-red-500 hover:text-red-700 delete-link" data-index="${index}">
<i data-feather="trash-2" width="16"></i>
</button>
`;
// Add double-click handler for editing
linkElement.addEventListener('dblclick', (e) => {
if (!e.target.closest('.delete-link')) {
handleEditLink(link, index);
}
});
linksList.appendChild(linkElement);
});
// Update dropdowns
updateNodeDropdowns();
// Refresh feather icons
feather.replace();
}
// Update node dropdowns in links section
function updateNodeDropdowns() {
const sourceSelect = document.getElementById('source-node');
const targetSelect = document.getElementById('target-node');
sourceSelect.innerHTML = '';
targetSelect.innerHTML = '';
sankeyData.nodes.forEach((node, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = node.name;
sourceSelect.appendChild(option.cloneNode(true));
targetSelect.appendChild(option);
});
}
// Export to CSV function
function exportToCSV() {
try {
if (!sankeyData || !sankeyData.links || sankeyData.links.length === 0) {
alert("No hay datos para exportar.");
return;
}
let csvContent = "Source,Target,Value,Color\r\n";
sankeyData.links.forEach(link => {
const src = (sankeyData.nodes[link.source] && sankeyData.nodes[link.source].name) || "";
const tgt = (sankeyData.nodes[link.target] && sankeyData.nodes[link.target].name) || "";
const val = link.value || 0;
const color = (sankeyData.nodes[link.source] && sankeyData.nodes[link.source].color) || "";
// Escape quotes
csvContent += `"${src.replace(/"/g,'""')}","${tgt.replace(/"/g,'""')}",${val},"${color}"\r\n`;
});
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = 'sankey_data.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log("CSV exported");
} catch (err) {
console.error("Error exporting CSV:", err);
alert("Error exporting CSV. See console for details.");
}
}
// Import from CSV function
function importFromCSV(file) {
const reader = new FileReader();
reader.onload = function(e) {
const text = e.target.result;
const lines = text.split('\n').filter(line => line.trim() !== '');
if (lines.length < 2) {
alert("Invalid CSV format");
return;
}
// Reset data
sankeyData = { nodes: [], links: [] };
nodePositions = {};
// Parse header
const header = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
const sourceIdx = header.indexOf('Source');
const targetIdx = header.indexOf('Target');
const valueIdx = header.indexOf('Value');
const colorIdx = header.indexOf('Color');
if (sourceIdx === -1 || targetIdx === -1 || valueIdx === -1) {
alert("CSV must have columns: Source, Target, Value");
return;
}
// Node name to index mapping
const nodeMap = {};
let nodeIndex = 0;
// First pass: collect all unique nodes and their relationships
const nodeRelations = new Map(); // Track incoming/outgoing links for each node
// Parse data rows
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim().replace(/"/g, ''));
if (values.length < 3) continue;
const sourceName = values[sourceIdx];
const targetName = values[targetIdx];
const value = parseFloat(values[valueIdx]);
const color = colorIdx !== -1 ? values[colorIdx] : "";
if (isNaN(value)) continue;
// Track relationships for column assignment
if (!nodeRelations.has(sourceName)) {
nodeRelations.set(sourceName, { in: 0, out: 0, totalValue: 0 });
}
if (!nodeRelations.has(targetName)) {
nodeRelations.set(targetName, { in: 0, out: 0, totalValue: 0 });
}
nodeRelations.get(sourceName).out += 1;
nodeRelations.get(sourceName).totalValue += value;
nodeRelations.get(targetName).in += 1;
nodeRelations.get(targetName).totalValue += value;
// Add nodes if they don't exist
if (!(sourceName in nodeMap)) {
nodeMap[sourceName] = nodeIndex++;
sankeyData.nodes.push({
name: sourceName,
color: color && /^#[0-9A-Fa-f]{6}$/.test(color.trim()) ? color.trim() : "#4f46e5",
value: 0 // Will be updated later
});
}
if (!(targetName in nodeMap)) {
nodeMap[targetName] = nodeIndex++;
sankeyData.nodes.push({
name: targetName,
color: "#4f46e5",
value: 0 // Will be updated later
});
}
// Add link
sankeyData.links.push({
source: nodeMap[sourceName],
target: nodeMap[targetName],
value: value
});
}
// Update node values based on maximum flow
sankeyData.nodes.forEach(node => {
const relation = nodeRelations.get(node.name);
node.value = Math.max(
relation.totalValue,
relation.in ? relation.totalValue / relation.in : 0,
relation.out ? relation.totalValue / relation.out : 0
);
});
updateLists();
initDiagram();
saveData();
};
reader.readAsText(file);
}
// Center diagram
function centerDiagram() {
nodePositions = {};
initDiagram();
}
// Event Listeners
document.addEventListener('DOMContentLoaded', () => {
// Add node
document.getElementById('add-node').addEventListener('click', () => {
const nodeName = document.getElementById('node-name').value.trim();
if (nodeName) {
sankeyData.nodes.push({ name: nodeName, color: "#4f46e5" });
document.getElementById('node-name').value = '';
updateLists();
initDiagram();
saveData();
}
});
// Add link
document.getElementById('add-link').addEventListener('click', () => {
const sourceIndex = parseInt(document.getElementById('source-node').value);
const targetIndex = parseInt(document.getElementById('target-node').value);
const value = parseInt(document.getElementById('link-value').value) || 1;
if (!isNaN(sourceIndex) && !isNaN(targetIndex) && !isNaN(value) && value > 0) {
sankeyData.links.push({
source: sourceIndex,
target: targetIndex,
value: value
});
document.getElementById('link-value').value = '';
updateLists();
initDiagram();
saveData();
}
});
// Export to CSV
document.getElementById('export-csv').addEventListener('click', exportToCSV);
// Import from CSV
document.getElementById('import-csv').addEventListener('click', () => {
document.getElementById('csv-file').click();
});
document.getElementById('csv-file').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
importFromCSV(file);
}
e.target.value = ''; // Reset input
});
// Settings panel toggle
const settingsBtn = document.getElementById('settings-btn');
const settingsPanel = document.getElementById('settings-panel');
if (settingsBtn && settingsPanel) {
settingsBtn.addEventListener('click', () => {
settingsPanel.classList.toggle('hidden');
});
}
// Center diagram
document.getElementById('center-diagram').addEventListener('click', () => {
centerDiagram();
saveData();
});
// Delete node or link
document.addEventListener('click', (e) => {
if (e.target.closest('.delete-node')) {
const button = e.target.closest('.delete-node');
const index = parseInt(button.getAttribute('data-index'));
// Delete node and associated links
sankeyData.links = sankeyData.links.filter(link =>
link.source !== index && link.target !== index
);
sankeyData.nodes.splice(index, 1);
// Update link indices
sankeyData.links = sankeyData.links.map(link => ({
source: link.source > index ? link.source - 1 : link.source,
target: link.target > index ? link.target - 1 : link.target,
value: link.value
}));
// Remove node position data
delete nodePositions[index];
// Update remaining node positions
const newNodePositions = {};
Object.keys(nodePositions).forEach(key => {
const keyNum = parseInt(key);
if (keyNum > index) {
newNodePositions[keyNum - 1] = nodePositions[key];
} else if (keyNum < index) {
newNodePositions[keyNum] = nodePositions[key];
}
});
nodePositions = newNodePositions;
updateLists();
initDiagram();
saveData();
}
if (e.target.closest('.delete-link')) {
const button = e.target.closest('.delete-link');
const index = parseInt(button.getAttribute('data-index'));
sankeyData.links.splice(index, 1);
updateLists();
initDiagram();
saveData();
}
});
// Node color change
document.addEventListener('input', (e) => {
if (e.target.classList.contains('node-color-picker')) {
const index = parseInt(e.target.getAttribute('data-index'));
const color = e.target.value;
sankeyData.nodes[index].color = color;
initDiagram();
saveData();
}
});
// Color and options changes
document.getElementById('text-color').addEventListener('input', initDiagram);
document.getElementById('text-position').addEventListener('change', initDiagram);
document.getElementById('text-padding').addEventListener('input', initDiagram);
document.getElementById('node-width').addEventListener('input', initDiagram);
document.getElementById('diagram-height').addEventListener('input', initDiagram);
document.getElementById('link-alpha').addEventListener('input', initDiagram);
document.getElementById('static-link-color').addEventListener('input', initDiagram);
document.getElementById('font-size').addEventListener('change', initDiagram);
document.getElementById('background-color').addEventListener('input', initDiagram);
document.getElementById('node-edge-color').addEventListener('input', initDiagram);
document.getElementById('text-shadow').addEventListener('change', initDiagram);
// Handle link color mode changes
const linkColorMode = document.getElementById('link-color-mode');
const staticColorContainer = document.getElementById('static-link-color-container');
linkColorMode.addEventListener('change', () => {
staticColorContainer.classList.toggle('hidden', linkColorMode.value !== 'static');
initDiagram();
});
// Initialize dropdowns and lists
updateLists();
initDiagram();
});