// d3OrgChartMEN V1.1 - 2024.10.15 class OrgChart { constructor(config = {}) { // Configuración por defecto this.defaultConfig = { graphType:"", containerId:"tree-container-dk", width: 2000, //Ancho inicial de la gráfica luego se modifica a 100% height: 1500, //Alto inicial de la gráfica luego se modifica a auto graphPaddingX: 20, graphPaddingY: 20, nodeWidthSpacing: 160, //espaciado horizontal entre nodos (si un nodo supera este ancho hay probabilidad de superposicion) nodeHeightSpacing: 140, //espaciado vertical entre nodos siblingHSeparation: 1.5, // factor de separación nodos hermanos con padres en disposición horizontal branchHSeparation: 2, // factor de separación ramas hermanas con padres en disposición horizontal siblingVSeparation: 1, // factor de separación nodos hermanos en disposición vertical branchVSeparation: 0.1, //factor de separación ramas hermanas en disposición vertical nodesGroupMarginX:0, //margen izquierdo entre padres e hijos - gráfica vertical nodesGroupMarginY:10, // ubicación Y de nodos en grafica vertical - espacio para escudo. nodeTextLeftPadding: 20, nodeTextRightPadding: 10, nodeTextTopPadding: 10, nodeTextOffice: 12, //font-size nodeTextOfficeColor: "#6A6A6A", nodeTextOfficeW: 400, //weight nodeTextOfficeLH: 1.17, //line-height nodeTextOfficerName: 16, //font-size nodeTextOfficerColor: "#2B2B2B", nodeTextOfficerW: 600, //weight nodeTextOfficerLH: 1.17, //line-height nodeTextsSep: 10, nodesVerticalVSpacing: 8, nodesVerticalMarginX: 20, nodeTextBottomPadding: 5, staffHSpacing: 80, staffVSpacing: 10, staffGroupVSpacing:30, //espacio vertical adicional entre los nodos staff y no staff. nodeBorder: 1, nodeBorderRadius: 10, nodeBorderColor: "#565656", nodeBgColor: "#ffffff", nodeBtnFont: 18, nodeBtnFontColor: "#ffffff", nodeBtnBgColor: "#A0A3BA", nodeBtnRadius: 2, nodeBtnSize: 24, nodeShortStickWidth: 2, nodeShortStickHeight: 0.1, //porcentaje del alto del nodo nodeShortStickRadius: 0, nodeStickWidth: 7, nodeStickHeight: 0.7, //porcentaje del alto del nodo nodeStickRadius: 3.5, branchStaff: "Asesoras", branchStaffId: "69596", branchDefault: "Default", legendDefault: "Default", verticalDirection: "vertical", horizontalDirection: "horizontal", colDirection: "columnas", collapsedIcon: "arrow", // plus or arrow collapsedButtonSize: 15, collapsedButtonRadio: 7, linksHorizontalMarginY: 15, fontFamily: "'Work Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif", processLegend: { "69600": {name:"Procesos Estrátegicos", class:"color-estrategicos", color:"#7e3f98"}, "69601": {name:"Procesos Misionales", class:"color-misionales", color:"#0073b7"}, "69602": {name:"Procesos de Apoyo", class:"color-apoyo",color:"#28a745"}, "69603": {name:"Procesos de Evaluación", class:"color-evaluacion", color:"#f39c12"}, "Default": {name:"", class:"color-default", color:"#154a8a"} }, branchConfig: { "69595": { name: "Despacho", width: 260, height: 90, nodeBorder: "2", stick: "none", orderText: true}, "69596": { name: "Asesoras", width: 240, height: 90, direction: "columnas" }, "69597": { name: "Viceministerio", width: 220, height: 90}, "69598": { name: "Dirección", width: 200, height: 110,collapsed:false}, "69599": { name: "Subdirección", width: 190, height: 110, direction: "vertical", button: false, stick: "short", orderText: true, role:"button"}, "Default": { name: "Default", width: 220, height: 90} }, customCollapsed:"", headerConfig: { width: 250, height: 180, marginX: 650, marginY: 50, title: "Organigrama Ministerio de Educación Nacional", titleFontSize: 20, image: "logoMen.png", imageAlt:"Logo del Ministerio de Educación Nacional", imageWidth: 200, imageHeight: 160, dateUpdate: latestUpdateDate, dateUpdateLabel: 'Fecha de actualización:', details: ` Decreto 2269`, detailsLabel: "Decreto 2269 del 29 de diciembre de 2023", titleFontWeight: 600, titleTextAlign:"center", }, legendConfig: { marginX: 420, marginY: 80, width: 250, height: 160, fsRadius: 5, fsBorderColor: "gray", fsStrokeWidth: 1.5, fsStrokeDasharray: "3,3", fsPadding:"15px 15px 15px 15px", lcPadding:"10px 5px 5px 10px", title: "Convenciones", titleFontSize: 16, titleFontWeight: "500", titleFillColor: "#000", titleBackgroundColor: "#f5f5f5", titleTextPadding: "6px 0px 6px 10px", titleTextMargin: "0px 80px 12px 0px", liWidth: 15, liHeight: 15, liDisplay: "flex", liAlignItems: "center", liMargin: "8px 10px 8px 10px", liTextMargin: "0px 0px 10px 0px", liTextPadding: "5px 2.5px 0px 0px", liTextFontSize: 14 } }; this.config = { ...this.defaultConfig, // Valores predeterminados completos ...config, // Valores proporcionados por el usuario (sobrescriben a los predeterminados) branchConfig: { ...this.defaultConfig.branchConfig, ...config.branchConfig }, headerConfig: { ...this.defaultConfig.headerConfig, ...config.headerConfig }, legendConfig: { ...this.defaultConfig.legendConfig, ...config.legendConfig }, customCollapsed: [ ...new Set([ ...(this.defaultConfig.customCollapsed || []), // Colapsados por defecto ...(config.customCollapsed || []) // Colapsados proporcionados por el usuario ]) ] }; this.container = d3.select(`#${this.config.containerId}`); // Inicializa el SVG this.svg = this.container.append("svg") .attr("width", this.config.width) .attr("height", this.config.height) .attr("aria-label", this.config.headerConfig.title) .attr("preserveAspectRatio", "xMidYMid meet") .attr("viewBox", `0 0 ${this.config.width} ${this.config.height}`) .append("g") .attr("class", 'main-group') .style('visibility', 'visible') .style("overflow", "visible"); // Variables internas this.root = null; this.maxHeightByBranch = {}; } createHierarchy(dataSource) { // Reordenar los hijos de los nodos principales con "Asesoras" al principio dataSource.forEach(node => { // Si el nodo no tiene un pid, es un nodo principal if (!node.pid) { const children = dataSource.filter(d => d.pid === node.id); // Separar los hijos con branch "Asesoras" del resto const asesoras = children.filter(d => d.branch === "Asesoras"); const otherChildren = children.filter(d => d.branch !== "Asesoras"); // Reasignar los hijos en la estructura original // Eliminamos los hijos anteriores del dataSource dataSource = dataSource.filter(d => d.pid !== node.id); // Añadimos los hijos en el nuevo orden (Asesoras primero) dataSource.push(...asesoras, ...otherChildren); } }); // Aplicar el stratify para generar la jerarquía const stratify = d3.stratify() .id(d => d.id) .parentId(d => d.pid); this.root = stratify(dataSource); } // Función para colapsar nodos collapse(d, updateGraph = true) { if (d.children) { d._children = d.children; // Guarda los hijos en _children d.children = null; // Elimina los hijos visibles para colapsar el nodo } if (updateGraph) { this.update(); } } // Configura el layout del árbol y la relación de sibblings configureTreeLayout() { const treeLayout = d3.tree() .size([this.config.width, this.config.height]) .nodeSize([this.config.nodeWidthSpacing, this.config.nodeHeightSpacing]) .separation((a, b) => { const aParent = a.parent || {}; const bParent = b.parent || {}; const aParentIsHorizontal = aParent.children && aParent.children.some(child => { const { direction: childDirection = this.config.horizontalDirection } = this.config.branchConfig[child.data.branchId] || {}; return childDirection === this.config.horizontalDirection; }); const bParentIsHorizontal = bParent.children && bParent.children.some(child => { const { direction: childDirection = this.config.horizontalDirection } = this.config.branchConfig[child.data.branchId] || {}; return childDirection === this.config.horizontalDirection; }); const aHasVerticalChildren = a.children && a.children.some(child => { const { direction: childDirection = this.config.horizontalDirection } = this.config.branchConfig[child.data.branchId] || {}; return childDirection === this.config.verticalDirection; }); const bHasVerticalChildren = b.children && b.children.some(child => { const { direction: childDirection = this.config.horizontalDirection } = this.config.branchConfig[child.data.branchId] || {}; return childDirection === this.config.verticalDirection; }); if (aParentIsHorizontal || bParentIsHorizontal) { return aParent === bParent ? this.config.siblingHSeparation : this.config.branchHSeparation; } return aHasVerticalChildren || bHasVerticalChildren ? this.config.siblingVSeparation : this.config.branchVSeparation; }); treeLayout(this.root); } // Renderiza jerarquicamente los nodos renderNodes() { const nodesGroup = this.svg.append('g') .attr('class', 'nodes-group') .style("overflow", "visible") this.root.eachBefore(node => { // Renderiza cada nodo antes que sus hijos nodesGroup.append('g') .attr('class', 'node') .attr('id', `node-${node.id}`) .datum(node) // Asocia el dato a este nodo .style('overflow', 'visible'); }); const node = nodesGroup.selectAll('g.node') this.renderForeignObject(node); this.adjustNodeHeight(node); this.positionStaff(node); this.positionNodesVertically(node); this.renderSticks(node); this.renderButtons(node); this.translateNodes(node); this.applyCollapseEvent(node); this.handleNodeAccessibility(node); } handleNodeAccessibility(node) { node .attr('role', d => { const nodeConfig = this.config.branchConfig[d.data.branchId] || this.config.branchConfig[this.config.branchDefault]; return nodeConfig.role || 'group'; // Si no tiene role, asigna "group" }) .attr('tabindex', d => { const nodeConfig = this.config.branchConfig[d.data.branchId] || this.config.branchConfig[this.config.branchDefault]; return nodeConfig.role === 'button' ? 0 : -1; }) .attr('aria-label', d => `${d.data.office}, ${d.data.firstName} ${d.data.lastName}`) .on('keydown', (event, d) => { // Agrega una acción al presionar Enter o Espacio if (event.key === 'Enter' || event.key === ' ') { event.stopPropagation(); showModal(d.data); // Asume que showModal es una función definida en tu clase } }); } // Métodos para renderizar elementos html de los nodos renderForeignObject(node) { // Agregar foreignObject dentro de los nodos const nodeforeignObject = node.append("foreignObject") .attr("width", d => { const size = this.config.branchConfig[d.data.branchId] || this.config.branchConfig[this.config.branchDefault]; return `${size.width}px`; }) .attr("height", d => { const size = this.config.branchConfig[d.data.branchId] || this.config.branchConfig[this.config.branchDefault]; return `${size.height}px`; }) .attr("x", 0) .attr("y", 0) .attr("id", d => `foreignObject-${d.id}`) .style("overflow", "visible"); // Dibuja el contenedor del nodo dentro del foreignObject nodeforeignObject.append("xhtml:div") .attr("class", 'node-container') .style("font-weight", 400) .style("font-style", "normal") .style("text-anchor", "start") .style("color", "#070707") .style("margin", "0px") .style("line-height", 1) .style("background-color", this.config.nodeBgColor) .style("border", d => { const branchData = this.config.branchConfig[d.data.branchId] || this.config.branchConfig[this.config.branchDefault]; const customBorder = branchData.nodeBorder || "default"; const border = customBorder !== "default" ? customBorder : this.config.nodeBorder; const customButton = branchData.button !== false; return `${customButton ? `${border}px solid ${this.config.nodeBorderColor}` : ''}`; }) .style("border-radius", `0px ${this.config.nodeBorderRadius}px ${this.config.nodeBorderRadius}px ${this.config.nodeBorderRadius}px`) .style("font-family", this.config.fontFamily) .style("font-size", "12px") .style("padding", "0px") .style("box-sizing", "border-box") .style("display", "flex") .style("align-items", "center") .style("overflow", "visible") .attr("height","auto") .html(d => { const branchData = this.config.branchConfig[d.data.branchId] || this.config.branchConfig[this.config.branchDefault]; const orderText = branchData.orderText !== true; const customButton = branchData.button !== false; let textNameRPadding = this.config.nodeBtnSize; // si hay botón, el padding derecho es el ancho del botón. let textAttr = ""; let textNameClass = "full-name"; let textOfficeClass="office"; let cursorPointer=""; if (customButton === false ){ textNameRPadding = this.config.nodeTextRightPadding; textAttr = `data-node-id="${d.data.id}"`; textNameClass= "full-name-noBtn"; textOfficeClass="full-name-noBtn"; cursorPointer= "cursor:pointer;" } const textOfficeStyles = `display:block; font-weight: ${this.config.nodeTextOfficeW};font-family: ${this.config.fontFamily}; font-size: ${this.config.nodeTextOffice}px;color:${this.config.nodeTextOfficeColor};line-height:${this.config.nodeTextOfficeLH}; ${cursorPointer}`; const textNameStyles = `display:block; font-family: ${this.config.fontFamily}; font-weight: ${this.config.nodeTextOfficerW};font-size: ${this.config.nodeTextOfficerName}px;color:${this.config.nodeTextOfficerColor}; line-height:${this.config.nodeTextOfficerLH}; ${cursorPointer}`; return ` `; }); } adjustNodeHeight(node) { // Objeto para almacenar la altura máxima por branch this.maxHeightByBranch = {}; const self = this; // Ajusta la altura de cada nodo y determina la altura máxima por branch node.each(function(d) { const nodeContainer= d3.select(this).select(`#${self.config.containerId} .node-container`); const divHeight = nodeContainer.node().offsetHeight; const nodeSize = self.config.branchConfig[d.data.branchId] || self.config.branchConfig[self.config.branchDefault]; const nodeDefaultHeight = nodeSize.height; const nodeDefaultWidth = nodeSize.width; // Asegura que como mínimo se tenga la altura predeterminada para el tipo de nodo const adjustedHeight = Math.max(divHeight, nodeDefaultHeight); d.height = adjustedHeight; d.width=nodeDefaultWidth; // Guarda la altura máxima por cada nivel (branch) if (!self.maxHeightByBranch[d.data.branchId] || adjustedHeight > self.maxHeightByBranch[d.data.branchId]) { self.maxHeightByBranch[d.data.branchId] = adjustedHeight; } }); // Aplica la altura máxima calculada a todos los nodos de cada branch node.each(function(d) { d.height = self.maxHeightByBranch[d.data.branchId]; const nodeContainer= d3.select(this).select(`#${self.config.containerId} .node-container`); nodeContainer.style("height", `${self.maxHeightByBranch[d.data.branchId]}px`); nodeContainer.attr("height", `${self.maxHeightByBranch[d.data.branchId]}px`); const foreignObject = d3.select(this).select("foreignObject"); foreignObject.attr("height", `${self.maxHeightByBranch[d.data.branchId]}px`); }); } positionStaff(node) { // Ajusta posiciones x y y de los nodos de Staff en dos columnas let staffIndex = 0; node.each(d => { const nodeConfig = this.config.branchConfig[d.data.branchId] || this.config.branchConfig[this.config.branchDefault]; const nodeWidth = nodeConfig.width; const nodeDirection = nodeConfig.direction; const maxNodeHeightByBranch = this.maxHeightByBranch[this.config.branchStaffId]; const parentWidth = (d.parent) ? d.parent.width : 0; if (d.data.branchId === this.config.branchStaffId && nodeDirection === this.config.colDirection) { const y = maxNodeHeightByBranch + this.config.staffVSpacing + Math.floor(staffIndex / 2) * (this.config.staffVSpacing + maxNodeHeightByBranch); const x = -parentWidth/2 - this.config.staffHSpacing/2 + Math.floor(staffIndex % 2) * (nodeWidth+ this.config.staffHSpacing); d.x = x; d.y = y; const sidePosition = (staffIndex % 2 === 0) ? "left" : "right"; d.sidePosition = sidePosition; staffIndex++; } }); // Ajuste de las posiciones de los nodos que no son de Staff con respecto a los nodos de Staff this.root.eachAfter(d => { const nodeConfig = this.config.branchConfig[d.data.branchId] || this.config.branchConfig[this.config.branchDefault]; const nodeWidth = nodeConfig.width; if (d.children) { const staffChildren = d.children.filter(child => child.data.branchId === this.config.branchStaffId); const nonStaffChildren = d.children.filter(child => child.data.branchId !== this.config.branchStaffId); if (staffChildren.length > 0) { const staffX= staffChildren.length * nodeWidth const maxStaffY = d3.max(staffChildren, child => child.y + child.height); nonStaffChildren.forEach(child => { const offsetY = maxStaffY - child.y + this.config.staffVSpacing + this.config.staffGroupVSpacing; const offsetX = (staffX - nodeWidth) / 2; if (offsetY > 0 || offsetX > 0) { this.adjustChildrenPosition(child, offsetX, offsetY); } }); } } }); } // Función auxiliar para ajustar la posición de los nodos hijos recursivamente adjustChildrenPosition(node, offsetX, offsetY) { node.y += offsetY; node.x -= offsetX; if (node.children) { node.children.forEach(child => { this.adjustChildrenPosition(child, offsetX, offsetY); }); } } // ubicación vertical de la gráfica. positionNodesVertically() { this.root.each(node => { if (node.children && !node._children) { const verticalChildren = node.children.filter(child => this.isVerticalChild(child)); // Calcula el espacio total requerido por los hojos verticales y sus descendientes const totalVerticalSpace = verticalChildren.reduce((acc, child) => { return acc + this.calculateTotalHeight(child); }, 0); // Position vertical children let y = node.y + node.height + this.config.nodesVerticalVSpacing; verticalChildren.forEach(child => { child.x = node.x + this.config.nodesVerticalMarginX; child.y = y; y += this.calculateTotalHeight(child) ; }); // Adjusta posición de hijos no verticales const nonVerticalChildren = node.children.filter(child => !this.isVerticalChild(child)); nonVerticalChildren.forEach(child => { child.y += totalVerticalSpace; }); } }); } isVerticalChild(child) { const { direction: childDirection = this.config.horizontalDirection } = this.config.branchConfig[child.data.branchId] || {}; return childDirection === this.config.verticalDirection; } calculateTotalHeight(node) { return node.height + this.config.nodesVerticalVSpacing + (node.children ? node.children.reduce((acc, child) => acc + this.calculateTotalHeight(child), 0) : 0); } // Ubica y ajusta las posiciones x e y de los nodos después de calcular las posiciones de Staff o verticales translateNodes(node) { node.attr('transform', d => { const nodeSize = this.config.branchConfig[d.data.branchId] || this.config.branchConfig[this.config.branchDefault]; let marginY = 0; const headerOrg = document.querySelector('g.header-org'); const bbox = headerOrg.getBBox(); const legendOrg = document.querySelector('g.legend'); const bboxl = legendOrg.getBBox(); if (this.config.graphType === "mobile") { const headerHeight = bbox.height + bbox.y + this.config.headerConfig.marginY; const legendHeight = bboxl.height + bboxl.y + this.config.legendConfig.marginY ; // para compensar el traslado del titulo marginY=headerHeight + legendHeight +20; } d.x = d.x - nodeSize.width + this.config.nodesGroupMarginX ; d.y = d.y + this.config.nodesGroupMarginY + marginY ; return `translate(${d.x}, ${d.y})`; }); } applyCollapseEvent(node) { const self = this; const container = d3.select(`#${self.config.containerId}`); // Selecciona todos los nodos que tienen hijos visibles o colapsados container.selectAll('g.node') .filter(d => (d.children || d._children || self.config.customCollapsed.includes(d.data.id))) // Nodos que tienen hijos visibles o colapsados, o están en `customCollapsed` .each(function(d) { const branchData = self.config.branchConfig[d.data.branchId] || self.config.branchConfig[self.config.branchDefault]; const nodesCollapsed = branchData.collapsed === true; let buttonGroup = container.select(`#collapse-button-${d.id}`); if (!buttonGroup.empty()) { // Si el botón ya existe, no se crea de nuevo return; } // Verifica si el nodo está en `this.config.customCollapsed` const isCustomCollapsed = self.config.customCollapsed.includes(d.data.id); if (nodesCollapsed || isCustomCollapsed) { // Botón colapsable const { collapsedButtonSize, collapsedButtonRadio, collapsedIcon, nodeBorderRadius } = self.config; const collapsedButtonHalfSize = collapsedButtonSize / 2; const plusLong = collapsedButtonSize - collapsedButtonRadio; const buttonGroup = d3.select(this) .append('g') .attr('class', 'collapse-button') .attr('id', `collapse-button-${d.id}`) .attr('width', collapsedButtonSize) .attr('height', collapsedButtonSize) .attr('fill', 'white') .attr('viewBox', `0 0 ${collapsedButtonSize} ${collapsedButtonSize}`) .attr('transform', `translate(${nodeBorderRadius}, ${d.height - collapsedButtonHalfSize})`) .style('cursor', 'pointer') .attr('tabindex', 0) .attr('role', 'button') .attr('aria-label', d => `Dependencias de ${d.data.office}`) .on('click', (event, d) => { event.stopPropagation(); self.toggleCollapse(d); }) .on('keydown', (event, d) => { // Acciones de teclado: Enter o Espacio if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); self.handleToggleAndFocus(buttonGroup.node(), d); // Gestionar el foco y el toggle } }); // Añadir el círculo con borde buttonGroup.append('circle') .attr('cx', collapsedButtonHalfSize) .attr('cy', collapsedButtonHalfSize) .attr('r', collapsedButtonRadio) .style('stroke', '#565656') .style('stroke-width', '1px') .style('fill', 'white') // Añadir el icono dentro del botón if (collapsedIcon === 'arrow') { // Flecha arriba y abajo buttonGroup.append('path') .attr('d', d => d.children ? 'M7.5 3L11.3971 9.75L3.60289 9.75L7.5 3Z' : 'M7.5 12L3.60289 5.25L11.3971 5.25L7.5 12Z') .attr('fill', '#565656'); } else { // Línea horizontal para el signo de más buttonGroup.append('line') .attr('x1', collapsedButtonHalfSize - plusLong / 2) .attr('y1', collapsedButtonHalfSize) .attr('x2', collapsedButtonHalfSize + plusLong / 2) .attr('y2', collapsedButtonHalfSize) .attr('stroke', '#565656'); // Añadir la línea vertical solo si no hay hijos (nodo colapsado) if (!d.children) { buttonGroup.append('line') .attr('x1', collapsedButtonHalfSize) .attr('y1', collapsedButtonHalfSize - plusLong / 2) .attr('x2', collapsedButtonHalfSize) .attr('y2', collapsedButtonHalfSize + plusLong / 2) .attr('stroke', '#565656'); } } } }); } handleToggleAndFocus(buttonElement, d, containerId) { const self = this; // Guardar el ID del botón antes de la actualización const buttonId = `collapse-button-${d.id}`; // Realizar el toggle self.toggleCollapse(d); self.update(); // Restablece el foco en el botón una vez que se redibuje setTimeout(() => { // Seleccionar el contenedor correcto para evitar conflictos entre gráficos const container = d3.select(self.container.node()); // Busca el botón dentro del contenedor usando el ID guardado const newButton = container.select(`#${buttonId}`).node(); if (newButton) { // Restablecer el foco en el botón newButton.focus(); } else { console.warn("No se encontró el botón después del redibujado."); } }, 0); } // Colapsa o expande un nodo manualmente toggleCollapse(d) { if (d.children) { d._children = d.children; // Guardar los hijos en _children (estado colapsado) d.children = null; // Eliminar los hijos visibles } else if (d._children) { d.children = d._children; // Expandir el nodo restaurando los hijos d._children = null; // Limpiar el estado colapsado } this.update(); } // Método para redibujar el gráfico update(clearNodes = true) { // Vuelve a calcular el layout del árbol para los nodos visibles this.configureTreeLayout(); if (clearNodes) { // Solo borra los nodos y enlaces si es necesario this.svg.selectAll('g.nodes-group').remove(); this.svg.selectAll('g.links-group').remove(); } // Renderizar de nuevo los nodos y enlaces this.renderNodes(); this.renderLinks(this.svg); // Volver a aplicar la funcionalidad de colapsado/expandido // this.applyCollapseEvent(); // Redimensionar el gráfico this.resizeGraph(); } // Agrega las barras de colores según los macroprocesos renderSticks(node) { const self = this; node.each(function(d) { const nodeElement = d3.select(this); // Obtener los datos específicos del branch const defaultConfig=self.config.processLegend[self.config.legendDefault]; const branchData = self.config.branchConfig[d.data.branchId] || self.config.branchConfig[self.config.branchDefault]; const legend = self.config.processLegend[d.data.processId] || self.config.processLegend[self.config.legendDefault]; const color = legend.color || defaultConfig.color; const colorAlt = legend.name; const legendClass = legend.class || defaultConfig.class; d.data.processColor=color; d.data.processClass=legendClass; // Configuración del stick (palito) const stickLegend = branchData.stick || "default"; const stickHeightFactor = stickLegend === "default" ? self.config.nodeStickHeight : self.config.nodeShortStickHeight; const stickWidth = stickLegend === "default" ? self.config.nodeStickWidth : self.config.nodeShortStickWidth; const stickRadius = stickLegend === "default" ? self.config.nodeStickRadius : self.config.nodeShortStickRadius; if (stickLegend!="none") { // Agregar el rectángulo que representa el stick nodeElement.append("rect") .attr('width', stickWidth) .attr("height", d.height * stickHeightFactor) .attr("rx", stickRadius) // Bordes redondeados .attr("x", -stickWidth / 2 + self.config.nodeBorder/2) .attr("y", d => (d.height / 2 - (d.height * stickHeightFactor) / 2)) .style("fill", color) .style("overflow", "visible") .attr("aria-label", "Color representativo de " + colorAlt) } }) } // Agrega el botón inferior derecho sobre cada nodo según aplique. renderButtons(node) { const self = this; // Filtra los nodos que deben tener un botón const nodesWithButton = node.filter(d => { const nodeLevel = d.data.branchId || self.config.branchDefault; const { button = true } = self.config.branchConfig[nodeLevel]; // Por defecto, `button` es true return button; }) // Añade el botón cuadrado a la selección de nodos nodesWithButton .append("path") .attr('d', d => { const radius = self.config.nodeBorderRadius - self.config.nodeBorder; const radius2 = self.config.nodeBtnRadius; const width = self.config.nodeBtnSize; const height = self.config.nodeBtnSize; const nodeSize = self.config.branchConfig[d.data.branchId] || self.config.branchConfig[this.config.branchDefault]; const x = nodeSize.width - self.config.nodeBtnSize; // Coordenada x inicial const y = d.height - self.config.nodeBtnSize; // Coordenada y inicial return ` M ${x + radius2},${y} H${x + width - self.config.nodeBorder} V${y + height - radius} A${radius},${radius} 0 0 1 ${x + width - radius},${y + height - self.config.nodeBorder} H${x} V${y + radius2} A${radius2},${radius2} 0 0 1 ${x + radius2},${y} Z`; }) .attr("fill", self.config.nodeBtnBgColor) .attr('class', "show-modal-btn") .style("cursor", "pointer") .style("overflow", "visible") .on("click", function(event, d) { event.stopPropagation(); showModal(d.data); }) .attr('tabindex', 0) .attr('role', 'button') // .attr('aria-label', d => `Abrir modal para ${d.data.office || 'elemento'}`) .attr('aria-label', d => `Abrir modal para obtener detalles`) .style("cursor", "pointer") .on('keydown', function(event, d) { // Responde a Enter o Espacio como un botón estándar if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); // Previene el desplazamiento de la página con Espacio event.stopPropagation(); showModal(d.data); } }); // Añade el símbolo "+" dentro del botón del nodo nodesWithButton .append("text") .attr('dy', 4) // Ajuste vertical para centrar el símbolo "+" .attr('text-anchor', 'middle') .attr('font-weight', 600) .attr('fill', self.config.nodeBtnFontColor) .style("font-size", d => `${Math.min(self.config.nodeBtnSize / 10, 1)}em`) // Ajusta el tamaño según el tamaño del botón, max 1 em .text("+") .attr("x", d => { const nodeSize = self.config.branchConfig[d.data.branchId] || self.config.branchConfig[this.config.branchDefault]; return nodeSize.width - self.config.nodeBtnSize / 2; }) .attr("y", d => d.height - self.config.nodeBtnSize / 2) .style("cursor", "pointer") .style("overflow", "visible") .attr('tabindex', -1) // No requiere foco separado, el foco está en el path .attr('aria-hidden', "true") .on("click", (event, d) => { event.stopPropagation(); showModal(d.data); }); } // Crea los links con las ubicaciones ajustadas. renderLinks(svg) { const linksGroup = this.svg.append('g') .attr('class', 'links-group') .style("overflow", "visible") .lower(); linksGroup.selectAll(".link") .data(this.root.links()) .enter() .append("path") .attr("class", "link") .style("stroke-width", "1px") .style("overflow", "visible") .style("stroke", "#000") .style("fill", "none") .attr("d", d => { const sourceLevel = d.source.data.branchId || this.config.branchDefault; const targetLevel = d.target.data.branchId || this.config.branchDefault; const sourceNodeWidth = this.config.branchConfig[sourceLevel].width; const sourceNodeHeight = d.source.height; const targetNodeWidth = this.config.branchConfig[targetLevel].width; const targetNodeHeight = d.target.height; const targetNodeDirection = this.config.branchConfig[targetLevel].direction; const sourceX = d.source.x + sourceNodeWidth/2; const sourceY = d.source.y ; const targetX = d.target.x; const targetY = d.target.y; const targetHeightHalf = targetNodeHeight / 2; let path; switch (targetNodeDirection) { case this.config.colDirection: //columnas de staff path = d.target.sidePosition === "left" ? `M${sourceX},${targetY + targetHeightHalf} H${targetX+targetNodeWidth}` : `M${sourceX},${targetY + targetHeightHalf} H${targetX}`; break; case this.config.verticalDirection: //nodos verticales path = `M${sourceX + (this.config.nodeBorderRadius) - (sourceNodeWidth / 2)+ this.config.collapsedButtonSize/2},${sourceY+sourceNodeHeight} V${targetY + targetHeightHalf} H${targetX }`; break; default: // Layout horizontal (predeterminado) path = `M${sourceX},${sourceY} V${targetY - this.config.linksHorizontalMarginY} H${targetX + targetNodeWidth/2} V${targetY}`; break; } return path; }) } renderHeader() { let headerOffsetX = 0; if (this.config.graphType !== "mobile") { headerOffsetX = -this.config.width / 4 - this.config.headerConfig.width / 2; // ubicar el header por defecto en la cuarta parte de la pantalla } const headerX = -this.config.headerConfig.width + this.config.headerConfig.marginX + headerOffsetX; const headerY = this.config.headerConfig.marginY; const headerGroup = this.svg.append("g") .attr("class", "header-org") .attr("x", 0) .attr("y", 0) .attr("width", this.config.headerConfig.width) .attr("height", this.config.headerConfig.height) .attr("transform", `translate(${headerX}, ${headerY})`) .attr("role", "banner") .attr("aria-labelledby", "header-title") .attr("aria-describedby", "header-details"); // Verificar si la imagen existe y convertirla a Base64 if (this.config.headerConfig.image !== "") { this.convertImageToBase64(this.config.headerConfig.image, (base64Image) => { const img = headerGroup.append("image") .attr("href", base64Image) .attr("x", 0) .attr("y", 0) .attr("width", this.config.headerConfig.imageWidth) // Definir solo el ancho .attr("preserveAspectRatio", "xMidYMid meet") // Mantener la relación de aspecto .attr("role", "img") .attr("aria-label", this.config.headerConfig.imageAlt) .style("overflow", "visible"); // Asegura que la imagen se exporte en Safari. img.on("load", () => { const imgHeight = img.node().getBBox().height; // Obtener la altura calculada automáticamente this.continueRenderHeader(headerGroup, imgHeight); // Pasar la altura real de la imagen }); }); } else { // Si no hay imagen, continúa directamente this.continueRenderHeader(headerGroup, 0); // En este caso no hay imagen, por lo que la altura será 0 } } continueRenderHeader(headerGroup, imageHeight) { // Coloca el foreignObject justo debajo de la imagen const foHeader = headerGroup.append("foreignObject") .attr("x", 0) .attr("y", imageHeight) // Posiciona el foreignObject debajo de la imagen .attr("width", this.config.headerConfig.width) .attr("height", this.config.headerConfig.height) .style("overflow", "visible"); // Agrega el div dentro del foreignObject con etiquetas y descripciones accesibles foHeader.append("xhtml:div") .attr("class", "header-container") .style("font-family", this.config.fontFamily) .html(`
${this.config.headerConfig.title}
${this.config.headerConfig.details}
`); setTimeout(() => { // Ajusta la altura del foreignObject según el contenido const headerContainer = this.container.select(".header-container"); const headerContainerHeight = headerContainer.node().offsetHeight; foHeader.attr("height", headerContainerHeight); }, 0); } convertImageToBase64(url, callback) { const img = new Image(); img.crossOrigin = 'Anonymous'; img.onload = function() { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); canvas.height = img.height; canvas.width = img.width; ctx.drawImage(img, 0, 0); const dataURL = canvas.toDataURL('image/png'); callback(dataURL); }; img.src = url; } renderLegend() { const legendData = Object.keys(this.config.processLegend) .filter(key => key !== this.config.legendDefault) .map(key => ({ name: this.config.processLegend[key].name, color: this.config.processLegend[key].color || this.config.processLegend[this.config.legendDefault].color, class:this.config.processLegend[key].class || this.config.processLegend[this.config.legendDefault].class })); let legendOffsetX=0; let marginY = 0; if (this.config.graphType === "mobile") { const headerContainer= this.container.select(".header-container"); const headerContainerHeight = headerContainer.node().offsetHeight; marginY = headerContainerHeight + 20; // para compensar el traslado del titulo } else { legendOffsetX=this.config.width/4+this.config.legendConfig.width/2; //ubicar el header por defecto en la cuarta parte de la pantalla } const legendX=-this.config.legendConfig.width+this.config.legendConfig.marginX+legendOffsetX; const legendY=this.config.legendConfig.marginY + marginY; const legendGroup = this.svg.append("g") .attr("class", "legend") .attr("x", 0) .attr("y", 0) .attr("width", this.config.legendConfig.width) .attr("height", this.config.legendConfig.height) .attr("transform", `translate(${legendX}, ${legendY})`) .attr("role", "region") .attr("aria-labelledby", "legend-title") .attr("tabindex", -1); const legendFieldset= legendGroup.append("rect") .attr("x", 0) .attr("y", 0) .attr("width", this.config.legendConfig.width) .attr("height", this.config.legendConfig.height) .attr("rx", this.config.legendConfig.fsRadius) .attr("ry", this.config.legendConfig.fsRadius) .attr("fill", "none") .attr("stroke", this.config.legendConfig.fsBorderColor) .attr("stroke-width", this.config.legendConfig.fsStrokeWidth) .attr("stroke-dasharray", this.config.legendConfig.fsStrokeDasharray) .attr("class", "legend-fieldset"); const foLegend = legendGroup.append("foreignObject") .style("overflow", "visible") .attr("x", 0) .attr("y", -20) .attr("width", this.config.legendConfig.width) .attr("height", this.config.legendConfig.height); let legendHTML = `
${this.config.legendConfig.title}
`; legendData.forEach(item => { legendHTML += `
`; }); foLegend.append("xhtml:div") .attr("class", "legend-container") .style("font-family", this.config.fontFamily) .style("overflow", "visible") .style("padding", this.config.legendConfig.lcPadding) .html(legendHTML); setTimeout(() => { //Ajusta las alturas const legendContainer= this.container.select(".legend-container"); const legendContainerHeight = legendContainer.node().offsetHeight; legendFieldset.attr("height", legendContainerHeight - 20); foLegend.attr("height", legendContainerHeight); }, 0); } resizeGraph() { // Obtiene los límites del contenido dentro del grupo principal const mainGroup = this.container.select('g.main-group'); const bounds = mainGroup.node().getBBox(); const contentWidth = bounds.width + this.config.graphPaddingX; const contentHeight = bounds.height + this.config.graphPaddingY; // Configura el SVG para ser responsivo const svgSelect = this.container.select('svg') svgSelect .attr('viewBox', `0 0 ${contentWidth + this.config.graphPaddingX} ${contentHeight}`) .attr('preserveAspectRatio', 'xMidYMid meet') .style('width', '100%') .style('height', 'auto') .attr('width', '100%') .style('visibility', 'visible'); // Ajusta la transformación del mainGroup para centrar el contenido const translateX = -bounds.x + this.config.graphPaddingX; const translateY = -bounds.y + this.config.graphPaddingY; mainGroup.attr("transform", `translate(${translateX}, ${translateY})`); } exportSVGToPDF({ scaleFactor = 2, pdfFileName = "organigrama" }) { const nodeButtons = document.querySelectorAll(".show-modal-btn"); // Oculta los botones nodeButtons.forEach(button => { button.style.display = "none"; }); const treeContainer = document.getElementById(this.config.containerId); // Verifica si las fuentes están completamente cargadas document.fonts.ready.then(() => { // Define las dimensiones del PDF const pageWidth = 279; const pageHeight = 216; // Obtiene la fecha actual y formatearla const date = new Date(); const formattedDate = date.getFullYear() + "." + String(date.getMonth() + 1).padStart(2, '0') + "." + String(date.getDate()).padStart(2, '0'); // Define el nombre del archivo PDF con la fecha const finalPdfFileName = `${pdfFileName}_${formattedDate}.pdf`; html2canvas(treeContainer, { scale: scaleFactor, backgroundColor: null, useCORS: true, allowTaint: false }).then(canvas => { const imgData = canvas.toDataURL("image/png"); const canvasWidth = canvas.width / scaleFactor; const canvasHeight = canvas.height / scaleFactor; const imgAspectRatio = canvasWidth / canvasHeight; let pdfWidth = pageWidth; let pdfHeight = pageWidth / imgAspectRatio; if (pdfHeight > pageHeight) { pdfHeight = pageHeight; pdfWidth = pageHeight * imgAspectRatio; } const jsPDF = window.jspdf.jsPDF; const pdf = new jsPDF('landscape', 'mm', [pageWidth, pageHeight]); // Agregar la imagen al PDF con las dimensiones calculadas pdf.addImage(imgData, 'PNG', 0, 0, pdfWidth, pdfHeight); pdf.save(finalPdfFileName); // Restaurar los botones después de exportar nodeButtons.forEach(button => { button.style.display = "block"; }); }).catch(error => { console.error("Error al capturar el contenedor:", error); nodeButtons.forEach(button => { button.style.display = "block"; }); }); }); } cleanText(text) { return text.replace(/[\n\r]+/g, ' ').replace(/\s\s+/g, ' ').trim(); } createBlueLine() { return { canvas: [ { type: 'line', x1: 0, y1: 0, x2: 515, y2: 0, // Ancho ajustado para los márgenes lineWidth: 1, lineColor: this.config.processLegend["Default"].color } ], margin: [0,5, 0, 15] // Margen superior e inferior }; } ContentTextToPdf(node, includeChildren, level = 0) { const indent = level * 15; const block = { stack: [], margin: [indent, 0, 0, 25], pageBreak: 'avoid' }; const nameText = { text: `${this.cleanText(node.data.firstName)} ${this.cleanText(node.data.lastName)}`, style: 'name', margin: [0, 0, 0, 8] }; const officeText = { text: `${this.cleanText(node.data.office)}`, style: 'office', margin: [0, 0, 0, 5] }; const contactText = { text: `${this.cleanText(node.data.contactInfo || 'N/A')}`, style: 'contact', margin: [0, 0, 0, 0] }; block.stack.push(nameText, officeText, contactText); const contentArray = []; contentArray.push(block); if (includeChildren === true) { if (node.children || node._children) { const children = node.children || node._children; children.forEach(child => { contentArray.push(...this.ContentTextToPdf(child, true, level + 1)); }); } } return contentArray; } // Método para generar el texto PDF exportTextToPdf({pdfFileName = "organigrama"}) { const nodes = this.root; const textColorDefault = this.config.nodeTextOfficerColor; const textColorPrimary = this.config.processLegend["Default"].color; const textColorGrey = this.config.nodeTextOfficeColor; const docDefinition = { pageSize: 'LETTER', pageMargins: [40, 60, 40, 60], content: [], footer: function(currentPage, pageCount) { return { columns: [ { text: `Página ${currentPage} de ${pageCount}`, alignment: 'right', margin: [0, 0, 40, 30], // margen inferior derecho fontSize: 10 } ] }; }, defaultStyle: { fontSize: 12 }, styles: { header: { fontSize: 18, bold: true, color:textColorPrimary }, title: { fontSize: 16, bold: true, color:textColorPrimary }, name: { fontSize: 14, bold:true, color: textColorDefault }, office: { fontSize: 12, bold:true, color: textColorDefault }, contact: { fontSize: 10, color: textColorGrey } } }; const headerText = [ // Título principal centrado { text: this.config.headerConfig.title, style: 'header', alignment: 'center', margin: [0, 0, 0, 5] }, // Detalle centrado { text: this.config.headerConfig.detailsLabel, style: 'office', alignment: 'center', margin: [0, 0, 0, 5] }, { text: `${this.config.headerConfig.dateUpdateLabel} ${this.config.headerConfig.dateUpdate}`, style: 'contact', alignment: 'center', margin: [0, 0, 0, 20] } ]; docDefinition.content.push(...headerText); if (nodes) { docDefinition.content.push( // Bloque nivel 1 { text: nodes.data.office, style: 'title', margin: [0, 0, 0, 5] }, this.createBlueLine() ); docDefinition.content.push(...this.ContentTextToPdf(nodes, false, 0)); nodes.children.forEach(child => { if (child.depth == 1) { docDefinition.content.push(...this.ContentTextToPdf(child, false, 1)); } }); } if (nodes.children) { nodes.children.forEach(child => { //bloque nivel 2 sin staff if (child.depth==1 && child.data.branch != this.config.branchStaff) { docDefinition.content.push( { text: child.data.office, style: 'title', margin: [0, 10, 0, 10] }, this.createBlueLine() ); docDefinition.content.push(...this.ContentTextToPdf(child, true, 1)); } }); } // Obtiene la fecha actual y formatearla const date = new Date(); const formattedDate = date.getFullYear() + "." + String(date.getMonth() + 1).padStart(2, '0') + "." + String(date.getDate()).padStart(2, '0'); // Define el nombre del archivo PDF con la fecha const finalPdfFileName = `${pdfFileName}_${formattedDate}.pdf`; pdfMake.createPdf(docDefinition).download(finalPdfFileName); } // Inicializa el gráfico initGraph(dataSource) { // Crear la jerarquía y configurar el layout del árbol this.createHierarchy(dataSource); this.configureTreeLayout(); // Lógica para colapsar nodos basada en branchConfig y customCollapsed this.root.each(d => { const branchData = this.config.branchConfig[d.data.branchId] || this.config.branchConfig[this.config.branchDefault]; const nodesCollapsed = branchData.collapsed === true; // Verifica si el branch debe estar colapsado const isCustomCollapsed = this.config.customCollapsed.includes(d.data.id); // Verifica si el nodo está en customCollapsed // Si el nodo debe estar colapsado según branchConfig o customCollapsed if (nodesCollapsed || isCustomCollapsed) { this.collapse(d, false); // Colapsar nodos sin recalcular el layout inmediatamente } }); this.renderHeader(); this.renderLegend(); // Renderizar nodos y enlaces this.renderNodes(); this.renderLinks(this.svg); // Ajustar el tamaño del gráfico this.resizeGraph(); // Renderizar header y leyenda // Evento para manejar clicks en elementos con clase 'full-name' document.getElementById(this.config.containerId).addEventListener('click', (event) => { if (event.target.classList.contains('full-name-noBtn')) { const nodeId = event.target.getAttribute('data-node-id'); const nodeData = this.root.descendants().find(node => node.data.id === nodeId); if (nodeData) { showModal(nodeData.data); // Mostrar los datos en el modal } } }); // Agregar eventos para cambiar el estilo del cursor dinámicamente document.getElementById(this.config.containerId).addEventListener('mousedown', (event) => { if (event.target.classList.contains('full-name-noBtn')) { event.target.style.cursor = 'grabbing'; } }); document.getElementById(this.config.containerId).addEventListener('mouseup', (event) => { if (event.target.classList.contains('full-name-noBtn')) { event.target.style.cursor = 'pointer'; } }); // Evento para redimensionar el gráfico window.addEventListener('resize', () => { // Limpiar el timeout anterior si existe if (this.resizeTimeout) clearTimeout(this.resizeTimeout); // Establecer un nuevo timeout para evitar múltiples redimensionados seguidos this.resizeTimeout = setTimeout(() => { // Inicializa la gráfica dependiendo del tamaño de la pantalla initChart(); }, 50); }); } }