UNPKG

ngx-interactive-org-chart

Version:

Modern Angular organizational chart component with interactive pan/zoom functionality and more..

1,140 lines (1,135 loc) 125 kB
import { NgStyle, NgTemplateOutlet, NgClass } from '@angular/common'; import * as i0 from '@angular/core'; import { inject, Injector, ElementRef, input, output, viewChild, signal, computed, effect, Component, afterNextRender, ContentChild } from '@angular/core'; import createPanZoom from 'panzoom'; import { trigger, transition, style, animate } from '@angular/animations'; function toggleNodeCollapse({ node, targetNode, collapse, }) { if (node.id === targetNode) { const newCollapse = collapse ?? !node.collapsed; return { ...node, collapsed: newCollapse, children: node.children?.map(child => setCollapseRecursively(child, newCollapse)), }; } if (node.children?.length) { return { ...node, children: node.children.map(child => toggleNodeCollapse({ node: child, targetNode, collapse })), }; } return node; } function setCollapseRecursively(node, collapse) { return { ...node, collapsed: collapse, children: node.children?.map(child => setCollapseRecursively(child, collapse)), }; } function mapNodesRecursively(node, collapsed) { const mappedChildren = node.children?.map(child => mapNodesRecursively(child, collapsed)) || []; const descendantsCount = mappedChildren.reduce((acc, child) => acc + 1 + (child.descendantsCount ?? 0), 0); return { ...node, id: node.id ?? crypto.randomUUID(), collapsed: collapsed ?? node.collapsed ?? false, hidden: node.hidden ?? false, children: mappedChildren, descendantsCount, }; } /** * Essential CSS properties to copy for drag ghost elements. */ const GHOST_ELEMENT_STYLES = [ 'background', 'background-color', 'background-image', 'background-size', 'background-position', 'background-repeat', 'color', 'font-family', 'font-size', 'font-weight', 'line-height', 'text-align', 'border', 'border-radius', 'border-color', 'border-width', 'border-style', 'padding', 'box-sizing', 'display', 'flex-direction', 'align-items', 'justify-content', 'gap', 'outline', 'outline-color', 'outline-width', ]; /** * Creates a cloned node element with copied styles for drag preview. * This removes interactive elements and copies essential visual styles. * * @param nodeElement - The original node element to clone * @returns A cloned element with copied styles */ function cloneNodeWithStyles(nodeElement) { // Clone the node deeply const clone = nodeElement.cloneNode(true); // Remove ID and draggable to avoid conflicts clone.removeAttribute('id'); clone.removeAttribute('draggable'); // Remove interactive elements const collapseBtn = clone.querySelector('.collapse-btn'); if (collapseBtn) collapseBtn.remove(); const dragHandle = clone.querySelector('.drag-handle'); if (dragHandle) dragHandle.remove(); // Get computed styles from original const computed = window.getComputedStyle(nodeElement); // Apply essential visual styles to clone GHOST_ELEMENT_STYLES.forEach(prop => { const value = computed.getPropertyValue(prop); if (value) { clone.style.setProperty(prop, value, 'important'); } }); // Apply styles to nested elements const sourceChildren = nodeElement.querySelectorAll('*'); const cloneChildren = clone.querySelectorAll('*'); sourceChildren.forEach((sourceChild, index) => { if (cloneChildren[index]) { const childComputed = window.getComputedStyle(sourceChild); const cloneChild = cloneChildren[index]; GHOST_ELEMENT_STYLES.forEach(prop => { const value = childComputed.getPropertyValue(prop); if (value) { cloneChild.style.setProperty(prop, value, 'important'); } }); } }); return clone; } /** * Creates a ghost element to follow the touch during drag. * The ghost element is wrapped in a positioned container and scaled to match the current zoom level. * * @param config - Configuration for creating the ghost element * @returns The wrapper element and scaled dimensions */ function createTouchDragGhost(config) { const { nodeElement, currentScale, touchX, touchY } = config; // Get the unscaled dimensions const unscaledWidth = nodeElement.offsetWidth; const unscaledHeight = nodeElement.offsetHeight; // Clone the node with styles using shared helper const ghost = cloneNodeWithStyles(nodeElement); // Calculate the actual scaled dimensions for positioning const scaleFactor = currentScale * 1.05; const scaledWidth = unscaledWidth * scaleFactor; const scaledHeight = unscaledHeight * scaleFactor; // Create wrapper for positioning const wrapper = document.createElement('div'); wrapper.className = 'touch-drag-ghost-wrapper'; wrapper.style.position = 'fixed'; wrapper.style.pointerEvents = 'none'; wrapper.style.zIndex = '10000'; // Position wrapper so the scaled ghost is centered on the touch point wrapper.style.left = touchX - scaledWidth / 2 + 'px'; wrapper.style.top = touchY - scaledHeight / 2 + 'px'; // Set ghost to unscaled dimensions, then apply the zoom scale via transform ghost.style.setProperty('position', 'relative', 'important'); ghost.style.setProperty('width', unscaledWidth + 'px', 'important'); ghost.style.setProperty('height', unscaledHeight + 'px', 'important'); ghost.style.setProperty('margin', '0', 'important'); ghost.style.setProperty('opacity', '0.9', 'important'); ghost.style.setProperty('transform-origin', 'top left', 'important'); // Apply the current zoom scale to match the visible node, then apply slight scale-up for drag effect ghost.style.setProperty('transform', `scale(${scaleFactor})`, 'important'); ghost.style.setProperty('box-shadow', '0 15px 40px rgba(0, 0, 0, 0.4)', 'important'); ghost.style.setProperty('cursor', 'grabbing', 'important'); wrapper.appendChild(ghost); document.body.appendChild(wrapper); return { wrapper, scaledWidth, scaledHeight, }; } /** * Updates the position of a touch drag ghost element wrapper. * * @param wrapper - The ghost wrapper element to reposition * @param x - The new x coordinate * @param y - The new y coordinate * @param scaledWidth - The scaled width of the ghost element * @param scaledHeight - The scaled height of the ghost element */ function updateTouchGhostPosition(wrapper, x, y, scaledWidth, scaledHeight) { wrapper.style.left = x - scaledWidth / 2 + 'px'; wrapper.style.top = y - scaledHeight / 2 + 'px'; } /** * Creates and sets a custom drag image for desktop drag and drop. * This is required for Safari to show a drag preview properly. * * @param event - The drag event * @param nodeElement - The node element being dragged * @returns Cleanup function to remove the temporary drag image */ function setDesktopDragImage(event, nodeElement) { if (!event.dataTransfer) return; // Create a styled clone for the drag image const dragImage = cloneNodeWithStyles(nodeElement); dragImage.style.position = 'absolute'; dragImage.style.top = '-9999px'; dragImage.style.left = '-9999px'; dragImage.style.opacity = '0.8'; document.body.appendChild(dragImage); const rect = nodeElement.getBoundingClientRect(); const offsetX = event.clientX - rect.left; const offsetY = event.clientY - rect.top; event.dataTransfer.setDragImage(dragImage, offsetX, offsetY); setTimeout(() => { if (document.body.contains(dragImage)) { document.body.removeChild(dragImage); } }, 0); } const DEFAULT_THEME = { background: 'rgba(255, 255, 255, 0.95)', borderColor: 'rgba(0, 0, 0, 0.15)', borderRadius: '8px', shadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', nodeColor: 'rgba(0, 0, 0, 0.6)', viewportBackground: 'rgba(59, 130, 246, 0.2)', viewportBorderColor: 'rgb(59, 130, 246)', viewportBorderWidth: '2px', }; const REDRAW_DEBOUNCE_MS = 100; const CONTENT_PADDING_RATIO = 0.9; const CSS_VAR_REGEX = /var\((--[^)]+)\)/; class MiniMapComponent { #injector = inject(Injector); #elementRef = inject(ElementRef); panZoomInstance = input.required(...(ngDevMode ? [{ debugName: "panZoomInstance" }] : [])); chartContainer = input.required(...(ngDevMode ? [{ debugName: "chartContainer" }] : [])); position = input('bottom-right', ...(ngDevMode ? [{ debugName: "position" }] : [])); width = input(200, ...(ngDevMode ? [{ debugName: "width" }] : [])); height = input(150, ...(ngDevMode ? [{ debugName: "height" }] : [])); visible = input(true, ...(ngDevMode ? [{ debugName: "visible" }] : [])); themeOptions = input(...(ngDevMode ? [undefined, { debugName: "themeOptions" }] : [])); navigate = output(); canvasRef = viewChild('miniMapCanvas', ...(ngDevMode ? [{ debugName: "canvasRef" }] : [])); viewportRef = viewChild('viewport', ...(ngDevMode ? [{ debugName: "viewportRef" }] : [])); viewportStyle = signal({}, ...(ngDevMode ? [{ debugName: "viewportStyle" }] : [])); miniMapStyle = computed(() => { const theme = this.themeOptions(); return { width: `${this.width()}px`, height: `${this.height()}px`, backgroundColor: theme?.background ?? DEFAULT_THEME.background, borderColor: theme?.borderColor ?? DEFAULT_THEME.borderColor, borderRadius: theme?.borderRadius ?? DEFAULT_THEME.borderRadius, boxShadow: theme?.shadow ?? DEFAULT_THEME.shadow, }; }, ...(ngDevMode ? [{ debugName: "miniMapStyle" }] : [])); viewportIndicatorStyle = computed(() => { const theme = this.themeOptions(); return { ...this.viewportStyle(), backgroundColor: theme?.viewportBackground ?? DEFAULT_THEME.viewportBackground, borderColor: theme?.viewportBorderColor ?? DEFAULT_THEME.viewportBorderColor, borderWidth: theme?.viewportBorderWidth ?? DEFAULT_THEME.viewportBorderWidth, }; }, ...(ngDevMode ? [{ debugName: "viewportIndicatorStyle" }] : [])); nodeColor = computed(() => this.themeOptions()?.nodeColor ?? DEFAULT_THEME.nodeColor, ...(ngDevMode ? [{ debugName: "nodeColor" }] : [])); #animationFrameId = null; #isDragging = false; #mutationObserver = null; #themeObserver = null; #redrawTimeout = null; constructor() { this.#initializeEffects(); this.#setupThemeObserver(); } ngOnDestroy() { this.#cleanup(); } onMouseDown(event) { event.preventDefault(); this.#isDragging = true; const handleMouseMove = (e) => { if (this.#isDragging) { this.#navigateToPoint(e); } }; const handleMouseUp = () => { this.#isDragging = false; document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); } #initializeEffects() { effect(() => { const panZoom = this.panZoomInstance(); panZoom ? this.#startTracking() : this.#stopTracking(); }, { injector: this.#injector }); effect(() => { const container = this.chartContainer(); if (container) { this.#setupMutationObserver(container); this.#drawMiniMap(); } else { this.#disconnectMutationObserver(); } }, { injector: this.#injector }); effect(() => { this.themeOptions(); this.#scheduleRedraw(); }, { injector: this.#injector }); } #setupThemeObserver() { this.#themeObserver = new MutationObserver(() => this.#scheduleRedraw()); [document.documentElement, document.body].forEach(target => { this.#themeObserver.observe(target, { attributes: true, attributeFilter: ['class', 'data-theme', 'data-mode'], }); }); } #disconnectThemeObserver() { this.#themeObserver?.disconnect(); this.#themeObserver = null; } #setupMutationObserver(container) { this.#disconnectMutationObserver(); this.#mutationObserver = new MutationObserver(() => this.#scheduleRedraw()); this.#mutationObserver.observe(container, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'style'], }); } #disconnectMutationObserver() { this.#mutationObserver?.disconnect(); this.#mutationObserver = null; } #scheduleRedraw() { if (this.#redrawTimeout !== null) { clearTimeout(this.#redrawTimeout); } this.#redrawTimeout = setTimeout(() => { this.#drawMiniMap(); this.#redrawTimeout = null; }, REDRAW_DEBOUNCE_MS); } #startTracking() { if (this.#animationFrameId !== null) return; const update = () => { this.#updateViewport(); this.#animationFrameId = requestAnimationFrame(update); }; this.#animationFrameId = requestAnimationFrame(update); } #stopTracking() { if (this.#animationFrameId !== null) { cancelAnimationFrame(this.#animationFrameId); this.#animationFrameId = null; } } #drawMiniMap() { const canvas = this.canvasRef()?.nativeElement; const container = this.chartContainer(); if (!canvas || !container) return; const ctx = canvas.getContext('2d'); if (!ctx) return; canvas.width = this.width(); canvas.height = this.height(); ctx.clearRect(0, 0, canvas.width, canvas.height); const contentBounds = this.#getContentBounds(container); if (!contentBounds) return; const { scale, offsetX, offsetY } = this.#calculateMiniMapTransform(canvas, contentBounds); const transform = this.#getCurrentTransform(); this.#drawNodes(ctx, container, contentBounds, scale, offsetX, offsetY, transform); } #getContentBounds(container) { const nodes = container.querySelectorAll('.node-content'); if (nodes.length === 0) return null; const transform = this.#getCurrentTransform(); const containerRect = container.getBoundingClientRect(); let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; nodes.forEach(node => { const rect = node.getBoundingClientRect(); const relX = (rect.left - containerRect.left - transform.x) / transform.scale; const relY = (rect.top - containerRect.top - transform.y) / transform.scale; const width = rect.width / transform.scale; const height = rect.height / transform.scale; minX = Math.min(minX, relX); minY = Math.min(minY, relY); maxX = Math.max(maxX, relX + width); maxY = Math.max(maxY, relY + height); }); return { left: minX, top: minY, width: maxX - minX, height: maxY - minY, }; } #calculateMiniMapTransform(canvas, contentBounds) { const scaleX = canvas.width / contentBounds.width; const scaleY = canvas.height / contentBounds.height; const scale = Math.min(scaleX, scaleY) * CONTENT_PADDING_RATIO; const offsetX = (canvas.width - contentBounds.width * scale) / 2; const offsetY = (canvas.height - contentBounds.height * scale) / 2; return { scale, offsetX, offsetY }; } #drawNodes(ctx, container, contentBounds, scale, offsetX, offsetY, transform) { const resolvedNodeColor = this.#resolveColor(this.nodeColor()); ctx.fillStyle = resolvedNodeColor; ctx.strokeStyle = resolvedNodeColor; ctx.lineWidth = 1; const nodes = container.querySelectorAll('.node-content'); const containerRect = container.getBoundingClientRect(); nodes.forEach(node => { const rect = node.getBoundingClientRect(); const relX = (rect.left - containerRect.left - transform.x) / transform.scale; const relY = (rect.top - containerRect.top - transform.y) / transform.scale; const w = rect.width / transform.scale; const h = rect.height / transform.scale; const x = (relX - contentBounds.left) * scale + offsetX; const y = (relY - contentBounds.top) * scale + offsetY; const scaledW = w * scale; const scaledH = h * scale; ctx.fillRect(x, y, scaledW, scaledH); }); } #updateViewport() { const panZoom = this.panZoomInstance(); const container = this.chartContainer(); const canvas = this.canvasRef()?.nativeElement; if (!panZoom || !container || !canvas) return; const transform = panZoom.getTransform(); const contentBounds = this.#getContentBounds(container); if (!contentBounds) return; const { scale, offsetX, offsetY } = this.#calculateMiniMapTransform(canvas, contentBounds); const containerRect = container.getBoundingClientRect(); const viewportWidth = containerRect.width / transform.scale; const viewportHeight = containerRect.height / transform.scale; const viewportX = -transform.x / transform.scale - contentBounds.left; const viewportY = -transform.y / transform.scale - contentBounds.top; const miniViewportX = viewportX * scale + offsetX; const miniViewportY = viewportY * scale + offsetY; const miniViewportWidth = viewportWidth * scale; const miniViewportHeight = viewportHeight * scale; this.viewportStyle.set({ left: `${miniViewportX}px`, top: `${miniViewportY}px`, width: `${miniViewportWidth}px`, height: `${miniViewportHeight}px`, }); } #navigateToPoint(event) { const canvas = this.canvasRef()?.nativeElement; const container = this.chartContainer(); const panZoom = this.panZoomInstance(); if (!canvas || !container || !panZoom) return; const rect = canvas.getBoundingClientRect(); const clickX = event.clientX - rect.left; const clickY = event.clientY - rect.top; const contentBounds = this.#getContentBounds(container); if (!contentBounds) return; const { scale, offsetX, offsetY } = this.#calculateMiniMapTransform(canvas, contentBounds); const contentX = (clickX - offsetX) / scale + contentBounds.left; const contentY = (clickY - offsetY) / scale + contentBounds.top; const transform = panZoom.getTransform(); const containerRect = container.getBoundingClientRect(); const newX = -(contentX * transform.scale - containerRect.width / 2); const newY = -(contentY * transform.scale - containerRect.height / 2); panZoom.moveTo(newX, newY); this.navigate.emit({ x: newX, y: newY }); } #resolveColor(color) { if (!color.includes('var(')) return color; const computedStyle = getComputedStyle(this.#elementRef.nativeElement); const match = color.match(CSS_VAR_REGEX); if (match) { const propertyName = match[1]; const resolvedColor = computedStyle.getPropertyValue(propertyName).trim(); return resolvedColor || color; } return color; } #getCurrentTransform() { const panZoom = this.panZoomInstance(); return panZoom?.getTransform() ?? { scale: 1, x: 0, y: 0 }; } #cleanup() { this.#stopTracking(); this.#disconnectMutationObserver(); this.#disconnectThemeObserver(); if (this.#redrawTimeout !== null) { clearTimeout(this.#redrawTimeout); this.#redrawTimeout = null; } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.4", ngImport: i0, type: MiniMapComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.2.4", type: MiniMapComponent, isStandalone: true, selector: "ngx-org-chart-mini-map", inputs: { panZoomInstance: { classPropertyName: "panZoomInstance", publicName: "panZoomInstance", isSignal: true, isRequired: true, transformFunction: null }, chartContainer: { classPropertyName: "chartContainer", publicName: "chartContainer", isSignal: true, isRequired: true, transformFunction: null }, position: { classPropertyName: "position", publicName: "position", isSignal: true, isRequired: false, transformFunction: null }, width: { classPropertyName: "width", publicName: "width", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null }, visible: { classPropertyName: "visible", publicName: "visible", isSignal: true, isRequired: false, transformFunction: null }, themeOptions: { classPropertyName: "themeOptions", publicName: "themeOptions", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { navigate: "navigate" }, viewQueries: [{ propertyName: "canvasRef", first: true, predicate: ["miniMapCanvas"], descendants: true, isSignal: true }, { propertyName: "viewportRef", first: true, predicate: ["viewport"], descendants: true, isSignal: true }], ngImport: i0, template: "@if (visible()) {\n <div\n class=\"mini-map-container\"\n [class]=\"'position-' + position()\"\n [ngStyle]=\"miniMapStyle()\"\n (mousedown)=\"onMouseDown($event)\"\n >\n <canvas #miniMapCanvas class=\"mini-map-canvas\"></canvas>\n <div #viewport class=\"viewport-indicator\" [ngStyle]=\"viewportIndicatorStyle()\"></div>\n </div>\n}\n", styles: [":host{display:contents}.mini-map-container{position:absolute;z-index:1000;border:2px solid;border-radius:8px;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f;cursor:pointer;-webkit-user-select:none;user-select:none;overflow:hidden;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);transition:opacity .3s ease}.mini-map-container:hover{opacity:1!important}.mini-map-container.position-top-left{top:16px;left:16px}.mini-map-container.position-top-right{top:16px;right:16px}.mini-map-container.position-bottom-left{bottom:16px;left:16px}.mini-map-container.position-bottom-right{bottom:16px;right:16px}.mini-map-canvas{display:block;width:100%;height:100%}.viewport-indicator{position:absolute;border:2px solid;pointer-events:none;border-radius:2px;transition:all .1s ease-out;box-sizing:border-box}\n"], dependencies: [{ kind: "directive", type: NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.4", ngImport: i0, type: MiniMapComponent, decorators: [{ type: Component, args: [{ selector: 'ngx-org-chart-mini-map', standalone: true, imports: [NgStyle], template: "@if (visible()) {\n <div\n class=\"mini-map-container\"\n [class]=\"'position-' + position()\"\n [ngStyle]=\"miniMapStyle()\"\n (mousedown)=\"onMouseDown($event)\"\n >\n <canvas #miniMapCanvas class=\"mini-map-canvas\"></canvas>\n <div #viewport class=\"viewport-indicator\" [ngStyle]=\"viewportIndicatorStyle()\"></div>\n </div>\n}\n", styles: [":host{display:contents}.mini-map-container{position:absolute;z-index:1000;border:2px solid;border-radius:8px;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f;cursor:pointer;-webkit-user-select:none;user-select:none;overflow:hidden;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);transition:opacity .3s ease}.mini-map-container:hover{opacity:1!important}.mini-map-container.position-top-left{top:16px;left:16px}.mini-map-container.position-top-right{top:16px;right:16px}.mini-map-container.position-bottom-left{bottom:16px;left:16px}.mini-map-container.position-bottom-right{bottom:16px;right:16px}.mini-map-canvas{display:block;width:100%;height:100%}.viewport-indicator{position:absolute;border:2px solid;pointer-events:none;border-radius:2px;transition:all .1s ease-out;box-sizing:border-box}\n"] }] }], ctorParameters: () => [] }); const DEFAULT_THEME_OPTIONS = { node: { background: '#ffffff', color: '#4a4a4a', activeOutlineColor: '#3b82f6', outlineWidth: '2px', shadow: '0 2px 10px rgba(0, 0, 0, 0.1)', outlineColor: '#d1d5db', highlightShadowColor: 'rgba(121, 59, 246, 0)', padding: '12px 16px', borderRadius: '8px', containerSpacing: '20px', activeColor: '#3b82f6', maxWidth: 'auto', minWidth: 'auto', maxHeight: 'auto', minHeight: 'auto', dragOverOutlineColor: '#3b82f6', }, connector: { color: '#d1d5db', activeColor: '#3b82f6', borderRadius: '10px', width: '1.5px', }, collapseButton: { size: '20px', borderColor: '#d1d5db', borderRadius: '0.25rem', color: '#4a4a4a', background: '#ffffff', hoverColor: '#3b82f6', hoverBackground: '#f3f4f6', hoverShadow: '0 2px 4px rgba(0, 0, 0, 0.1)', hoverTransformScale: '1.05', focusOutline: '2px solid #3b82f6', countFontSize: '0.75rem', }, container: { background: 'transparent', border: 'none', }, miniMap: { background: 'rgba(255, 255, 255, 0.95)', borderColor: 'rgba(0, 0, 0, 0.15)', borderRadius: '8px', shadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', nodeColor: 'rgba(0, 0, 0, 0.6)', viewportBackground: 'rgba(59, 130, 246, 0.2)', viewportBorderColor: 'rgb(59, 130, 246)', viewportBorderWidth: '2px', }, }; // Constants const RESET_DELAY = 300; // ms const TOUCH_DRAG_THRESHOLD = 10; // pixels const AUTO_PAN_EDGE_THRESHOLD = 0.1; // 10% of container dimensions const AUTO_PAN_SPEED = 15; // pixels per frame class NgxInteractiveOrgChart { #elementRef = inject(ElementRef); #injector = inject(Injector); /** * Optional template for a custom node. * When provided, this template will be used to render each node in the org chart. * * @remarks * The template context includes: * - `$implicit`: The node data * - `node`: The node data (alternative accessor) * * @example * ```html * <ngx-interactive-org-chart> * <ng-template #nodeTemplate let-node="node"> * <div class="custom-node"> * <h3>{{ node.data?.name }}</h3> * <p>{{ node.data?.age }}</p> * </div> * </ng-template> * </ngx-interactive-org-chart> * ``` */ customNodeTemplate; /** * Optional template for a custom drag handle. * When provided, only this element will be draggable instead of the entire node. * * @remarks * The template context includes: * - `$implicit`: The node data * - `node`: The node data (alternative accessor) * * @example * ```html * <ngx-interactive-org-chart [draggable]="true"> * <ng-template #dragHandleTemplate let-node="node"> * <button class="drag-handle" title="Drag to move"> * <svg><!-- Drag icon --></svg> * </button> * </ng-template> * </ngx-interactive-org-chart> * ``` */ customDragHandleTemplate; panZoomContainer = viewChild.required('panZoomContainer'); orgChartContainer = viewChild.required('orgChartContainer'); container = viewChild.required('container'); /** * The data for the org chart. */ data = input.required(...(ngDevMode ? [{ debugName: "data" }] : [])); /** * The initial zoom level for the chart. */ initialZoom = input(...(ngDevMode ? [undefined, { debugName: "initialZoom" }] : [])); /** * The minimum zoom level for the chart */ minZoom = input(0.1, ...(ngDevMode ? [{ debugName: "minZoom" }] : [])); /** * The maximum zoom level for the chart. */ maxZoom = input(5, ...(ngDevMode ? [{ debugName: "maxZoom" }] : [])); /** * The speed at which the chart zooms in/out on wheel or pinch. */ zoomSpeed = input(1, ...(ngDevMode ? [{ debugName: "zoomSpeed" }] : [])); /** * The speed at which the chart zooms in/out on double-click. */ zoomDoubleClickSpeed = input(2, ...(ngDevMode ? [{ debugName: "zoomDoubleClickSpeed" }] : [])); /** * Whether the nodes can be collapsed/expanded. */ collapsible = input(true, ...(ngDevMode ? [{ debugName: "collapsible" }] : [])); /** * The CSS class to apply to each node element. */ nodeClass = input(...(ngDevMode ? [undefined, { debugName: "nodeClass" }] : [])); /** * If set to `true`, all the nodes will be initially collapsed. */ initialCollapsed = input(...(ngDevMode ? [undefined, { debugName: "initialCollapsed" }] : [])); /** * Whether to enable RTL (right-to-left) layout. */ isRtl = input(...(ngDevMode ? [undefined, { debugName: "isRtl" }] : [])); /** * The layout direction of the org chart tree. * - 'vertical': Traditional top-to-bottom tree layout * - 'horizontal': Left-to-right tree layout */ layout = input('vertical', ...(ngDevMode ? [{ debugName: "layout" }] : [])); /** * Whether to focus on the node when it is collapsed and focus on its children when it is expanded. */ focusOnCollapseOrExpand = input(false, ...(ngDevMode ? [{ debugName: "focusOnCollapseOrExpand" }] : [])); /** * Whether to display the count of children for each node on expand/collapse button. */ displayChildrenCount = input(true, ...(ngDevMode ? [{ debugName: "displayChildrenCount" }] : [])); /** * The ratio of the node's width to the viewport's width when highlighting. */ highlightZoomNodeWidthRatio = input(0.3, ...(ngDevMode ? [{ debugName: "highlightZoomNodeWidthRatio" }] : [])); /** * The ratio of the node's height to the viewport's height when highlighting. */ highlightZoomNodeHeightRatio = input(0.4, ...(ngDevMode ? [{ debugName: "highlightZoomNodeHeightRatio" }] : [])); /** * Whether to enable drag and drop functionality for nodes. * When enabled, nodes can be dragged and dropped onto other nodes. * * @remarks * By default, the entire node is draggable. To use a custom drag handle instead, * provide a `dragHandleTemplate` template with the selector `#dragHandleTemplate`. * * @example * ```html * <ngx-interactive-org-chart [draggable]="true"> * <!-- Custom drag handle template --> * <ng-template #dragHandleTemplate let-node="node"> * <span class="drag-handle">⋮⋮</span> * </ng-template> * </ngx-interactive-org-chart> * ``` */ draggable = input(false, ...(ngDevMode ? [{ debugName: "draggable" }] : [])); /** * Predicate function to determine if a specific node can be dragged. * * @param node - The node to check * @returns true if the node can be dragged, false otherwise * * @example * ```typescript * // Prevent CEO node from being dragged * canDragNode = (node: OrgChartNode) => node.data?.role !== 'CEO'; * ``` */ canDragNode = input(...(ngDevMode ? [undefined, { debugName: "canDragNode" }] : [])); /** * Predicate function to determine if a node can accept drops. * * @param draggedNode - The node being dragged * @param targetNode - The potential drop target * @returns true if the drop is allowed, false otherwise * * @example * ```typescript * // Don't allow employees to have subordinates * canDropNode = (dragged: OrgChartNode, target: OrgChartNode) => { * return target.data?.type !== 'Employee'; * }; * ``` */ canDropNode = input(...(ngDevMode ? [undefined, { debugName: "canDropNode" }] : [])); /** * The distance in pixels from the viewport edge to trigger auto-panning during drag. * The threshold is calculated automatically as 10% of the container dimensions for better responsiveness across different screen sizes. * @default 0.1 */ dragEdgeThreshold = input(AUTO_PAN_EDGE_THRESHOLD, ...(ngDevMode ? [{ debugName: "dragEdgeThreshold" }] : [])); /** * The speed of auto-panning in pixels per frame during drag. * @default 15 */ dragAutoPanSpeed = input(AUTO_PAN_SPEED, ...(ngDevMode ? [{ debugName: "dragAutoPanSpeed" }] : [])); /** * The minimum zoom level for the chart when highlighting a node. */ highlightZoomMinimum = input(0.8, ...(ngDevMode ? [{ debugName: "highlightZoomMinimum" }] : [])); /** * The theme options for the org chart. * This allows customization of the chart's appearance, including node styles, connector styles, and * other visual elements. */ themeOptions = input(...(ngDevMode ? [undefined, { debugName: "themeOptions" }] : [])); /** * Whether to show the mini map navigation tool. * @default false */ showMiniMap = input(false, ...(ngDevMode ? [{ debugName: "showMiniMap" }] : [])); /** * Position of the mini map on the screen. * @default 'bottom-right' */ miniMapPosition = input('bottom-right', ...(ngDevMode ? [{ debugName: "miniMapPosition" }] : [])); /** * Width of the mini map in pixels. * @default 200 */ miniMapWidth = input(200, ...(ngDevMode ? [{ debugName: "miniMapWidth" }] : [])); /** * Height of the mini map in pixels. * @default 150 */ miniMapHeight = input(150, ...(ngDevMode ? [{ debugName: "miniMapHeight" }] : [])); /** * Event emitted when a node is dropped onto another node. * Provides the dragged node and the target node. */ nodeDrop = output(); /** * Event emitted when a node drag operation starts. */ nodeDragStart = output(); /** * Event emitted when a node drag operation ends. */ nodeDragEnd = output(); defaultThemeOptions = DEFAULT_THEME_OPTIONS; finalThemeOptions = computed(() => { const themeOptions = this.themeOptions(); return { node: { ...this.defaultThemeOptions.node, ...themeOptions?.node, }, connector: { ...this.defaultThemeOptions.connector, ...themeOptions?.connector, }, collapseButton: { ...this.defaultThemeOptions.collapseButton, ...themeOptions?.collapseButton, }, container: { ...this.defaultThemeOptions.container, ...themeOptions?.container, }, miniMap: { ...this.defaultThemeOptions.miniMap, ...themeOptions?.miniMap, }, }; }, ...(ngDevMode ? [{ debugName: "finalThemeOptions" }] : [])); nodes = signal(null, ...(ngDevMode ? [{ debugName: "nodes" }] : [])); scale = signal(0, ...(ngDevMode ? [{ debugName: "scale" }] : [])); draggedNode = signal(null, ...(ngDevMode ? [{ debugName: "draggedNode" }] : [])); dragOverNode = signal(null, ...(ngDevMode ? [{ debugName: "dragOverNode" }] : [])); currentDragOverElement = signal(null, ...(ngDevMode ? [{ debugName: "currentDragOverElement" }] : [])); autoPanInterval = null; keyboardListener = null; touchDragState = { active: false, node: null, startX: 0, startY: 0, currentX: 0, currentY: 0, dragThreshold: TOUCH_DRAG_THRESHOLD, ghostElement: null, ghostScaledWidth: 0, ghostScaledHeight: 0, }; panZoomInstance = null; containerElement = computed(() => { return this.container()?.nativeElement || null; }, ...(ngDevMode ? [{ debugName: "containerElement" }] : [])); /** * A computed property that returns the current scale of the org chart. * @returns {number} The current scale of the org chart. */ getScale = computed(() => this.scale(), ...(ngDevMode ? [{ debugName: "getScale" }] : [])); /** * A computed property that flattens the org chart nodes into a single array. * It recursively traverses the nodes and their children, returning a flat array of OrgChartNode<T>. * This is useful for operations that require a single list of all nodes, such as searching or displaying all nodes in a list. * @returns {OrgChartNode<T>[]} An array of all nodes in the org chart, flattened from the hierarchical structure. */ flattenedNodes = computed(() => { const nodes = this.nodes(); if (!nodes) return []; const flatten = (node) => { const children = node.children?.flatMap(flatten) || []; return [node, ...children]; }; return nodes ? flatten(nodes) : []; }, ...(ngDevMode ? [{ debugName: "flattenedNodes" }] : [])); setNodes = effect(() => { const data = this.data(); const initialCollapsed = this.initialCollapsed(); if (data) { this.nodes.set(mapNodesRecursively(data, initialCollapsed)); } }, ...(ngDevMode ? [{ debugName: "setNodes" }] : [])); ngAfterViewInit() { this.initiatePanZoom(); this.disableChildDragging(); } /** * Initializes the pan-zoom functionality for the org chart. * This method creates a new panZoom instance and sets it up with the container element. * It also ensures that any existing panZoom instance is disposed of before creating a new one. */ initiatePanZoom() { if (this.panZoomInstance) { this.panZoomInstance.dispose(); } const container = this.panZoomContainer()?.nativeElement; this.panZoomInstance = createPanZoom(container, { initialZoom: this.getFitScale(), initialX: container.offsetWidth / 2, initialY: container.offsetHeight / 2, enableTextSelection: false, minZoom: this.minZoom(), maxZoom: this.maxZoom(), zoomSpeed: this.zoomSpeed(), smoothScroll: true, zoomDoubleClickSpeed: this.zoomDoubleClickSpeed(), }); this.calculateScale(); this.panZoomInstance?.on('zoom', e => { this.calculateScale(); }); } /** * Zooms in of the org chart. * @param {Object} options - Options for zooming. * @param {number} [options.by=10] - The percentage to zoom in or out by. * @param {boolean} [options.relative=true] - Whether to zoom relative to the current zoom level. * If true, zooms in by a percentage of the current zoom level. * If false, zooms to an absolute scale. */ zoomIn({ by, relative } = { relative: true }) { this.zoom({ type: 'in', by, relative }); } /** * Zooms out of the org chart. * @param {Object} options - Options for zooming. * @param {number} [options.by=10] - The percentage to zoom in or out by. * @param {boolean} [options.relative=true] - Whether to zoom relative to the current zoom level. * If true, zooms out by a percentage of the current zoom level. * If false, zooms to an absolute scale. */ zoomOut({ by, relative } = { relative: true }) { this.zoom({ type: 'out', by, relative }); } /** * Highlights a specific node in the org chart and pans to it. * @param {string} nodeId - The ID of the node to highlight. */ highlightNode(nodeId) { this.toggleCollapseAll(false); setTimeout(() => { const nodeElement = this.#elementRef?.nativeElement.querySelector(`#${this.getNodeId(nodeId)}`); this.panZoomToNode({ nodeElement, }); }, 200); } /** * Pans the view of the org chart. * @param x The horizontal offset to pan to. * @param y The vertical offset to pan to. * @param smooth Whether to animate the panning. * @returns void */ pan(x, y, smooth) { const container = this.orgChartContainer()?.nativeElement; if (!container || !this.panZoomInstance) { return; } const containerRect = container.getBoundingClientRect(); const panZoomRect = this.panZoomContainer()?.nativeElement.getBoundingClientRect(); const transformedX = x - containerRect.x + panZoomRect.x; const transformedY = y - containerRect.y + panZoomRect.y; if (smooth) { this.panZoomInstance.smoothMoveTo(transformedX, transformedY); } else { this.panZoomInstance.moveTo(transformedX, transformedY); } } /** * Resets the pan position of the org chart to center it horizontally and vertically. * This method calculates the center position based on the container's dimensions * and the hosting element's dimensions, then moves the panZoom instance to that position. */ resetPan() { const container = this.panZoomContainer()?.nativeElement; if (!container || !this.panZoomInstance) { return; } const containerRect = container.getBoundingClientRect(); const hostingElement = this.#elementRef.nativeElement; const windowWidth = hostingElement.getBoundingClientRect().width; const windowHeight = hostingElement.getBoundingClientRect().height; let x = (-1 * containerRect.width) / 2 + windowWidth / 2; if (this.layout() === 'horizontal') { if (this.isRtl()) { x = windowWidth - containerRect.width; } else { x = 0; } } const y = (-1 * containerRect.height) / 2 + windowHeight / 2; this.panZoomInstance?.smoothMoveTo(x, y); } /** * Resets the zoom level of the org chart to fit the content within the container. * This method calculates the optimal scale to fit the content and applies it. * @param {number} [padding=20] - Optional padding around the content when calculating the fit scale. */ resetZoom(padding = 20) { if (!this.panZoomInstance) { return; } const container = this.panZoomContainer()?.nativeElement; if (!container) { return; } this.zoomOut({ by: this.getFitScale(padding) }); } /** * Resets both the pan position and zoom level of the org chart. * This method first resets the pan position, then resets the zoom level after a short delay. * @param {number} [padding=20] - Optional padding around the content when calculating the fit scale. */ resetPanAndZoom(padding = 20) { this.resetPan(); setTimeout(() => { this.resetZoom(padding); }, RESET_DELAY); } /** * Toggles the collapse state of all nodes in the org chart. * If `collapse` is provided, it will collapse or expand all nodes accordingly. * If not provided, it will toggle the current state of the root node. */ toggleCollapseAll(collapse) { const nodes = this.nodes(); if (nodes?.children?.length && this.collapsible()) { this.onToggleCollapse({ node: nodes, collapse }); } } /** * Toggles the collapse state of a specific node in the org chart. * If `collapse` is provided, it will collapse or expand the node accordingly. * If not provided, it will toggle the current state of the node. * @param {Object} options - Options for toggling collapse. * @param {OrgChartNode<T>} options.node - The node to toggle. * @param {boolean} [options.collapse] - Whether to collapse or expand the node. * @param {boolean} [options.highlightNode=false] - Whether to highlight the node after toggling. * @param {boolean} [options.playAnimation=false] - Whether to play animation when highlighting. */ onToggleCollapse({ node, collapse, highlightNode = false, playAnimation = false, }) { if (!this.collapsible()) { return; } const nodeId = node.id; const wasCollapsed = node.collapsed; const nodes = toggleNodeCollapse({ node: this.nodes(), targetNode: nodeId, collapse, }); this.nodes.set(nodes); this.panZoomInstance?.resume(); if (highlightNode) { setTimeout(() => { const nodeElement = this.#elementRef?.nativeElement.querySelector(`#${wasCollapsed ? this.getNodeChildrenId(nodeId) : this.getNodeId(nodeId)}`); this.panZoomToNode({ nodeElement, skipZoom: true, playAnimation, }); }, 200); // allow the DOM finish animation before highlighting } } zoom({ type, by = 10, relative, }) { const containerEl = this.panZoomContainer()?.nativeElement; const containerRect = containerEl.getBoundingClientRect(); const hostElement = this.#elementRef?.nativeElement; const hostElementRect = hostElement.getBoundingClientRect(); const { scale } = this.panZoomInstance?.getTransform() ?? { scale: 1, }; let centerX = containerRect.width / 2 + containerRect.x - hostElementRect.x; const centerY = containerRect.height / 2 + containerRect.y - hostElementRect.y; if (this.layout() === 'horizontal') { if (this.isRtl()) { centerX = containerRect.width + containerRect.x - hostElementRect.x; } else { centerX = 0; } } const newScale = relative ? type === 'in' ? scale * (1 + by / 100) : scale / (1 + by / 100) : by; this.panZoomInstance?.smoothZoomAbs(centerX, centerY, newScale); } panZoomToNode({ nodeElement, skipZoom, playAnimation = true, }) { const container = this.panZoomContainer()?.nativeElement; if (!container || !nodeElement || !this.panZoomInstance) { return; } const highlightedElements = container.querySelectorAll('.highlighted'); highlightedElements.forEach(el => { el.classList.remove('highlighted'); }); this.panZoomInstance?.pause(); this.panZoomInstance?.resume(); setTimeout(() => { const hostElementRect = this.#elementRef.nativeElement.getBoundingClientRect(); const nodeRect1 = nodeElement.getBoundingClientRect(); const clientX = nodeRect1.x - nodeRect1.width / 2 - hostElementRect.x; const clientY = nodeRect1.y - nodeRect1.height / 2 - hostElementRect.y; if (!skipZoom) { const dynamicZoom = this.calculateOptimalZoom(nodeElement); this.panZoomInstance?.smoothZoomAbs(clientX, clientY, dynamicZoom); } }, 10); setTimeout(() => { const containerRect = container.getBoundingClientRect(); const nodeRect = nodeElement.getBoundingClientRect(); const hostingElement = this.#elementRef.nativeElement; const windowWidth = hostingElement.getBoundingClientRect().width; const windowHeight = hostingElement.getBoundingClientRect().height; const transformedNodeX = -1 * (nodeRect.x - containerRect.x); const transformedNodeY = -1 * (nodeRect.y - containerRect.y); const windowCenterX = windowWidth / 2; const windowCenterY = windowHeight / 2; const x = transformedNodeX + windowCenterX - nodeRect.width / 2; const y = transformedNodeY + windowCenterY - nodeRect.height / 2; this.panZoomInstance?.smoothMoveTo(x, y); if (playAnimation) { nodeElement.classList.add('highlighted'); setTimeout(() => { nodeElement.classList.remove('highlighted'); }, 2300); } }, 200); // allow some time for the zoom to take effect } getNodeId(nodeId) { return `node-${nodeId}`; } getNodeChildrenId(nodeId) { return `node-children-${nodeId}`; } /** * Handles the drag start event for a node. * @param event - The drag event * @param node - The node being dragged */ onDragStart(event, node) { if (!this.draggable()) return; const canDrag = this.canDragNode(); if (canDrag && !canDrag(node)) { event.preventDefault(); return; } this.draggedNode.set(node); this.nodeDragStart.emit(node); this.panZoomInstance?.pause(); const target = event.target; const nodeContent = target.closest('.node-c