chartjs-chart-treenode-test
Version:
A Chart.js plugin to render tree node graphs
844 lines (697 loc) • 28.1 kB
JavaScript
// chartjs-chart-treenode/src/plugin.js
const treeGraphPlugin = {
id: 'treeGraph',
// Función para dividir texto inteligentemente
wrapText(ctx, text, maxWidth) {
if (!text || text.length === 0) return [''];
// Verificar si el texto completo cabe en una línea
const fullTextWidth = ctx.measureText(text).width;
if (fullTextWidth <= maxWidth) {
return [text];
}
const words = text.split(' ');
if (words.length === 1) {
// Si es una sola palabra muy larga, devolverla como está
return [text];
}
const lines = [];
let currentLine = '';
for (let i = 0; i < words.length; i++) {
const word = words[i];
const testLine = currentLine + (currentLine ? ' ' : '') + word;
const testWidth = ctx.measureText(testLine).width;
if (testWidth <= maxWidth) {
currentLine = testLine;
} else {
if (currentLine) {
lines.push(currentLine);
currentLine = word;
} else {
currentLine = word;
}
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines.length > 0 ? lines : [text];
},
// Calcular ancho máximo basado en el espacio entre columnas/filas
calculateMaxLabelWidth(chart) {
if (!chart._treeGraphData) return 100;
const area = chart.chartArea;
const paddingX = 50;
const paddingY = 40;
const dataset = chart.data.datasets[0];
const isVertical = dataset.vertical === true;
if (isVertical) {
// En vertical, usar el ancho disponible dividido por el número de columnas
const height = area.bottom - area.top - 2 * paddingY;
const maxDepth = chart._treeGraphData.maxDepth || 1;
const rowSpacing = height / Math.max(1, maxDepth);
return Math.max(60, rowSpacing * 0.7);
} else {
// Lógica original para horizontal
const width = area.right - area.left - 2 * paddingX;
const maxDepth = chart._treeGraphData.maxDepth || 1;
const columnSpacing = width / Math.max(1, maxDepth);
return Math.max(60, columnSpacing * 0.7);
}
},
// Función para configurar las coordenadas de los nodos
setupNodeCoordinates(chart) {
if (chart.config.type !== 'scatter') return;
const dataset = chart.data.datasets[0];
const links = dataset.data;
if (!links || !Array.isArray(links)) return;
function buildGraph(links) {
const nodes = {};
links.forEach(link => {
if (!nodes[link.from]) nodes[link.from] = { label: link.from, children: [], parents: [] };
if (!nodes[link.to]) nodes[link.to] = { label: link.to, children: [], parents: [] };
nodes[link.from].children.push(nodes[link.to]);
nodes[link.to].parents.push(nodes[link.from]);
});
return nodes;
}
// Reemplazar la función layoutGraphic existente con esta versión mejorada
function layoutGraphic(nodes, dataset = {}) {
// 1. Primero establecer las profundidades (depth) como antes
Object.values(nodes).forEach(n => {
n.depth = null;
n.y = null;
n.subtreeSize = 0;
n.children = n.children || [];
n.parents = n.parents || [];
});
const roots = Object.values(nodes).filter(n => n.parents.length === 0);
roots.forEach(root => root.depth = 0);
let changed;
do {
changed = false;
Object.values(nodes).forEach(node => {
if (node.parents.length === 0) return;
const maxParentDepth = Math.max(-1, ...node.parents.map(p => p.depth ?? -1));
const newDepth = maxParentDepth + 1;
if (node.depth === null || node.depth < newDepth) {
node.depth = newDepth;
changed = true;
}
});
} while (changed);
// Aplicar configuración manual de columnas si existe
const columnMap = dataset.column || {};
Object.entries(nodes).forEach(([label, node]) => {
if (columnMap[label] !== undefined) {
node.depth = columnMap[label];
}
});
// 2. Calcular el tamaño del subárbol para cada nodo (bottom-up)
calculateSubtreeSizes(nodes);
// 3. Asignar posiciones Y basadas en las ramificaciones (top-down)
assignHierarchicalPositions(nodes, dataset);
// 4. Aplicar ajustes finales y normalización
normalizePositions(nodes);
const maxDepth = Math.max(...Object.values(nodes).map(n => n.depth));
return { maxDepth, maxY: 1 };
}
// Calcular el tamaño del subárbol de cada nodo (cuántos descendientes tiene)
function calculateSubtreeSizes(nodes) {
const visited = new Set();
function calculateSize(node) {
if (visited.has(node.label)) return node.subtreeSize;
visited.add(node.label);
if (node.children.length === 0) {
node.subtreeSize = 1;
return 1;
}
let totalSize = 1; // El nodo mismo
node.children.forEach(child => {
totalSize += calculateSize(child);
});
node.subtreeSize = totalSize;
return totalSize;
}
Object.values(nodes).forEach(node => {
if (!visited.has(node.label)) {
calculateSize(node);
}
});
}
// Asignar posiciones Y basadas en la estructura jerárquica
function assignHierarchicalPositions(nodes, dataset) {
const priorityMap = dataset.priority || {};
// Organizar nodos por profundidad
const nodesByDepth = new Map();
Object.values(nodes).forEach(node => {
if (!nodesByDepth.has(node.depth)) {
nodesByDepth.set(node.depth, []);
}
nodesByDepth.get(node.depth).push(node);
});
// Procesar cada nivel de profundidad
for (let depth = 0; depth <= Math.max(...nodesByDepth.keys()); depth++) {
const nodesAtDepth = nodesByDepth.get(depth) || [];
if (depth === 0) {
// Nodos raíz: distribuir uniformemente
assignRootPositions(nodesAtDepth, priorityMap);
} else {
// Nodos no raíz: posicionar basado en sus padres
assignChildPositions(nodesAtDepth, priorityMap);
}
}
}
// Asignar posiciones a los nodos raíz
function assignRootPositions(rootNodes, priorityMap) {
// Ordenar por prioridad y por tamaño de subárbol
rootNodes.sort((a, b) => {
const aPriority = priorityMap[a.label] ?? Number.MAX_SAFE_INTEGER;
const bPriority = priorityMap[b.label] ?? Number.MAX_SAFE_INTEGER;
if (aPriority !== bPriority) return aPriority - bPriority;
// Si tienen la misma prioridad, ordenar por tamaño de subárbol (más grande primero)
if (a.subtreeSize !== b.subtreeSize) return b.subtreeSize - a.subtreeSize;
return a.label.localeCompare(b.label);
});
// Asignar posiciones espaciadas uniformemente
const spacing = 1 / (rootNodes.length + 1);
rootNodes.forEach((node, index) => {
node.y = spacing * (index + 1);
node.assignedSpace = calculateNodeSpace(node, rootNodes.length);
});
}
// Asignar posiciones a nodos hijos basándose en sus padres
function assignChildPositions(childNodes, priorityMap) {
// Agrupar nodos por sus padres
const nodeGroups = groupNodesByParents(childNodes);
nodeGroups.forEach(group => {
assignGroupPositions(group, priorityMap);
});
}
// Agrupar nodos por sus combinaciones de padres
function groupNodesByParents(nodes) {
const groups = new Map();
nodes.forEach(node => {
// Crear una clave única basada en los padres
const parentKey = node.parents
.map(p => p.label)
.sort()
.join('|');
if (!groups.has(parentKey)) {
groups.set(parentKey, {
parents: node.parents,
children: []
});
}
groups.get(parentKey).children.push(node);
});
return Array.from(groups.values());
}
// Asignar posiciones a un grupo de nodos que comparten padres
function assignGroupPositions(group, priorityMap) {
const { parents, children } = group;
// Ordenar hijos por prioridad y tamaño de subárbol
children.sort((a, b) => {
const aPriority = priorityMap[a.label] ?? Number.MAX_SAFE_INTEGER;
const bPriority = priorityMap[b.label] ?? Number.MAX_SAFE_INTEGER;
if (aPriority !== bPriority) return aPriority - bPriority;
if (a.subtreeSize !== b.subtreeSize) return b.subtreeSize - a.subtreeSize;
return a.label.localeCompare(b.label);
});
// Calcular la posición central basada en los padres
const centerY = calculateParentCenter(parents);
// Calcular el espacio disponible para este grupo
const availableSpace = calculateAvailableSpace(parents, children);
// Distribuir los hijos alrededor del centro
distributeChildrenAroundCenter(children, centerY, availableSpace);
}
// Calcular la posición Y central de los padres
function calculateParentCenter(parents) {
if (parents.length === 0) return 0.5;
const parentPositions = parents.map(p => p.y).filter(y => y !== null);
if (parentPositions.length === 0) return 0.5;
// Usar el promedio ponderado por el tamaño del subárbol
let totalWeight = 0;
let weightedSum = 0;
parents.forEach(parent => {
if (parent.y !== null) {
const weight = parent.subtreeSize || 1;
weightedSum += parent.y * weight;
totalWeight += weight;
}
});
return totalWeight > 0 ? weightedSum / totalWeight : 0.5;
}
// Calcular el espacio disponible para un grupo de hijos
function calculateAvailableSpace(parents, children) {
// Calcular el espacio basado en el tamaño total de los subárboles de los padres
const totalParentSubtreeSize = parents.reduce((sum, parent) => sum + (parent.subtreeSize || 1), 0);
const totalChildrenSize = children.reduce((sum, child) => sum + (child.subtreeSize || 1), 0);
// El espacio disponible es proporcional al tamaño del subárbol
const baseSpace = Math.min(0.8, totalChildrenSize / totalParentSubtreeSize * 0.6);
return Math.max(0.1, baseSpace); // Mínimo 10% del espacio total
}
// Distribuir hijos alrededor del centro
function distributeChildrenAroundCenter(children, centerY, availableSpace) {
if (children.length === 0) return;
if (children.length === 1) {
children[0].y = centerY;
children[0].assignedSpace = availableSpace;
return;
}
// Calcular posiciones basadas en el tamaño de los subárboles
const totalSubtreeSize = children.reduce((sum, child) => sum + (child.subtreeSize || 1), 0);
let currentOffset = -availableSpace / 2;
children.forEach(child => {
const childProportion = (child.subtreeSize || 1) / totalSubtreeSize;
const childSpace = availableSpace * childProportion;
child.y = centerY + currentOffset + childSpace / 2;
child.assignedSpace = childSpace;
currentOffset += childSpace;
});
}
// Calcular el espacio que debe ocupar un nodo
function calculateNodeSpace(node, totalNodesAtLevel) {
const baseSpace = 1 / totalNodesAtLevel;
const subtreeFactor = Math.log(node.subtreeSize || 1) + 1;
return baseSpace * subtreeFactor;
}
// Normalizar todas las posiciones Y para que estén entre 0 y 1
function normalizePositions(nodes) {
const allNodes = Object.values(nodes);
const validYs = allNodes.map(n => n.y).filter(y => y !== null);
if (validYs.length === 0) return;
const minY = Math.min(...validYs);
const maxY = Math.max(...validYs);
const range = maxY - minY;
if (range === 0) {
// Todos los nodos tienen la misma Y, centrarlos
allNodes.forEach(node => {
if (node.y !== null) node.y = 0.5;
});
} else {
// Normalizar al rango [0.1, 0.9] para dejar márgenes
allNodes.forEach(node => {
if (node.y !== null) {
node.y = 0.1 + 0.8 * ((node.y - minY) / range);
}
});
}
// Ajustar posiciones para evitar solapamientos
resolveOverlaps(nodes);
}
// Resolver solapamientos entre nodos en la misma profundidad
function resolveOverlaps(nodes) {
const nodesByDepth = new Map();
Object.values(nodes).forEach(node => {
if (!nodesByDepth.has(node.depth)) {
nodesByDepth.set(node.depth, []);
}
nodesByDepth.get(node.depth).push(node);
});
nodesByDepth.forEach(nodesAtDepth => {
if (nodesAtDepth.length <= 1) return;
// Ordenar por posición Y
nodesAtDepth.sort((a, b) => a.y - b.y);
const minSeparation = 0.08; // Separación mínima entre nodos
// Ajustar posiciones para evitar solapamientos
for (let i = 1; i < nodesAtDepth.length; i++) {
const prevNode = nodesAtDepth[i - 1];
const currentNode = nodesAtDepth[i];
const minY = prevNode.y + minSeparation;
if (currentNode.y < minY) {
currentNode.y = minY;
}
}
// Renormalizar si es necesario
const lastNode = nodesAtDepth[nodesAtDepth.length - 1];
if (lastNode.y > 0.9) {
const excess = lastNode.y - 0.9;
nodesAtDepth.forEach(node => {
node.y -= excess;
});
}
});
}
const nodes = buildGraph(links);
const { maxDepth } = layoutGraphic(nodes);
const allNodes = Object.values(nodes);
// Calcular valores de los nodos
allNodes.forEach(node => {
const incoming = links.filter(l => l.to === node.label);
const outgoing = links.filter(l => l.from === node.label);
if (incoming.length > 0) {
node.value = incoming.reduce((acc, l) => acc + l.value, 0);
} else if (outgoing.length > 0) {
node.value = outgoing[0].value;
} else {
node.value = 0;
}
});
// Función para convertir a coordenadas del canvas
function toCanvasCoords(depth, y) {
const area = chart.chartArea;
const paddingX = 50, paddingY = 40;
const width = area.right - area.left - 2 * paddingX;
const height = area.bottom - area.top - 2 * paddingY;
const minY = Math.min(...allNodes.map(n => n.y));
const maxY = Math.max(...allNodes.map(n => n.y));
const yNorm = (y - minY) / (maxY - minY || 1);
const isVertical = dataset.vertical === true;
if (isVertical) {
// En orientación vertical: intercambiar X e Y
return {
x: area.left + paddingX + yNorm * width,
y: area.top + paddingY + (depth / (maxDepth || 1)) * height
};
} else {
// Orientación horizontal original
return {
x: area.left + paddingX + (depth / (maxDepth || 1)) * width,
y: area.top + paddingY + yNorm * height
};
}
}
// Guardar datos en el chart para uso posterior
chart._treeGraphData = {
nodes,
allNodes,
maxDepth,
links,
toCanvasCoords
};
return chart._treeGraphData;
},
// Configurar el event listener del mouse
setupMouseListener(chart) {
if (chart._tooltipHandlerAttached) return;
const canvas = chart.canvas;
chart._hoveredNode = null;
canvas.addEventListener("mousemove", (e) => {
if (!chart._treeGraphData) return;
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
chart._hoveredNode = null;
for (const node of chart._treeGraphData.allNodes) {
const { x, y } = chart._treeGraphData.toCanvasCoords(node.depth, node.y);
const dx = mouseX - x;
const dy = mouseY - y;
if (Math.sqrt(dx * dx + dy * dy) <= 10) {
chart._hoveredNode = node;
break;
}
}
chart.draw();
});
chart._tooltipHandlerAttached = true;
},
// Calcular posición inteligente del tooltip
calculateTooltipPosition(chart, nodeX, nodeY, tooltipWidth, tooltipHeight) {
const area = chart.chartArea;
const padding = 10; // Separación del nodo
const margin = 8; // Margen del borde del canvas
// Posición por defecto: arriba del nodo
let x = nodeX - tooltipWidth / 2;
let y = nodeY - tooltipHeight - padding;
let position = 'top';
// Verificar si se sale por arriba
if (y < area.top + margin) {
// Mover abajo del nodo
y = nodeY + padding;
position = 'bottom';
}
// Verificar si se sale por la izquierda
if (x < area.left + margin) {
if (position === 'top' || position === 'bottom') {
// Mover a la derecha del nodo
x = nodeX + padding;
y = nodeY - tooltipHeight / 2;
position = 'right';
} else {
x = area.left + margin;
}
}
// Verificar si se sale por la derecha
if (x + tooltipWidth > area.right - margin) {
if (position === 'top' || position === 'bottom') {
// Mover a la izquierda del nodo
x = nodeX - tooltipWidth - padding;
y = nodeY - tooltipHeight / 2;
position = 'left';
} else {
x = area.right - tooltipWidth - margin;
}
}
// Verificar si se sale por abajo (cuando está en posición bottom)
if (y + tooltipHeight > area.bottom - margin) {
if (position === 'bottom') {
// Volver arriba del nodo si hay espacio
const topY = nodeY - tooltipHeight - padding;
if (topY >= area.top + margin) {
y = topY;
position = 'top';
} else {
y = area.bottom - tooltipHeight - margin;
}
} else if (position === 'left' || position === 'right') {
y = Math.min(y, area.bottom - tooltipHeight - margin);
}
}
// Verificar límites verticales para posiciones left/right
if (position === 'left' || position === 'right') {
if (y < area.top + margin) {
y = area.top + margin;
} else if (y + tooltipHeight > area.bottom - margin) {
y = area.bottom - tooltipHeight - margin;
}
}
return { x, y, position };
},
// Dibujar tooltip con estilo ChartJS
drawTooltip(chart, node) {
const ctx = chart.ctx;
const { x: nodeX, y: nodeY } = chart._treeGraphData.toCanvasCoords(node.depth, node.y);
// Configuración del tooltip
const fontSize = 13;
const fontFamily = 'Helvetica, Arial, sans-serif';
const padding = 8;
const borderRadius = 6;
const shadowBlur = 4;
const shadowOffset = 2;
// Preparar texto
const title = node.label;
const datasetLabel = chart.data.datasets[0].label || 'Value';
const value = `${datasetLabel}: ${node.value}`;
const lines = [title, value];
ctx.save();
ctx.font = `${fontSize}px ${fontFamily}`;
// Calcular dimensiones del tooltip
const lineHeight = fontSize + 4;
const maxWidth = Math.max(...lines.map(line => ctx.measureText(line).width));
const tooltipWidth = maxWidth + (padding * 2);
const tooltipHeight = (lines.length * lineHeight) + (padding * 2) - 4; // -4 para ajustar espaciado
// Calcular posición inteligente
const { x: tooltipX, y: tooltipY, position } = this.calculateTooltipPosition(
chart, nodeX, nodeY, tooltipWidth, tooltipHeight
);
// Dibujar sombra
ctx.shadowColor = 'rgba(0, 0, 0, 0.2)';
ctx.shadowBlur = shadowBlur;
ctx.shadowOffsetX = shadowOffset;
ctx.shadowOffsetY = shadowOffset;
// Dibujar fondo del tooltip
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
this.drawRoundedRect(ctx, tooltipX, tooltipY, tooltipWidth, tooltipHeight, borderRadius);
ctx.fill();
// Resetear sombra
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
// Dibujar borde
ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
ctx.lineWidth = 1;
this.drawRoundedRect(ctx, tooltipX, tooltipY, tooltipWidth, tooltipHeight, borderRadius);
ctx.stroke();
// Dibujar flecha indicadora (opcional, basada en la posición)
this.drawTooltipArrow(ctx, nodeX, nodeY, tooltipX, tooltipY, tooltipWidth, tooltipHeight, position);
// Dibujar texto
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
lines.forEach((line, index) => {
const textY = tooltipY + padding + (index * lineHeight);
const textX = tooltipX + padding;
if (index === 0) {
// Título en negrita
ctx.font = `bold ${fontSize}px ${fontFamily}`;
ctx.fillStyle = '#ffffff';
} else {
// Contenido regular
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.fillStyle = '#cccccc';
}
ctx.fillText(line, textX, textY);
});
ctx.restore();
},
// Dibujar rectángulo con esquinas redondeadas
drawRoundedRect(ctx, x, y, width, height, radius) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
},
// Dibujar flecha del tooltip
drawTooltipArrow(ctx, nodeX, nodeY, tooltipX, tooltipY, tooltipWidth, tooltipHeight, position) {
const arrowSize = 6;
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.beginPath();
switch (position) {
case 'top':
// Flecha apuntando hacia abajo
const centerX = tooltipX + tooltipWidth / 2;
ctx.moveTo(centerX - arrowSize, tooltipY + tooltipHeight);
ctx.lineTo(centerX + arrowSize, tooltipY + tooltipHeight);
ctx.lineTo(centerX, tooltipY + tooltipHeight + arrowSize);
break;
case 'bottom':
// Flecha apuntando hacia arriba
const centerXBottom = tooltipX + tooltipWidth / 2;
ctx.moveTo(centerXBottom - arrowSize, tooltipY);
ctx.lineTo(centerXBottom + arrowSize, tooltipY);
ctx.lineTo(centerXBottom, tooltipY - arrowSize);
break;
case 'left':
// Flecha apuntando hacia la derecha
const centerYLeft = tooltipY + tooltipHeight / 2;
ctx.moveTo(tooltipX + tooltipWidth, centerYLeft - arrowSize);
ctx.lineTo(tooltipX + tooltipWidth, centerYLeft + arrowSize);
ctx.lineTo(tooltipX + tooltipWidth + arrowSize, centerYLeft);
break;
case 'right':
// Flecha apuntando hacia la izquierda
const centerYRight = tooltipY + tooltipHeight / 2;
ctx.moveTo(tooltipX, centerYRight - arrowSize);
ctx.lineTo(tooltipX, centerYRight + arrowSize);
ctx.lineTo(tooltipX - arrowSize, centerYRight);
break;
}
ctx.closePath();
ctx.fill();
},
afterDraw(chart) {
const data = this.setupNodeCoordinates(chart);
if (!data) return;
this.setupMouseListener(chart);
this.drawGraph(chart, data);
},
afterResize(chart) {
// Recalcular coordenadas después del resize
this.setupNodeCoordinates(chart);
},
drawGraph(chart, { allNodes, links, toCanvasCoords }) {
const ctx = chart.ctx;
const dataset = chart.data.datasets[0];
const isVertical = dataset.vertical === true;
// Dibujar conexiones
allNodes.forEach(node => {
node.children.forEach(child => {
const from = toCanvasCoords(node.depth, node.y);
const to = toCanvasCoords(child.depth, child.y);
ctx.save();
ctx.strokeStyle = "#888";
ctx.lineWidth = 2;
ctx.beginPath();
if (isVertical) {
// En orientación vertical, las curvas van de arriba hacia abajo
const dy = (to.y - from.y) * 0.5;
ctx.moveTo(from.x, from.y);
ctx.bezierCurveTo(from.x, from.y + dy, to.x, to.y - dy, to.x, to.y);
} else {
// Orientación horizontal original
const dx = (to.x - from.x) * 0.5;
ctx.moveTo(from.x, from.y);
ctx.bezierCurveTo(from.x + dx, from.y, to.x - dx, to.y, to.x, to.y);
}
ctx.stroke();
ctx.restore();
});
});
// Dibujar nodos
allNodes.forEach(node => {
const { x, y } = toCanvasCoords(node.depth, node.y);
ctx.save();
const nodeColors = dataset.nodeColors || {};
const fillColor = nodeColors[node.label] || "black";
const nodeRadius = dataset.nodeRadius || 10;
const borderRaw = dataset.nodeBorder || "0px";
const borderColor = dataset.nodeBorderColor || "black";
let borderWidth = 0;
if (typeof borderRaw === "string" && borderRaw.endsWith("px")) {
borderWidth = parseInt(borderRaw.replace("px", ""));
}
if (borderWidth > 0) {
ctx.beginPath();
ctx.arc(x, y, nodeRadius + borderWidth / 2, 0, 2 * Math.PI);
ctx.fillStyle = borderColor;
ctx.fill();
}
ctx.beginPath();
ctx.arc(x, y, nodeRadius, 0, 2 * Math.PI);
ctx.fillStyle = fillColor;
ctx.fill();
const fontSize = dataset.labelFontSize || 12;
const fontWeight = dataset.labelFontWeight || "bold";
const fontFamily = dataset.labelFontFamily || "sans-serif";
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
ctx.fillStyle = "#222";
ctx.textAlign = "center";
ctx.textBaseline = "top";
// Calcular ancho máximo y dividir el texto
const maxLabelWidth = this.calculateMaxLabelWidth(chart);
const showValue = dataset.showValue ?? false;
const textLines = this.wrapText(ctx, node.label, maxLabelWidth);
if (showValue) {
textLines.push(`(${node.value})`);
}
// Dibujar cada línea de texto
const lineHeight = fontSize + 2;
let startY, startX;
if (isVertical) {
// En orientación vertical, posicionar el texto a la derecha del nodo
startX = x + nodeRadius + 8;
startY = y - (textLines.length * lineHeight) / 2;
ctx.textAlign = "left";
ctx.textBaseline = "top";
} else {
// Orientación horizontal original - texto debajo del nodo
startX = x;
startY = y + nodeRadius + 4;
ctx.textAlign = "center";
ctx.textBaseline = "top";
}
textLines.forEach((line, index) => {
const lineY = startY + (index * lineHeight);
ctx.fillText(line, startX, lineY);
});
ctx.restore();
});
// Dibujar tooltip mejorado si hay un nodo hovereado
const hovered = chart._hoveredNode;
if (hovered) {
this.drawTooltip(chart, hovered);
}
}
};
if (typeof window !== 'undefined' && window.Chart) {
window.Chart.register(treeGraphPlugin);
}
export default treeGraphPlugin;