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
JavaScript
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