/** * Node Connection System * * Complete SVG-based node connection system with particle animations. * Handles SVG overlay creation, path generation, coordinate calculations, * connection lifecycle management, and automatic initialization from data attributes. * * @module NodeConnectionSystem * @version 2.0.0 */ (function(global) { 'use strict'; // ============================================================================ // CONSTANTS // ============================================================================ /** * Configuration object for connection styling and behavior * @typedef {Object} ConnectionConfig * @property {number} curveStrength - Bezier curve intensity (0-1) * @property {number} particleSpeed - Animation speed in pixels per frame * @property {number} particleCount - Number of particles per connection * @property {string} lineColor - Hex color for connection line * @property {number} lineWidth - Stroke width in pixels * @property {number} particleSize - Particle radius in pixels * @property {number} lineOpacity - Line transparency (0-1) * @property {string} particleColor - Hex color for particles */ /** * Position coordinates with element reference * @typedef {Object} NodePosition * @property {number} x - X coordinate * @property {number} y - Y coordinate * @property {HTMLElement} element - DOM element reference */ /** * Default configuration values * @constant {ConnectionConfig} */ const DEFAULT_CONFIG = { curveStrength: 0.5, particleSpeed: 0.25, particleCount: 3, lineColor: 'rgba(0,0,0,0.20)', lineBgColor: null, // Background color (wenn null, wird lineColor verwendet) lineFillColor: null, // Fill color für Scroll-Reveal (wenn null, wird lineColor verwendet) lineWidth: 2, particleSize: 6, lineOpacity: 1, particleColor: '#fff', particleRandom: false, scrollReveal: false, scrollStart: 0, scrollEnd: 100, scrollTriggerPosition: 50 // Position im Viewport wo Animation startet (0-100%, 50 = Mitte) }; /** * Maximum limits to prevent performance issues * @constant {Object} */ const LIMITS = { MAX_CONNECTIONS: 200, MAX_PARTICLES_PER_CONNECTION: 20, MAX_CURVE_STRENGTH: 1, MIN_CURVE_STRENGTH: 0, MAX_PARTICLE_SIZE: 50, MIN_PARTICLE_SIZE: 1, MAX_LINE_WIDTH: 20, MIN_LINE_WIDTH: 0.5 }; // ============================================================================ // HELPER FUNCTIONS // ============================================================================ /** * Safely parses a float attribute from a DOM element * * @param {HTMLElement|null} element - DOM element to read from * @param {string} attrName - Attribute name * @param {number} defaultValue - Fallback value * @param {number} [min=-Infinity] - Minimum allowed value * @param {number} [max=Infinity] - Maximum allowed value * @returns {number} Parsed and validated float value */ function parseFloatAttr(element, attrName, defaultValue, min = -Infinity, max = Infinity) { if (!element || !(element instanceof Element)) { console.warn(`parseFloatAttr: Invalid element for attribute "${attrName}"`); return defaultValue; } try { const value = element.getAttribute(attrName); if (value === null || value === undefined || value.trim() === '') { return defaultValue; } const parsed = parseFloat(value); if (!isFinite(parsed) || isNaN(parsed)) { console.warn( `Invalid numeric value for ${attrName}: "${value}", using default: ${defaultValue}` ); return defaultValue; } const clamped = Math.max(min, Math.min(max, parsed)); if (clamped !== parsed) { console.warn( `Value ${parsed} for ${attrName} clamped to range [${min}, ${max}]: ${clamped}` ); } return clamped; } catch (error) { console.error(`Error parsing float attribute ${attrName}:`, error); return defaultValue; } } /** * Safely parses an integer attribute from a DOM element * * @param {HTMLElement|null} element - DOM element to read from * @param {string} attrName - Attribute name * @param {number} defaultValue - Fallback value * @param {number} [min=-Infinity] - Minimum allowed value * @param {number} [max=Infinity] - Maximum allowed value * @returns {number} Parsed and validated integer value */ function parseIntAttr(element, attrName, defaultValue, min = -Infinity, max = Infinity) { const floatValue = parseFloatAttr(element, attrName, defaultValue, min, max); return Math.round(floatValue); } /** * Safely parses a string attribute from a DOM element * * @param {HTMLElement|null} element - DOM element to read from * @param {string} attrName - Attribute name * @param {string} defaultValue - Fallback value * @returns {string} Trimmed string value or default */ function parseStringAttr(element, attrName, defaultValue) { if (!element || !(element instanceof Element)) { console.warn(`parseStringAttr: Invalid element for attribute "${attrName}"`); return defaultValue; } try { const value = element.getAttribute(attrName); if (value === null || value === undefined) { return defaultValue; } return String(value).trim(); } catch (error) { console.error(`Error parsing string attribute ${attrName}:`, error); return defaultValue; } } /** * Safely parses a boolean attribute from a DOM element * * @param {HTMLElement|null} element - DOM element to read from * @param {string} attrName - Attribute name * @param {boolean} defaultValue - Fallback value * @returns {boolean} Boolean value or default */ function parseBoolAttr(element, attrName, defaultValue) { if (!element || !(element instanceof Element)) { console.warn(`parseBoolAttr: Invalid element for attribute "${attrName}"`); return defaultValue; } try { const value = element.getAttribute(attrName); if (value === null || value === undefined) { return defaultValue; } const strValue = String(value).toLowerCase().trim(); // Accept: "true", "1", "yes" as true if (strValue === 'true' || strValue === '1' || strValue === 'yes') { return true; } // Accept: "false", "0", "no", "" as false if (strValue === 'false' || strValue === '0' || strValue === 'no' || strValue === '') { return false; } return defaultValue; } catch (error) { console.warn(`Error parsing boolean attribute "${attrName}":`, error); return defaultValue; } } /** * Validates and sanitizes a color string * * @param {string} color - Color string to validate * @param {string} defaultColor - Fallback color * @returns {string} Valid color string */ function validateColor(color, defaultColor) { if (!color || typeof color !== 'string') { return defaultColor; } const trimmedColor = color.trim(); // Basic validation for hex colors if (/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(trimmedColor)) { return trimmedColor; } // Allow rgb/rgba format if (/^rgba?\(/.test(trimmedColor)) { return trimmedColor; } // Allow named colors (basic check) if (/^[a-zA-Z]+$/.test(trimmedColor)) { return trimmedColor; } console.warn(`Invalid color format: "${color}", using default: ${defaultColor}`); return defaultColor; } /** * Finds the global settings element (data-node-settings) * * @returns {HTMLElement|null} Settings element or null */ function findGlobalSettings() { try { return document.querySelector('[data-node-settings]'); } catch (error) { console.error('Error finding global settings:', error); return null; } } /** * Parses a single config attribute with fallback hierarchy: * 1. Local element attribute * 2. Global settings element attribute * 3. data-svg-canvas attribute (legacy) * 4. Default value * * @param {HTMLElement} element - Local element * @param {string} attrName - Attribute name * @param {Function} parseFunc - Parser function (parseFloatAttr, parseIntAttr, etc.) * @param {*} defaultValue - Default fallback value * @param {...*} args - Additional arguments for parser * @returns {*} Parsed value */ function parseConfigAttribute(element, attrName, parseFunc, defaultValue, ...args) { // 1. Check local element if (element && element.hasAttribute(attrName)) { return parseFunc(element, attrName, defaultValue, ...args); } // 2. Check global settings const globalSettings = findGlobalSettings(); if (globalSettings && globalSettings.hasAttribute(attrName)) { return parseFunc(globalSettings, attrName, defaultValue, ...args); } // 3. Check data-svg-canvas (legacy support) const svgCanvas = document.querySelector('[data-svg-canvas]'); if (svgCanvas && svgCanvas.hasAttribute(attrName)) { return parseFunc(svgCanvas, attrName, defaultValue, ...args); } // 4. Return default return defaultValue; } /** * Parses configuration from a DOM element's data attributes * Priority: Local element > data-node-settings > data-svg-canvas > defaults * * @param {HTMLElement|null} element - Element with data-* attributes * @returns {ConnectionConfig} Validated configuration object */ function parseConfigFromElement(element) { if (!element || !(element instanceof Element)) { console.warn('parseConfigFromElement: Invalid element, using defaults'); return { ...DEFAULT_CONFIG }; } try { return { curveStrength: parseConfigAttribute( element, 'data-curve-strength', parseFloatAttr, DEFAULT_CONFIG.curveStrength, LIMITS.MIN_CURVE_STRENGTH, LIMITS.MAX_CURVE_STRENGTH ), particleSpeed: parseConfigAttribute( element, 'data-particle-speed', parseFloatAttr, DEFAULT_CONFIG.particleSpeed, 0.1, 20 ), particleCount: parseConfigAttribute( element, 'data-particle-count', parseIntAttr, DEFAULT_CONFIG.particleCount, 0, LIMITS.MAX_PARTICLES_PER_CONNECTION ), lineColor: validateColor( parseConfigAttribute( element, 'data-line-color', parseStringAttr, DEFAULT_CONFIG.lineColor ), DEFAULT_CONFIG.lineColor ), lineBgColor: validateColor( parseConfigAttribute( element, 'data-line-bg-color', parseStringAttr, DEFAULT_CONFIG.lineBgColor || DEFAULT_CONFIG.lineColor ), DEFAULT_CONFIG.lineColor ), lineFillColor: validateColor( parseConfigAttribute( element, 'data-line-fill-color', parseStringAttr, DEFAULT_CONFIG.lineFillColor || DEFAULT_CONFIG.lineColor ), DEFAULT_CONFIG.lineColor ), lineWidth: parseConfigAttribute( element, 'data-line-width', parseFloatAttr, DEFAULT_CONFIG.lineWidth, LIMITS.MIN_LINE_WIDTH, LIMITS.MAX_LINE_WIDTH ), particleSize: parseConfigAttribute( element, 'data-particle-size', parseFloatAttr, DEFAULT_CONFIG.particleSize, LIMITS.MIN_PARTICLE_SIZE, LIMITS.MAX_PARTICLE_SIZE ), lineOpacity: parseConfigAttribute( element, 'data-line-opacity', parseFloatAttr, DEFAULT_CONFIG.lineOpacity, 0, 1 ), particleColor: validateColor( parseConfigAttribute( element, 'data-particle-color', parseStringAttr, DEFAULT_CONFIG.particleColor ), DEFAULT_CONFIG.particleColor ), particleRandom: parseConfigAttribute( element, 'data-particle-random', parseBoolAttr, DEFAULT_CONFIG.particleRandom ), scrollReveal: parseConfigAttribute( element, 'data-scroll-reveal', parseBoolAttr, DEFAULT_CONFIG.scrollReveal ), scrollStart: parseConfigAttribute( element, 'data-scroll-start', parseFloatAttr, DEFAULT_CONFIG.scrollStart, 0, 100 ), scrollEnd: parseConfigAttribute( element, 'data-scroll-end', parseFloatAttr, DEFAULT_CONFIG.scrollEnd, 0, 100 ), scrollTriggerPosition: parseConfigAttribute( element, 'data-scroll-trigger-position', parseFloatAttr, DEFAULT_CONFIG.scrollTriggerPosition, 0, 100 ) }; } catch (error) { console.error('Error parsing configuration from element:', error); return { ...DEFAULT_CONFIG }; } } /** * Gets the center position of a node element by its ID * (Wrapper for getElementPosition for backwards compatibility) * * @param {string} nodeId - Node identifier from data-node-id * @param {SVGSVGElement} svgContainer - SVG container for coordinate calculation * @returns {NodePosition|null} Position object or null if not found */ function getNodePosition(nodeId, svgContainer = null) { return getElementPosition(nodeId, svgContainer); } /** * Creates a straight path through multiple points * * @param {NodePosition[]} points - Array of position objects * @returns {string|null} SVG path data or null if invalid */ function createMultiPointPath(points) { if (!Array.isArray(points) || points.length < 2) { console.warn('createMultiPointPath: Need at least 2 points'); return null; } try { const round = (num) => Math.round(num * 100) / 100; // Validate all points for (const point of points) { if (!point || typeof point.x !== 'number' || typeof point.y !== 'number') { console.warn('createMultiPointPath: Invalid point in array'); return null; } if (!isFinite(point.x) || !isFinite(point.y)) { console.warn('createMultiPointPath: Non-finite coordinates'); return null; } } // Build path: M x1 y1 L x2 y2 L x3 y3 ... let pathData = `M ${round(points[0].x)} ${round(points[0].y)}`; for (let i = 1; i < points.length; i++) { pathData += ` L ${round(points[i].x)} ${round(points[i].y)}`; } return pathData; } catch (error) { console.error('Error creating multi-point path:', error); return null; } } /** * Creates a Bezier curve path between two points * * @param {NodePosition} from - Starting position * @param {NodePosition} to - Ending position * @param {number} [strength=0.5] - Curve intensity (0-1) * @returns {string|null} SVG path data or null if invalid */ function createBezierPath(from, to, strength = 0.5) { // Validate input coordinates if (!from || !to || typeof from.x !== 'number' || typeof from.y !== 'number' || typeof to.x !== 'number' || typeof to.y !== 'number') { console.warn('createBezierPath: Invalid coordinate objects'); return null; } // Check for finite values if (!isFinite(from.x) || !isFinite(from.y) || !isFinite(to.x) || !isFinite(to.y)) { console.warn('createBezierPath: Non-finite coordinate values'); return null; } try { // Round to 2 decimal places to reduce path string length const round = (num) => Math.round(num * 100) / 100; // Return straight line (no curves) return `M ${round(from.x)} ${round(from.y)} L ${round(to.x)} ${round(to.y)}`; } catch (error) { console.error('Error creating path:', error); return null; } } /** * Parses gap value with unit (e.g., "20px", "2rem", "10vw") * Returns value in pixels * * @param {string} gapStr - Gap string with unit * @returns {number} Gap value in pixels */ function parseGapValue(gapStr) { if (!gapStr || typeof gapStr !== 'string') { return 0; } const trimmed = gapStr.trim(); const match = trimmed.match(/^([\d.]+)(px|rem|%|vw|vh)?$/); if (!match) { console.warn(`Invalid gap format: "${gapStr}"`); return 0; } const value = parseFloat(match[1]); const unit = match[2] || 'px'; // Convert to pixels switch (unit) { case 'px': return value; case 'rem': return value * parseFloat(getComputedStyle(document.documentElement).fontSize); case 'vw': return (value / 100) * window.innerWidth; case 'vh': return (value / 100) * window.innerHeight; case '%': console.warn('Percentage gap requires parent context, treating as px'); return value; default: return value; } } /** * Finds a node by ID anywhere in the DOM * Supports data-node-id, regular id, and data-control-point * * @param {string} nodeId - Node identifier * @returns {HTMLElement|null} Found element or null */ function findNodeById(nodeId) { if (!nodeId || typeof nodeId !== 'string') { return null; } try { const escapedId = CSS?.escape ? CSS.escape(nodeId) : nodeId.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&'); // Try data-node-id first let element = document.querySelector(`[data-node-id="${escapedId}"]`); if (element) return element; // Try regular id element = document.getElementById(nodeId); if (element) return element; // Try data-control-point with id element = document.querySelector(`[data-control-point][id="${escapedId}"]`); if (element) return element; return null; } catch (error) { console.error(`Error finding node "${nodeId}":`, error); return null; } } /** * Processes data-duplicate attributes to create visual duplicates of connections * Does NOT clone DOM elements - only creates offset connections for visual duplication * Requires data-node-chain to be present * @param {NodeConnectionSystem} nodeSystem - The node connection system instance */ function processDuplicates(nodeSystem) { try { // Find all elements with both data-duplicate and data-node-chain const duplicateContainers = document.querySelectorAll('[data-duplicate][data-node-chain]'); if (duplicateContainers.length === 0) { console.log('[NodeConnectionSystem] No elements with data-duplicate and data-node-chain found'); return; } console.log(`[NodeConnectionSystem] Found ${duplicateContainers.length} elements with data-duplicate`); duplicateContainers.forEach(container => { try { // Read duplicate settings const direction = parseStringAttr(container, 'data-duplicate', 'horizontal').toLowerCase(); const count = parseIntAttr(container, 'data-duplicate-count', 1, 1, 50); const gapStr = parseStringAttr(container, 'data-duplicate-gap', '20px'); const gap = parseGapValue(gapStr); if (direction !== 'horizontal' && direction !== 'vertical') { console.warn(`[NodeConnectionSystem] Invalid data-duplicate direction: "${direction}", skipping`); return; } // Parse chain IDs const chainStr = container.getAttribute('data-node-chain'); const chain = chainStr.split(',').map(id => id.trim()).filter(id => id); if (chain.length < 2) { console.warn(`[NodeConnectionSystem] data-node-chain must have at least 2 nodes for duplication`); return; } // Get configuration from container attributes const config = parseConfigFromElement(container); console.log(`[NodeConnectionSystem] Creating ${count} visual duplicates: direction=${direction}, gap=${gap}px`); // Calculate centered distribution // Original stays at offset (0, 0) // Duplicates are distributed symmetrically on both sides const halfCount = Math.floor(count / 2); const rightCount = count - halfCount; // Create left side duplicates (negative offsets) for (let i = halfCount; i >= 1; i--) { const offset = direction === 'horizontal' ? { x: -gap * i, y: 0 } : { x: 0, y: -gap * i }; console.log(`[NodeConnectionSystem] Creating duplicate -${i}: offset=(${offset.x}, ${offset.y})`); const connection = nodeSystem.createConnection(chain, null, config, offset); if (connection) { console.log(`[NodeConnectionSystem] Success: visual duplicate -${i} of chain ${chain.join(' -> ')}`); } else { console.warn(`[NodeConnectionSystem] Failed: visual duplicate -${i} of chain ${chain.join(' -> ')}`); } } // Create right side duplicates (positive offsets) for (let i = 1; i <= rightCount; i++) { const offset = direction === 'horizontal' ? { x: gap * i, y: 0 } : { x: 0, y: gap * i }; console.log(`[NodeConnectionSystem] Creating duplicate +${i}: offset=(${offset.x}, ${offset.y})`); const connection = nodeSystem.createConnection(chain, null, config, offset); if (connection) { console.log(`[NodeConnectionSystem] Success: visual duplicate +${i} of chain ${chain.join(' -> ')}`); } else { console.warn(`[NodeConnectionSystem] Failed: visual duplicate +${i} of chain ${chain.join(' -> ')}`); } } } catch (error) { console.error('[NodeConnectionSystem] Error processing duplicate container:', error); } }); } catch (error) { console.error('[NodeConnectionSystem] Error in processDuplicates:', error); } } /** * Finds the container where SVG overlay should be rendered * * @returns {Object} Object with element and mode ('fixed' or 'absolute') */ function findSVGContainer() { try { // Check for custom container with data-svg-canvas attribute const customContainer = document.querySelector('[data-svg-canvas]'); if (customContainer) { console.log('[NodeConnectionSystem] Using custom SVG canvas container'); return { element: customContainer, mode: 'absolute' }; } // Default: use body with fixed positioning return { element: document.body, mode: 'fixed' }; } catch (error) { console.error('[NodeConnectionSystem] Error finding SVG container:', error); return { element: document.body, mode: 'fixed' }; } } /** * Finds the container where control points should be searched * First checks for data-svg-canvas, then falls back to entire document * * @returns {Element} Container element for control point search */ function findControlPointContainer() { try { const customContainer = document.querySelector('[data-svg-canvas]'); if (customContainer) { console.log('[NodeConnectionSystem] Searching for control points in data-svg-canvas container'); return customContainer; } console.log('[NodeConnectionSystem] Searching for control points in entire document'); return document.body; } catch (error) { console.error('[NodeConnectionSystem] Error finding control point container:', error); return document.body; } } /** * Gets the center position of an element (node or control point) by its ID * Supports both data-node-id and regular id attributes, as well as data-control-point * * @param {string} elementId - Element identifier * @param {SVGSVGElement} svgContainer - SVG container for coordinate calculation * @returns {NodePosition|null} Position object or null if not found */ function getElementPosition(elementId, svgContainer = null) { if (!elementId || typeof elementId !== 'string' || elementId.trim() === '') { console.warn('getElementPosition: Invalid elementId provided'); return null; } try { // Escape CSS special characters in selector const escapedId = CSS?.escape ? CSS.escape(elementId) : elementId.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&'); // Try to find element by data-node-id, regular id, or data-control-point with id let element = document.querySelector(`[data-node-id="${escapedId}"]`); if (!element) { element = document.getElementById(elementId); } if (!element) { element = document.querySelector(`[data-control-point][id="${escapedId}"]`); } if (!element) { console.warn(`Element not found for ID: "${elementId}"`); return null; } if (!(element instanceof HTMLElement)) { console.warn(`Element with ID "${elementId}" is not an HTMLElement`); return null; } const rect = element.getBoundingClientRect(); // Validate that we got valid dimensions if (!rect || !isFinite(rect.left) || !isFinite(rect.top) || !isFinite(rect.width) || !isFinite(rect.height)) { console.warn(`Invalid bounding rect for element "${elementId}"`); return null; } // Calculate position based on SVG positioning mode let x = rect.left + rect.width / 2; let y = rect.top + rect.height / 2; // If SVG container is provided and uses absolute positioning if (svgContainer && svgContainer.dataset.positionMode === 'absolute') { const containerElement = svgContainer.parentElement; if (containerElement) { const containerRect = containerElement.getBoundingClientRect(); x = x - containerRect.left; y = y - containerRect.top; } } return { x: x, y: y, element: element }; } catch (error) { console.error(`Error getting element position for "${elementId}":`, error); return null; } } /** * Creates or retrieves the SVG overlay container * * @returns {SVGSVGElement|null} SVG container element or null if creation failed */ function getOrCreateSVGOverlay() { const OVERLAY_ID = 'node-connection-svg-overlay'; try { // Check if overlay already exists let svg = document.getElementById(OVERLAY_ID); if (svg) { if (!(svg instanceof SVGSVGElement)) { console.error('Element with overlay ID exists but is not an SVG element'); return null; } return svg; } // Check SVG support if (!document.createElementNS) { console.error('Browser does not support createElementNS'); return null; } // Create new SVG overlay svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); if (!(svg instanceof SVGSVGElement)) { console.error('Failed to create SVG element'); return null; } svg.id = OVERLAY_ID; // Find target container const container = findSVGContainer(); if (!container.element) { console.error('No valid container element found'); return null; } // Set SVG style based on mode if (container.mode === 'fixed') { svg.setAttribute('style', ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 2; overflow: visible; `); } else { // Absolute mode - position relative to container svg.setAttribute('style', ` position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 2; overflow: visible; `); // Ensure container has position: relative const containerStyle = window.getComputedStyle(container.element); if (containerStyle.position === 'static') { console.warn('[NodeConnectionSystem] SVG canvas container needs position: relative, setting automatically'); container.element.style.position = 'relative'; } } // Store mode on SVG element for later reference svg.dataset.positionMode = container.mode; container.element.appendChild(svg); return svg; } catch (error) { console.error('Error creating SVG overlay:', error); return null; } } /** * Creates a throttled version of a function * @param {Function} func - Function to throttle * @param {number} limit - Minimum time between calls in milliseconds * @returns {Function} Throttled function */ function throttle(func, limit) { let inThrottle = false; let lastResult; return function(...args) { if (!inThrottle) { lastResult = func.apply(this, args); inThrottle = true; setTimeout(() => { inThrottle = false; }, limit); } return lastResult; }; } // ============================================================================ // CONNECTION CLASS // ============================================================================ /** * Connection class manages lifecycle of a single connection */ class Connection { /** * Creates a new connection instance * Can handle simple 2-point connections or multi-point chains * * @param {string|string[]} fromIdOrChain - Source node ID or array of IDs for chain * @param {string} toId - Target node ID (ignored if fromIdOrChain is array) * @param {ConnectionConfig} config - Connection configuration * @param {SVGSVGElement} svgContainer - SVG container element * @param {{x: number, y: number}} offset - Position offset for visual duplication (default {x: 0, y: 0}) */ constructor(fromIdOrChain, toId, config, svgContainer, offset = {x: 0, y: 0}) { // Handle both single connection and chain if (Array.isArray(fromIdOrChain)) { // Chain mode if (fromIdOrChain.length < 2) { throw new TypeError('Connection: chain must have at least 2 elements'); } this.chain = fromIdOrChain; this.fromId = fromIdOrChain[0]; this.toId = fromIdOrChain[fromIdOrChain.length - 1]; } else { // Simple mode if (!fromIdOrChain || typeof fromIdOrChain !== 'string') { throw new TypeError('Connection: fromId must be a non-empty string'); } if (!toId || typeof toId !== 'string') { throw new TypeError('Connection: toId must be a non-empty string'); } this.chain = [fromIdOrChain, toId]; this.fromId = fromIdOrChain; this.toId = toId; } if (!config || typeof config !== 'object') { throw new TypeError('Connection: config must be an object'); } if (!svgContainer || !(svgContainer instanceof SVGSVGElement)) { throw new TypeError('Connection: svgContainer must be an SVGSVGElement'); } /** @type {string[]} */ this.chain = this.chain; /** @type {string} */ this.fromId = this.fromId; /** @type {string} */ this.toId = this.toId; /** @type {ConnectionConfig} */ this.config = { ...config }; /** @type {SVGSVGElement} */ this.svgContainer = svgContainer; /** @type {{x: number, y: number}} */ this.offset = offset || {x: 0, y: 0}; /** @type {SVGPathElement|null} */ this.pathElement = null; /** @type {SVGPathElement|null} */ this.bgPathElement = null; /** @type {SVGCircleElement[]} */ this.particles = []; /** @type {boolean} */ this.isDestroyed = false; /** @type {number|null} */ this.animationFrameId = null; /** @type {number[]} */ this.particleOffsets = []; /** @type {number[]} */ this.particleSpeeds = []; /** @type {number|null} */ this.pathLength = null; /** @type {number} */ this.scrollProgress = 0; /** @type {Array<{distance: number, y: number}>|null} */ this.cachedPathPoints = null; this._initialize(); } /** * Initializes the connection by creating path and particles * @private */ _initialize() { try { this._createPath(); this._createParticles(); } catch (error) { console.error(`Error initializing connection ${this.fromId} -> ${this.toId}:`, error); this.destroy(); } } /** * Creates the SVG path element for the connection * Handles both simple 2-point and multi-point chain connections * @private */ _createPath() { if (this.isDestroyed) return; try { // Get positions for all points in the chain const positions = []; for (const elementId of this.chain) { const pos = getElementPosition(elementId, this.svgContainer); if (!pos) { throw new Error(`Could not get position for element "${elementId}"`); } // Apply offset for visual duplication positions.push({ x: pos.x + this.offset.x, y: pos.y + this.offset.y, element: pos.element }); } if (positions.length < 2) { throw new Error('Need at least 2 positions for path'); } // Create path data based on number of points let pathData; if (positions.length === 2) { // Simple 2-point connection pathData = createBezierPath(positions[0], positions[1], this.config.curveStrength); } else { // Multi-point chain pathData = createMultiPointPath(positions); } if (!pathData) { throw new Error('Could not create path data'); } // Setup scroll reveal with two paths (background + fill) if (this.config.scrollReveal) { try { // 1. Create background path (always visible) const bgPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); if (!(bgPath instanceof SVGPathElement)) { throw new Error('Failed to create background SVG path element'); } bgPath.setAttribute('d', pathData); bgPath.setAttribute('stroke', this.config.lineBgColor || this.config.lineColor); bgPath.setAttribute('stroke-width', String(this.config.lineWidth)); bgPath.setAttribute('stroke-opacity', String(this.config.lineOpacity)); bgPath.setAttribute('fill', 'none'); bgPath.setAttribute('stroke-linecap', 'round'); // GPU optimization: background path doesn't need GPU acceleration bgPath.style.willChange = 'auto'; this.svgContainer.appendChild(bgPath); this.bgPathElement = bgPath; // 2. Create fill path (animated with scroll) const fillPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); if (!(fillPath instanceof SVGPathElement)) { throw new Error('Failed to create fill SVG path element'); } fillPath.setAttribute('d', pathData); fillPath.setAttribute('stroke', this.config.lineFillColor || this.config.lineColor); fillPath.setAttribute('stroke-width', String(this.config.lineWidth)); fillPath.setAttribute('stroke-opacity', String(this.config.lineOpacity)); fillPath.setAttribute('fill', 'none'); fillPath.setAttribute('stroke-linecap', 'round'); // GPU optimization: Force GPU layer for animated path fillPath.style.willChange = 'stroke-dashoffset'; fillPath.style.transform = 'translateZ(0)'; // GPU layer hack this.svgContainer.appendChild(fillPath); this.pathElement = fillPath; // Setup stroke-dasharray for scroll animation on fill path this.pathLength = fillPath.getTotalLength(); if (this.pathLength && isFinite(this.pathLength)) { fillPath.style.strokeDasharray = String(this.pathLength); fillPath.style.strokeDashoffset = String(this.pathLength); // Cache path points for performance this._cachePathPoints(); // Initial scroll check this.updateScrollProgress(); } else { console.warn(`Invalid path length for scroll reveal: ${this.pathLength}`); } } catch (error) { console.warn(`Could not setup scroll reveal:`, error); } } else { // Normal path without scroll reveal const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); if (!(path instanceof SVGPathElement)) { throw new Error('Failed to create SVG path element'); } path.setAttribute('d', pathData); path.setAttribute('stroke', this.config.lineColor); path.setAttribute('stroke-width', String(this.config.lineWidth)); path.setAttribute('stroke-opacity', String(this.config.lineOpacity)); path.setAttribute('fill', 'none'); path.setAttribute('stroke-linecap', 'round'); // Minimal GPU optimization for static path path.style.transform = 'translateZ(0)'; this.svgContainer.appendChild(path); this.pathElement = path; } } catch (error) { console.error(`Error creating path for connection ${this.fromId} -> ${this.toId}:`, error); throw error; } } /** * Creates particle elements for animation * @private */ _createParticles() { if (this.isDestroyed || !this.pathElement) return; try { const count = Math.max(0, Math.min(this.config.particleCount, LIMITS.MAX_PARTICLES_PER_CONNECTION)); for (let i = 0; i < count; i++) { const particle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); if (!(particle instanceof SVGCircleElement)) { console.warn('Failed to create particle circle element'); continue; } particle.setAttribute('r', String(this.config.particleSize)); particle.setAttribute('fill', this.config.particleColor); particle.setAttribute('opacity', '0.8'); // Initialize particle offset and speed if (this.config.particleRandom) { // Random start position (0-100%) this.particleOffsets.push(Math.random() * 100); // Random speed variation (70% - 130% of base speed) const speedVariation = 0.7 + Math.random() * 0.6; this.particleSpeeds.push(this.config.particleSpeed * speedVariation); } else { // Uniform distribution this.particleOffsets.push((i / count) * 100); this.particleSpeeds.push(this.config.particleSpeed); } this.svgContainer.appendChild(particle); this.particles.push(particle); } } catch (error) { console.error(`Error creating particles for connection ${this.fromId} -> ${this.toId}:`, error); } } /** * Updates the connection path based on current element positions * Handles both simple 2-point and multi-point chain connections * * @returns {boolean} True if update successful, false otherwise */ update() { if (this.isDestroyed || !this.pathElement) { return false; } try { // Get positions for all points in the chain const positions = []; for (const elementId of this.chain) { const pos = getElementPosition(elementId, this.svgContainer); if (!pos) { console.warn(`Cannot update connection ${this.fromId} -> ${this.toId}: element "${elementId}" not found`); return false; } // Apply offset for visual duplication positions.push({ x: pos.x + this.offset.x, y: pos.y + this.offset.y, element: pos.element }); } if (positions.length < 2) { console.warn(`Cannot update connection ${this.fromId} -> ${this.toId}: insufficient positions`); return false; } // Create path data based on number of points let pathData; if (positions.length === 2) { // Simple 2-point connection pathData = createBezierPath(positions[0], positions[1], this.config.curveStrength); } else { // Multi-point chain pathData = createMultiPointPath(positions); } if (!pathData) { console.warn(`Cannot update connection ${this.fromId} -> ${this.toId}: invalid path data`); return false; } // Update fill path this.pathElement.setAttribute('d', pathData); // Update background path if exists (for scroll-reveal) if (this.bgPathElement) { this.bgPathElement.setAttribute('d', pathData); } // Update path length for scroll animation if (this.config.scrollReveal && this.pathElement) { try { this.pathLength = this.pathElement.getTotalLength(); if (this.pathLength && isFinite(this.pathLength)) { this.pathElement.style.strokeDasharray = String(this.pathLength); // Maintain current progress const currentOffset = this.pathElement.style.strokeDashoffset; if (currentOffset) { this.pathElement.style.strokeDashoffset = currentOffset; } // Update cached path points after path change this._cachePathPoints(); } } catch (error) { console.warn('Error updating path length:', error); } } return true; } catch (error) { console.error(`Error updating connection ${this.fromId} -> ${this.toId}:`, error); return false; } } /** * Animates particles along the path * * @param {number} deltaTime - Time elapsed since last frame (ms) */ animateParticles(deltaTime) { if (this.isDestroyed || !this.pathElement || this.particles.length === 0) { return; } try { const pathLength = this.pathElement.getTotalLength(); if (!isFinite(pathLength) || pathLength <= 0) { return; } const deltaFactor = deltaTime / 16.67; // Normalize to 60fps this.particles.forEach((particle, index) => { if (!particle || this.isDestroyed) return; try { // Use per-particle speed from particleSpeeds array const speed = this.particleSpeeds[index] * deltaFactor; // Update particle offset this.particleOffsets[index] = (this.particleOffsets[index] + speed) % 100; // Get point on path const distance = (this.particleOffsets[index] / 100) * pathLength; const point = this.pathElement.getPointAtLength(distance); if (point && isFinite(point.x) && isFinite(point.y)) { particle.setAttribute('cx', String(point.x)); particle.setAttribute('cy', String(point.y)); } } catch (error) { console.warn(`Error animating particle ${index}:`, error); } }); } catch (error) { console.error(`Error in animateParticles for ${this.fromId} -> ${this.toId}:`, error); } } /** * Updates scroll-based line drawing animation * Progress is based on path position at trigger line (pixel-perfect) * @param {DOMRect} [svgRect] - Optional pre-calculated SVG bounding rect for performance */ updateScrollProgress(svgRect = null) { if (!this.config.scrollReveal || !this.pathElement || !this.pathLength) { return; } try { // Get viewport dimensions const viewportHeight = window.innerHeight; // Try to get scroll from Lenis if available (reduces lag) let scrollY; if (window.lenis && window.lenis.scroll !== undefined) { scrollY = window.lenis.scroll; // Use Lenis's smooth scroll position } else { scrollY = window.pageYOffset || document.documentElement.scrollTop; } // Calculate trigger line position in viewport (0 = top, 50 = middle, 100 = bottom) const triggerY = viewportHeight * (this.config.scrollTriggerPosition / 100); // Get SVG bounding box (use provided rect or calculate) if (!svgRect) { svgRect = this.svgContainer.getBoundingClientRect(); } // Find the point on the path that's at triggerY using binary search const pathPositionAtTrigger = this._findPathPositionAtY(triggerY, svgRect); // Calculate progress as ratio of path length let progress = 0; if (pathPositionAtTrigger !== null) { progress = pathPositionAtTrigger / this.pathLength; } // Clamp progress between 0 and 1 progress = Math.max(0, Math.min(1, progress)); // === DEBUGGING: Path Progress === // this._debugPathProgress(triggerY, pathPositionAtTrigger, progress); // Update stroke-dashoffset for line drawing effect const offset = this.pathLength * (1 - progress); this.pathElement.style.strokeDashoffset = String(offset); this.scrollProgress = progress; } catch (error) { console.warn(`Error updating scroll progress for ${this.fromId} -> ${this.toId}:`, error); } } /** * Caches path points for performance optimization * Pre-calculates points along the path to avoid expensive DOM calls during scroll * @private */ _cachePathPoints() { if (!this.pathElement || !this.pathLength) { return; } try { const CACHE_RESOLUTION = 100; // Number of points to cache this.cachedPathPoints = []; for (let i = 0; i <= CACHE_RESOLUTION; i++) { const distance = (i / CACHE_RESOLUTION) * this.pathLength; const point = this.pathElement.getPointAtLength(distance); this.cachedPathPoints.push({ distance: distance, y: point.y }); } } catch (error) { console.warn('Error caching path points:', error); this.cachedPathPoints = null; } } /** * Finds the position on the path where Y coordinate matches targetY * Uses cached points and linear interpolation for performance * @private */ _findPathPositionAtY(targetY, svgRect) { // Use cached points if available (performance optimization) if (this.cachedPathPoints && this.cachedPathPoints.length > 1) { return this._findPathPositionFromCache(targetY, svgRect); } // Fallback to direct calculation if no cache if (!this.pathElement || !this.pathLength) { return 0; } try { // Simple check of start and end points const startPoint = this.pathElement.getPointAtLength(0); const endPoint = this.pathElement.getPointAtLength(this.pathLength); const startY = svgRect.top + startPoint.y; const endY = svgRect.top + endPoint.y; if (targetY <= startY) return 0; if (targetY >= endY) return this.pathLength; // Linear approximation for fallback const ratio = (targetY - startY) / (endY - startY); return this.pathLength * ratio; } catch (error) { console.warn('Error in _findPathPositionAtY:', error); return 0; } } /** * Finds path position using cached points with linear interpolation * Much faster than binary search with getPointAtLength * @private */ _findPathPositionFromCache(targetY, svgRect) { const points = this.cachedPathPoints; // Check bounds const firstY = svgRect.top + points[0].y; const lastY = svgRect.top + points[points.length - 1].y; if (targetY <= firstY) return 0; if (targetY >= lastY) return this.pathLength; // Find the two cached points that surround targetY for (let i = 0; i < points.length - 1; i++) { const p1 = points[i]; const p2 = points[i + 1]; const y1 = svgRect.top + p1.y; const y2 = svgRect.top + p2.y; // Check if targetY is between these two points if ((y1 <= targetY && targetY <= y2) || (y2 <= targetY && targetY <= y1)) { // Linear interpolation between the two points const t = (y2 !== y1) ? (targetY - y1) / (y2 - y1) : 0; return p1.distance + t * (p2.distance - p1.distance); } } // If we didn't find it, return closest endpoint return targetY < svgRect.top ? 0 : this.pathLength; } /** * Debug helper: Logs path-based progress to console * @private */ _debugPathProgress(triggerY, pathPosition, totalProgress) { const progressPercent = Math.round(totalProgress * 100); // Only log when progress changes by at least 5% const logKey = Math.floor(progressPercent / 5); if (!this._lastLoggedProgress || this._lastLoggedProgress !== logKey) { console.log('%c🎯 PATH PROGRESS', 'color: #10b981; font-weight: bold'); console.log(` Trigger Y: ${Math.round(triggerY)}px from viewport top`); console.log(` Path Position: ${Math.round(pathPosition)}px of ${Math.round(this.pathLength)}px`); console.log(` Total Progress: ${progressPercent}%`); console.log(''); this._lastLoggedProgress = logKey; } } /** * Debug helper: Logs node progress to console (simplified) * @private */ _debugNodeProgress(viewportHeight, scrollY, triggerY, totalProgress) { if (!this.chain || this.chain.length < 2) return; try { const chainId = this.chain.join(' → '); // Check each node's position relative to viewport center this.chain.forEach((nodeId, index) => { const nodePos = getElementPosition(nodeId, this.svgContainer); if (!nodePos) return; const nodeRect = nodePos.element.getBoundingClientRect(); const nodeTopInViewport = nodeRect.top; const nodeCenterInViewport = nodeTopInViewport + (nodeRect.height / 2); // Check if node is at trigger position (±10px tolerance) const distanceFromTrigger = Math.abs(nodeCenterInViewport - triggerY); if (distanceFromTrigger < 10) { // Node reached trigger position const logKey = `${nodeId}_${Math.round(totalProgress * 100)}`; if (!this._lastTriggeredNode || this._lastTriggeredNode !== logKey) { console.log(`%c🎯 NODE REACHED TRIGGER`, 'color: #10b981; font-weight: bold; font-size: 14px'); console.log(` Chain: ${chainId}`); console.log(` Node: ${nodeId} (${index + 1}/${this.chain.length})`); console.log(` Trigger Position: ${this.config.scrollTriggerPosition}% of viewport`); console.log(` Total Progress: ${Math.round(totalProgress * 100)}%`); console.log(''); this._lastTriggeredNode = logKey; } } }); // Calculate progress between nodes if (this.chain.length > 1 && totalProgress > 0 && totalProgress < 1) { const segmentCount = this.chain.length - 1; const progressPerSegment = 1 / segmentCount; const currentSegmentIndex = Math.floor(totalProgress / progressPerSegment); const progressInSegment = (totalProgress % progressPerSegment) / progressPerSegment; if (currentSegmentIndex < segmentCount) { const fromNode = this.chain[currentSegmentIndex]; const toNode = this.chain[currentSegmentIndex + 1]; const segmentPercent = Math.round(progressInSegment * 100); // Only log when progress changes significantly (every 10%) const logKey = `${currentSegmentIndex}_${Math.floor(segmentPercent / 10)}`; if (!this._lastLoggedSegment || this._lastLoggedSegment !== logKey) { console.log(`%c📊 SEGMENT PROGRESS`, 'color: #3b82f6; font-weight: bold'); console.log(` ${fromNode} → ${toNode}: ${segmentPercent}%`); console.log(` Segment ${currentSegmentIndex + 1}/${segmentCount}`); console.log(` Total Chain Progress: ${Math.round(totalProgress * 100)}%`); console.log(''); this._lastLoggedSegment = logKey; } } } // Reset at start/end if (totalProgress === 0) { this._lastTriggeredNode = null; this._lastLoggedSegment = null; } } catch (error) { // Silent fail for debugging } } /** * Cleans up connection resources */ destroy() { if (this.isDestroyed) return; this.isDestroyed = true; try { // Cancel animation frame if active if (this.animationFrameId !== null) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; } // Remove path elements if (this.pathElement) { try { if (this.pathElement.parentNode) { this.pathElement.parentNode.removeChild(this.pathElement); } } catch (error) { console.warn('Error removing path element:', error); } this.pathElement = null; } // Remove background path element if exists if (this.bgPathElement) { try { if (this.bgPathElement.parentNode) { this.bgPathElement.parentNode.removeChild(this.bgPathElement); } } catch (error) { console.warn('Error removing background path element:', error); } this.bgPathElement = null; } // Remove particle elements this.particles.forEach((particle, index) => { try { if (particle && particle.parentNode) { particle.parentNode.removeChild(particle); } } catch (error) { console.warn(`Error removing particle ${index}:`, error); } }); // Clear arrays this.particles = []; this.particleOffsets = []; } catch (error) { console.error(`Error destroying connection ${this.fromId} -> ${this.toId}:`, error); } } /** * Checks if connection is valid and not destroyed * * @returns {boolean} True if connection is valid */ isValid() { return !this.isDestroyed && this.pathElement !== null && this.pathElement.parentNode !== null; } } // ============================================================================ // CONNECTION SYSTEM CLASS // ============================================================================ /** * Main connection system class */ class ConnectionSystem { constructor() { /** @type {Map} */ this.connections = new Map(); /** @type {SVGSVGElement|null} */ this.svgContainer = null; /** @type {ResizeObserver|null} */ this.resizeObserver = null; /** @type {MutationObserver|null} */ this.mutationObserver = null; /** @type {number|null} */ this.animationFrameId = null; /** @type {boolean} */ this.isRunning = false; /** @type {number} */ this.lastFrameTime = 0; } /** * Initializes the system by scanning for data attributes * @returns {boolean} True if initialization successful */ init() { try { if (this.isRunning) { console.warn('[NodeConnectionSystem] System already initialized'); return false; } // Create SVG overlay this.svgContainer = getOrCreateSVGOverlay(); if (!this.svgContainer) { console.error('[NodeConnectionSystem] Failed to create SVG overlay'); return false; } // Find all connector elements (both simple and chain) const simpleConnectors = document.querySelectorAll('[data-connect-to]:not([data-node-chain])'); const chainConnectors = document.querySelectorAll('[data-node-chain]'); if (simpleConnectors.length === 0 && chainConnectors.length === 0) { console.warn('[NodeConnectionSystem] No elements found with data-connect-to or data-node-chain attributes'); return false; } console.log(`[NodeConnectionSystem] Found ${simpleConnectors.length} simple connectors and ${chainConnectors.length} chain connectors`); let connectionCount = 0; // Process simple connections (data-connect-to without data-node-chain) simpleConnectors.forEach(element => { // Try to get element ID (data-node-id, id, or data-control-point with id) let fromId = parseStringAttr(element, 'data-node-id', ''); if (!fromId) { fromId = element.id || ''; } const connectToStr = parseStringAttr(element, 'data-connect-to', ''); if (!fromId || !connectToStr) { console.warn(`[NodeConnectionSystem] Skipping connector: fromId="${fromId}", connectTo="${connectToStr}"`); return; } // Parse comma-separated target IDs const targetIds = connectToStr.split(',').map(id => id.trim()).filter(id => id); if (targetIds.length === 0) { console.warn(`[NodeConnectionSystem] No valid target IDs for fromId="${fromId}"`); return; } // Get configuration from element const config = parseConfigFromElement(element); // Create simple connections to each target targetIds.forEach(toId => { console.log(`[NodeConnectionSystem] Attempting simple: ${fromId} -> ${toId}`); const connection = this.createConnection(fromId, toId, config); if (connection) { connectionCount++; console.log(`[NodeConnectionSystem] Success: ${fromId} -> ${toId}`); } else { console.warn(`[NodeConnectionSystem] Failed: ${fromId} -> ${toId}`); } }); }); // Process chain connections (data-node-chain) chainConnectors.forEach(element => { const chainStr = parseStringAttr(element, 'data-node-chain', ''); if (!chainStr) { console.warn(`[NodeConnectionSystem] Skipping chain connector: empty data-node-chain`); return; } // Parse comma-separated chain IDs const chain = chainStr.split(',').map(id => id.trim()).filter(id => id); if (chain.length < 2) { console.warn(`[NodeConnectionSystem] Chain must have at least 2 nodes: "${chainStr}"`); return; } // Get configuration from element const config = parseConfigFromElement(element); console.log(`[NodeConnectionSystem] Attempting chain: ${chain.join(' -> ')}`); const connection = this.createConnection(chain, null, config); if (connection) { connectionCount++; console.log(`[NodeConnectionSystem] Success: chain with ${chain.length} points`); } else { console.warn(`[NodeConnectionSystem] Failed: chain ${chain.join(' -> ')}`); } }); // Process visual duplicates (data-duplicate with data-node-chain) // This creates additional connections with position offsets processDuplicates(this); console.log(`[NodeConnectionSystem] Created ${connectionCount} connections`); if (connectionCount === 0) { console.warn('[NodeConnectionSystem] No valid connections created'); return false; } // Setup observers this.setupResizeObserver(); this.setupMutationObserver(); this.setupScrollHandler(); // Start animation loop this.startAnimation(); this.isRunning = true; return true; } catch (error) { console.error('[NodeConnectionSystem] Initialization error:', error); return false; } } /** * Creates a connection between nodes * Supports both simple 2-point and multi-point chain connections * @param {string|string[]} fromIdOrChain - Source node ID or array of IDs for chain * @param {string|null} toId - Target node ID (null if using chain) * @param {ConnectionConfig} config - Connection configuration * @param {{x: number, y: number}} offset - Position offset for visual duplication (default {x: 0, y: 0}) * @returns {Connection|null} Connection object or null on failure */ createConnection(fromIdOrChain, toId, config, offset = {x: 0, y: 0}) { if (!fromIdOrChain || !config || !this.svgContainer) { console.warn(`[NodeConnectionSystem] createConnection validation failed`); return null; } // Determine connection ID based on input type let connectionId; let chainArray; if (Array.isArray(fromIdOrChain)) { // Chain mode if (fromIdOrChain.length < 2) { console.warn(`[NodeConnectionSystem] Chain must have at least 2 elements`); return null; } chainArray = fromIdOrChain; connectionId = chainArray.join('->'); } else { // Simple mode if (!toId) { console.warn(`[NodeConnectionSystem] toId required for simple connection`); return null; } chainArray = [fromIdOrChain, toId]; connectionId = `${fromIdOrChain}->${toId}`; } // Include offset in connection ID if it's not {0,0} to allow duplicate connections with different offsets if (offset.x !== 0 || offset.y !== 0) { connectionId += `@offset(${offset.x},${offset.y})`; } // Check if connection already exists if (this.connections.has(connectionId)) { return this.connections.get(connectionId); } // Check connection limit if (this.connections.size >= LIMITS.MAX_CONNECTIONS) { console.warn(`[NodeConnectionSystem] Max connections limit reached (${LIMITS.MAX_CONNECTIONS})`); return null; } try { const connection = new Connection(chainArray, null, config, this.svgContainer, offset); this.connections.set(connectionId, connection); return connection; } catch (error) { console.error(`[NodeConnectionSystem] Error creating connection:`, error); return null; } } /** * Updates all connection paths */ updateConnections() { try { this.connections.forEach(connection => { if (connection && connection.isValid()) { connection.update(); } }); } catch (error) { console.error('[NodeConnectionSystem] Error updating connections:', error); } } /** * Animation loop for all particles * @param {number} currentTime - Current timestamp from requestAnimationFrame */ animate(currentTime) { if (!this.isRunning) { return; } try { // Calculate delta time const deltaTime = this.lastFrameTime ? currentTime - this.lastFrameTime : 16.67; this.lastFrameTime = currentTime; // Animate all connections this.connections.forEach(connection => { if (connection && connection.isValid()) { connection.animateParticles(deltaTime); } }); } catch (error) { console.error('[NodeConnectionSystem] Animation error:', error); } // Continue animation loop this.animationFrameId = requestAnimationFrame(t => this.animate(t)); } /** * Starts the animation loop */ startAnimation() { if (this.animationFrameId !== null) { return; } this.lastFrameTime = 0; this.animationFrameId = requestAnimationFrame(t => this.animate(t)); } /** * Stops the animation loop */ stopAnimation() { if (this.animationFrameId !== null) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; } } /** * Sets up ResizeObserver to track element changes */ setupResizeObserver() { if (typeof ResizeObserver === 'undefined') { console.warn('[NodeConnectionSystem] ResizeObserver not supported'); return; } try { const throttledUpdate = throttle(() => { this.updateConnections(); }, 16); this.resizeObserver = new ResizeObserver(() => { throttledUpdate(); }); // Observe all nodes and control points const nodes = document.querySelectorAll('[data-node-id], [data-control-point]'); nodes.forEach(node => { if (node instanceof HTMLElement) { this.resizeObserver.observe(node); } }); } catch (error) { console.error('[NodeConnectionSystem] Error setting up ResizeObserver:', error); } } /** * Sets up MutationObserver to detect DOM changes */ setupMutationObserver() { if (typeof MutationObserver === 'undefined') { console.warn('[NodeConnectionSystem] MutationObserver not supported'); return; } try { const throttledUpdate = throttle(() => { this.updateConnections(); }, 16); this.mutationObserver = new MutationObserver(() => { throttledUpdate(); }); this.mutationObserver.observe(document.body, { attributes: true, childList: true, subtree: true, attributeFilter: ['data-node-id', 'data-control-point', 'data-connect-to', 'data-node-chain', 'data-duplicate', 'data-duplicate-count', 'data-duplicate-gap', 'style', 'class'] }); } catch (error) { console.error('[NodeConnectionSystem] Error setting up MutationObserver:', error); } } /** * Sets up scroll event handler for scroll-reveal animations */ setupScrollHandler() { if (typeof window === 'undefined') { console.warn('[NodeConnectionSystem] Window not available for scroll tracking'); return; } try { // Check for Lenis smooth scroll library (global or class available) const lenisInstance = window.lenis || (typeof Lenis !== 'undefined' ? document.querySelector('script')?.lenis : null); if (lenisInstance || typeof Lenis !== 'undefined') { // Lenis is available - hook into its scroll event for perfect sync console.log('[NodeConnectionSystem] Lenis detected, syncing with smooth scroll'); // Check if we can access the Lenis instance if (window.lenis && window.lenis.on) { // Perfect sync: Update connections on every Lenis frame window.lenis.on('scroll', () => { // Update ALL connections to stay in sync with smooth scroll this.updateConnections(); // Also update scroll-reveal animations this.updateScrollReveal(); }); console.log('[NodeConnectionSystem] Connected to Lenis scroll events'); } else { // Fallback if Lenis instance not accessible window.addEventListener('scroll', () => { this.updateConnections(); this.updateScrollReveal(); }, { passive: true }); } // Initial updates this.updateConnections(); this.updateScrollReveal(); console.log('[NodeConnectionSystem] Scroll handler setup complete (with Lenis environment)'); } else { // Fallback: Standard scroll handler with RAF let rafId = null; const scheduleUpdate = () => { if (rafId === null) { rafId = requestAnimationFrame(() => { this.updateScrollReveal(); rafId = null; }); } }; window.addEventListener('scroll', scheduleUpdate, { passive: true }); // Initial scroll check this.updateScrollReveal(); console.log('[NodeConnectionSystem] Scroll handler setup complete (native scroll)'); } } catch (error) { console.error('[NodeConnectionSystem] Error setting up scroll handler:', error); } } /** * Updates all connections with scroll-reveal enabled */ updateScrollReveal() { try { // Get SVG rect once for all connections (performance optimization) const svgRect = this.svgContainer ? this.svgContainer.getBoundingClientRect() : null; if (!svgRect) return; this.connections.forEach(connection => { if (connection && connection.isValid() && connection.config.scrollReveal) { connection.updateScrollProgress(svgRect); } }); } catch (error) { console.error('[NodeConnectionSystem] Error updating scroll reveal:', error); } } /** * Removes a specific connection * @param {string} fromId - Source node ID * @param {string} toId - Target node ID * @returns {boolean} True if removed, false otherwise */ removeConnection(fromId, toId) { const connectionId = `${fromId}->${toId}`; const connection = this.connections.get(connectionId); if (!connection) { return false; } try { connection.destroy(); this.connections.delete(connectionId); return true; } catch (error) { console.error(`[NodeConnectionSystem] Error removing connection ${connectionId}:`, error); return false; } } /** * Destroys the system and cleans up all resources */ destroy() { try { console.log('[NodeConnectionSystem] Destroying system...'); // Stop animations this.stopAnimation(); // Disconnect observers if (this.resizeObserver) { this.resizeObserver.disconnect(); this.resizeObserver = null; } if (this.mutationObserver) { this.mutationObserver.disconnect(); this.mutationObserver = null; } // Destroy all connections this.connections.forEach(connection => { if (connection) { connection.destroy(); } }); this.connections.clear(); // Remove SVG overlay if (this.svgContainer && this.svgContainer.parentNode) { this.svgContainer.parentNode.removeChild(this.svgContainer); this.svgContainer = null; } this.isRunning = false; console.log('[NodeConnectionSystem] System destroyed'); } catch (error) { console.error('[NodeConnectionSystem] Error during destruction:', error); } } /** * Gets system status information * @returns {Object} Status object */ getStatus() { return { isRunning: this.isRunning, connectionCount: this.connections.size, animating: this.animationFrameId !== null }; } } // ============================================================================ // PUBLIC API // ============================================================================ /** * Global instance of the connection system * @type {ConnectionSystem|null} */ let globalInstance = null; /** * Public API for the connection system */ const NodeConnectionSystem = { /** * Initializes the connection system * @returns {boolean} True if successful, false otherwise */ init: function() { try { if (globalInstance) { console.warn('[NodeConnectionSystem] System already initialized'); return false; } globalInstance = new ConnectionSystem(); return globalInstance.init(); } catch (error) { console.error('[NodeConnectionSystem] Init error:', error); return false; } }, /** * Destroys the connection system */ destroy: function() { if (globalInstance) { globalInstance.destroy(); globalInstance = null; } }, /** * Updates all connections manually */ update: function() { if (globalInstance) { globalInstance.updateConnections(); } }, /** * Gets system status * @returns {Object|null} Status object or null if not initialized */ getStatus: function() { return globalInstance ? globalInstance.getStatus() : null; }, /** * Gets the global instance (for advanced usage) * @returns {ConnectionSystem|null} System instance or null */ getInstance: function() { return globalInstance; }, /** * Creates a connection manually * @param {string} fromId - Source node ID * @param {string} toId - Target node ID * @param {ConnectionConfig} [config] - Optional configuration * @returns {boolean} True if created successfully */ createConnection: function(fromId, toId, config = {}) { if (!globalInstance) { console.warn('[NodeConnectionSystem] System not initialized'); return false; } const mergedConfig = { ...DEFAULT_CONFIG, ...config }; const connection = globalInstance.createConnection(fromId, toId, mergedConfig); return connection !== null; }, /** * Removes a connection manually * @param {string} fromId - Source node ID * @param {string} toId - Target node ID * @returns {boolean} True if removed successfully */ removeConnection: function(fromId, toId) { if (!globalInstance) { console.warn('[NodeConnectionSystem] System not initialized'); return false; } return globalInstance.removeConnection(fromId, toId); } }; // ============================================================================ // AUTO-INITIALIZATION // ============================================================================ // Auto-initialize on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { try { NodeConnectionSystem.init(); } catch (error) { console.error('[NodeConnectionSystem] Auto-init error:', error); } }); } else { // DOM already loaded try { NodeConnectionSystem.init(); } catch (error) { console.error('[NodeConnectionSystem] Auto-init error:', error); } } // Cleanup on page unload if (typeof window !== 'undefined') { window.addEventListener('beforeunload', () => { try { NodeConnectionSystem.destroy(); } catch (error) { console.error('[NodeConnectionSystem] Cleanup error:', error); } }); } // ============================================================================ // EXPORTS // ============================================================================ // Export to global scope global.NodeConnectionSystem = NodeConnectionSystem; // CommonJS export (if available) if (typeof module !== 'undefined' && module.exports) { module.exports = NodeConnectionSystem; } // AMD export (if available) if (typeof define === 'function' && define.amd) { define([], function() { return NodeConnectionSystem; }); } })(typeof window !== 'undefined' ? window : this);