import { useDynamicSectors } from '@/hooks/useDynamicSectors.ts'; import { useTranslation } from '@/hooks/useI18n.tsx'; import { motion } from 'framer-motion'; import { Briefcase, Hammer, Layers, ShoppingBag } from 'lucide-react'; import React, { useMemo, useState } from 'react'; interface ResourceExchangeVisualizationProps { maxItems?: number; showStats?: boolean; } // Layout constants - single source of truth const LAYOUT_CONFIG = { SVG_VIEWBOX_SIZE: 500, CENTER_X: 250, CENTER_Y: 250, SECTOR_RADIUS: 180, RESOURCE_ICON_OFFSET: 45, // Distance from sector center for unidirectional RESOURCE_ICON_SIZE: 7, // Radius in SVG units SECTOR_NODE_RADIUS: 32, SECTOR_ICON_BACKGROUND_RADIUS: 22, CONNECTION_BADGE_OFFSET: 28, CONNECTION_BADGE_RADIUS: 14, LABEL_OFFSET_Y: 55, NODE_RADIUS_FOR_PARTICLES: 35, // Hide particles within this radius } as const; // Layout calculator - handles all coordinate transformations // Uses SVG's native coordinate system - no pixel conversion needed class LayoutCalculator { private svgSize: number; constructor(svgSize: number = LAYOUT_CONFIG.SVG_VIEWBOX_SIZE) { this.svgSize = svgSize; } // Calculate sector positions in circular layout (SVG coordinates) calculateSectorPositions(count: number): Array<{ x: number; y: number; angle: number }> { if (count === 0) return []; const { CENTER_X, CENTER_Y, SECTOR_RADIUS } = LAYOUT_CONFIG; const angleStep = (2 * Math.PI) / count; return Array.from({ length: count }, (_, index) => { const angle = index * angleStep - Math.PI / 2; return { x: CENTER_X + SECTOR_RADIUS * Math.cos(angle), y: CENTER_Y + SECTOR_RADIUS * Math.sin(angle), angle, }; }); } // Calculate resource icon position (SVG coordinates) // Avoids overlapping with connection count badges calculateResourceIconPosition( fromPos: { x: number; y: number }, toPos: { x: number; y: number }, isBidirectional: boolean, fromIndex: number, toIndex: number, showStats: boolean = false ): { x: number; y: number } { const angle = Math.atan2(toPos.y - fromPos.y, toPos.x - fromPos.x); if (isBidirectional) { // Position at midpoint, offset perpendicular // Use from/to indices to ensure consistent side assignment const midpointX = (fromPos.x + toPos.x) / 2; const midpointY = (fromPos.y + toPos.y) / 2; const perpAngle = angle + Math.PI / 2; // Ensure consistent side: lower index → higher index goes left, reverse goes right const sideOffset = fromIndex < toIndex ? -12 : 12; return { x: midpointX + Math.cos(perpAngle) * sideOffset, y: midpointY + Math.sin(perpAngle) * sideOffset, }; } else { // Position next to source organization let resourceX = fromPos.x + Math.cos(angle) * LAYOUT_CONFIG.RESOURCE_ICON_OFFSET; let resourceY = fromPos.y + Math.sin(angle) * LAYOUT_CONFIG.RESOURCE_ICON_OFFSET; // Check if resource icon would overlap with badge (badge is at +28x, -28y from sector center) if (showStats) { const badgeX = fromPos.x + LAYOUT_CONFIG.CONNECTION_BADGE_OFFSET; const badgeY = fromPos.y - LAYOUT_CONFIG.CONNECTION_BADGE_OFFSET; const badgeRadius = LAYOUT_CONFIG.CONNECTION_BADGE_RADIUS + 8; // Add padding const resourceIconRadius = 14; // Half of foreignObject size (28/2) const distanceToBadge = Math.sqrt( Math.pow(resourceX - badgeX, 2) + Math.pow(resourceY - badgeY, 2) ); // If too close to badge, adjust position if (distanceToBadge < badgeRadius + resourceIconRadius) { // Move resource icon further along the connection direction const adjustedOffset = LAYOUT_CONFIG.RESOURCE_ICON_OFFSET + 20; resourceX = fromPos.x + Math.cos(angle) * adjustedOffset; resourceY = fromPos.y + Math.sin(angle) * adjustedOffset; // Also check if still too close, if so offset perpendicularly const newDistanceToBadge = Math.sqrt( Math.pow(resourceX - badgeX, 2) + Math.pow(resourceY - badgeY, 2) ); if (newDistanceToBadge < badgeRadius + resourceIconRadius) { // Offset perpendicular to avoid badge const perpAngle = angle + Math.PI / 2; const perpOffset = badgeRadius + resourceIconRadius - newDistanceToBadge + 5; // Choose side that's away from badge const badgeAngle = Math.atan2(badgeY - fromPos.y, badgeX - fromPos.x); const angleDiff = angle - badgeAngle; const perpDirection = Math.abs(angleDiff) < Math.PI / 2 ? -1 : 1; resourceX += Math.cos(perpAngle) * perpOffset * perpDirection; resourceY += Math.sin(perpAngle) * perpOffset * perpDirection; } } } return { x: resourceX, y: resourceY, }; } } // Calculate connection line start position (SVG coordinates) calculateConnectionStart( fromPos: { x: number; y: number }, resourceIconPos: { x: number; y: number }, isBidirectional: boolean ): { x: number; y: number } { if (isBidirectional) { return { x: fromPos.x, y: fromPos.y }; } else { const angle = Math.atan2(resourceIconPos.y - fromPos.y, resourceIconPos.x - fromPos.x); return { x: resourceIconPos.x + Math.cos(angle) * LAYOUT_CONFIG.RESOURCE_ICON_SIZE, y: resourceIconPos.y + Math.sin(angle) * LAYOUT_CONFIG.RESOURCE_ICON_SIZE, }; } } // Get SVG coordinate bounds for foreignObject positioning // Returns position and size in SVG coordinate system getForeignObjectBounds( svgX: number, svgY: number, size: number = 44 ): { x: number; y: number; width: number; height: number; } { // Center the foreignObject on the SVG coordinate return { x: svgX - size / 2, y: svgY - size / 2, width: size, height: size, }; } // Calculate distance between two points distance(p1: { x: number; y: number }, p2: { x: number; y: number }): number { return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); } } // Resource types that can flow between sectors const RESOURCE_TYPES = [ { type: 'products', icon: ShoppingBag, color: 'text-blue-500' }, { type: 'services', icon: Briefcase, color: 'text-purple-500' }, { type: 'materials', icon: Layers, color: 'text-green-500' }, { type: 'equipment', icon: Hammer, color: 'text-orange-500' }, ] as const; // Generate potential connections between sectors based on common symbiosis patterns // Creates varying connection counts (1-4 connections per sector) for visual interest const generateConnections = (sectors: Array<{ backendName?: string; colorKey: string }>) => { const connections: Array<{ from: number; to: number; resourceType: string; strength: number }> = []; // Track resource types used per sector to ensure variety const sectorResourceUsage: Map> = new Map(); if (sectors.length < 2) return connections; // Connection pattern per sector index to create varied counts: [1, 2, 3, 2, 4, 3] const connectionPatterns = [ [1], // Sector 0: 1 connection (to next) [1, -Math.floor(sectors.length / 2)], // Sector 1: 2 connections (next + opposite) [1, -1, 2], // Sector 2: 3 connections (next + prev + skipOne) [1, -Math.floor(sectors.length / 2)], // Sector 3: 2 connections (next + opposite) [1, -1, -Math.floor(sectors.length / 2), 2], // Sector 4: 4 connections [1, -1, 2], // Sector 5: 3 connections (next + prev + skipOne) ]; for (let i = 0; i < sectors.length; i++) { const pattern = connectionPatterns[i] || [1]; // Default to 1 connection if (!sectorResourceUsage.has(i)) { sectorResourceUsage.set(i, new Set()); } pattern.forEach((offset) => { const targetIndex = (i + offset + sectors.length) % sectors.length; // Don't connect to self if (targetIndex === i) return; // Avoid duplicate connections (check if reverse connection already exists) const reverseExists = connections.some((c) => c.from === targetIndex && c.to === i); if (!reverseExists) { // Assign resource type ensuring variety - cycle through all resource types globally // This ensures all resource types (products, services, materials, equipment) are used const connectionIndex = connections.length; const resourceTypeIndex = connectionIndex % RESOURCE_TYPES.length; // Track usage per sector for reference (but don't restrict assignment) const usedResources = sectorResourceUsage.get(i)!; usedResources.add(resourceTypeIndex); connections.push({ from: i, to: targetIndex, resourceType: RESOURCE_TYPES[resourceTypeIndex].type, strength: Math.abs(offset) === 1 ? 0.8 : Math.abs(offset) === Math.floor(sectors.length / 2) ? 0.5 : 0.6, }); } }); } return connections; }; const ResourceExchangeVisualization: React.FC = ({ maxItems = 6, showStats = false, }) => { const { t } = useTranslation(); const { sectors: dynamicSectors, isLoading } = useDynamicSectors(maxItems); const [hoveredSector, setHoveredSector] = useState(null); const [activeConnections, setActiveConnections] = useState>(new Set()); // Create layout calculator instance - uses SVG native coordinate system const layoutCalculator = useMemo(() => new LayoutCalculator(LAYOUT_CONFIG.SVG_VIEWBOX_SIZE), []); // Generate sector positions using layout calculator const sectorPositions = useMemo(() => { return layoutCalculator.calculateSectorPositions(dynamicSectors.length); }, [dynamicSectors.length, layoutCalculator]); // Generate connections between sectors const connections = useMemo(() => { if (dynamicSectors.length === 0) return []; return generateConnections(dynamicSectors); }, [dynamicSectors]); // Activate connections on hover React.useEffect(() => { if (hoveredSector !== null) { const active = new Set(); connections.forEach((conn, idx) => { if (conn.from === hoveredSector || conn.to === hoveredSector) { active.add(`conn-${idx}`); } }); setActiveConnections(active); } else { setActiveConnections(new Set()); } }, [hoveredSector, connections]); if (isLoading) { return (
); } if (dynamicSectors.length === 0) { return null; } return (
{/* Title */}

Resource Exchange Network

Businesses connect to exchange resources

{/* SVG Canvas for network visualization */}
{/* Gradient for connection lines */} {/* Glow filter for active connections */} {/* Connection lines (edges) */} {connections.map((conn, idx) => { const fromPos = sectorPositions[conn.from]; const toPos = sectorPositions[conn.to]; if (!fromPos || !toPos) return null; const isActive = activeConnections.has(`conn-${idx}`) || hoveredSector === null; // Check if this is a bidirectional exchange (both A→B and B→A exist) const reverseConnection = connections.find( (c) => c.from === conn.to && c.to === conn.from ); const isBidirectional = !!reverseConnection; // Calculate resource icon position using layout calculator const resourceIconPos = layoutCalculator.calculateResourceIconPosition( fromPos, toPos, isBidirectional, conn.from, conn.to, showStats ); // Calculate connection start position const connectionStart = layoutCalculator.calculateConnectionStart( fromPos, resourceIconPos, isBidirectional ); // Calculate total distance for particle animation const totalDistance = layoutCalculator.distance(connectionStart, toPos); return ( {/* Connection line - for bidirectional: source to destination (icons at midpoint); for unidirectional: from resource icon to destination */} {/* Animated resource flow particles - flow from resource icon to organization */} {isActive && totalDistance > 0 && ( <> {[...Array(3)].map((_, particleIdx) => { // Particles start exactly at the resource icon position const particleStartX = resourceIconPos.x; const particleStartY = resourceIconPos.y; // Particles travel all the way to the organization icon // Stop just before the organization circle edge to avoid overlap const organizationRadius = LAYOUT_CONFIG.SECTOR_NODE_RADIUS; const particleEndX = toPos.x; const particleEndY = toPos.y; // Calculate full distance from resource icon to organization const fullDistance = layoutCalculator.distance( { x: particleStartX, y: particleStartY }, { x: particleEndX, y: particleEndY } ); // Base duration with randomization for organic feel const baseDuration = 4 + conn.strength * 2; const randomVariation = 0.7 + Math.random() * 0.6; // 0.7x to 1.3x speed variation const particleDuration = baseDuration * randomVariation; // Stagger particles for continuous flow - overlap them so there's always a particle visible // Each particle starts before the previous one finishes (overlap by ~60%) const overlapRatio = 0.6; // 60% overlap const staggerDelay = particleIdx * particleDuration * (1 - overlapRatio); // Randomize particle properties for dynamic feel const particleSize = 2.5 + Math.random() * 1.5; // 2.5-4px radius const particleOpacity = 0.6 + Math.random() * 0.4; // 0.6-1.0 opacity // Slight path variation for more organic movement (small perpendicular offset) const pathVariation = (Math.random() - 0.5) * 4; // Reduced to ±2px for more realistic path const angle = Math.atan2( particleEndY - particleStartY, particleEndX - particleStartX ); const perpAngle = angle + Math.PI / 2; const offsetX = Math.cos(perpAngle) * pathVariation; const offsetY = Math.sin(perpAngle) * pathVariation; // Add slight random delay to break synchronization const randomDelay = Math.random() * 0.5; // Wait for connection line to be visible before starting particles // Connection line has delay: idx * 0.1 and duration: 1s const connectionLineDelay = idx * 0.1; const connectionLineDuration = 1; const waitForLine = connectionLineDelay + connectionLineDuration * 0.8; // Start particles when line is 80% visible return ( ); })} )} ); })} {/* Sector nodes */} {dynamicSectors.map((sector, index) => { const pos = sectorPositions[index]; if (!pos) return null; const isHovered = hoveredSector === index; const sectorConnections = connections.filter((c) => c.from === index || c.to === index); // Find incoming connections (where this sector is the destination) const incomingConnections = connections.filter((c) => c.to === index); // Calculate pulse animation synchronized with particle arrivals // Particles take (4 + strength * 2) seconds to travel, and we have 3 particles staggered const getPulseProps = () => { // Don't pulse if no incoming connections or if this sector is hovered if (incomingConnections.length === 0 || isHovered) { return {}; } // Find active incoming connections (where particles are flowing) const activeIncoming = incomingConnections.find((conn) => { const connIdx = connections.findIndex( (c) => c.from === conn.from && c.to === conn.to ); const connKey = `conn-${connIdx}`; // Connection is active if it's in activeConnections set or if nothing is hovered (all connections active) return activeConnections.has(connKey) || hoveredSector === null; }); if (!activeIncoming) return {}; // Calculate pulse timing based on continuous particle flow // With 6 particles overlapping at 60%, particles arrive more frequently const baseDuration = 4 + activeIncoming.strength * 2; const overlapRatio = 0.6; const particleInterval = baseDuration * (1 - overlapRatio); // Time between particle arrivals const averageDuration = baseDuration * 0.85; // Average duration accounting for randomization // Pulse when particles arrive - more frequent pulses for continuous flow // Account for initial spring animation (~0.8s) + delay const initialAnimationTime = 0.8 + index * 0.1; const pulseDelay = Math.max(0, averageDuration - initialAnimationTime); return { transition: { duration: 0.3, delay: pulseDelay, repeat: Infinity, repeatDelay: particleInterval - 0.3, // Pulse with each particle arrival ease: 'easeInOut', }, }; }; const pulseProps = getPulseProps(); return ( {/* Connection highlight circle - consistent size */} {isHovered && ( )} {/* Sector node circle - consistent size */} 0 ? { scale: [1, 1.2, 1], } : { scale: 1 } } transition={ Object.keys(pulseProps).length > 0 ? { scale: { ...pulseProps.transition, }, default: { type: 'spring', stiffness: 200, damping: 15, delay: index * 0.1, }, } : { type: 'spring', stiffness: 200, damping: 15, delay: index * 0.1, } } onHoverStart={() => setHoveredSector(index)} onHoverEnd={() => setHoveredSector(null)} className="cursor-pointer" /> {/* Sector icon circle background - consistent size */} 0 ? { scale: [1, 1.2, 1], } : { scale: 1 } } transition={ Object.keys(pulseProps).length > 0 ? { scale: { ...pulseProps.transition, }, default: { delay: index * 0.1 + 0.2, }, } : { delay: index * 0.1 + 0.2 } } /> {/* Sector label */} {t(`${sector.nameKey}.name`)} {/* Connection count badge - shows number of connections */} {showStats && sectorConnections.length > 0 && ( {/* Background circle - colored by sector */} {/* Connection count text - use black for contrast on colored sector backgrounds */} {sectorConnections.length} {sectorConnections.length} resource exchange connections )} ); })} {/* Sector icons using foreignObject - native SVG coordinate system */} {dynamicSectors.map((sector, index) => { const pos = sectorPositions[index]; if (!pos) return null; const bounds = layoutCalculator.getForeignObjectBounds(pos.x, pos.y, 44); return ( {React.cloneElement(sector.icon, { className: `w-5 h-5 text-sector-${sector.colorKey}`, })} ); })} {/* Resource type icons using foreignObject - native SVG coordinate system */} {connections.map((conn, idx) => { const fromPos = sectorPositions[conn.from]; const toPos = sectorPositions[conn.to]; if (!fromPos || !toPos) return null; const sourceSector = dynamicSectors[conn.from]; if (!sourceSector) return null; const isActive = activeConnections.has(`conn-${idx}`) || hoveredSector === null; const resourceType = RESOURCE_TYPES.find((r) => r.type === conn.resourceType); if (!resourceType) return null; const ResourceIcon = resourceType.icon; // Check if this is a bidirectional exchange (both A→B and B→A exist) const reverseConnection = connections.find( (c) => c.from === conn.to && c.to === conn.from ); const isBidirectional = !!reverseConnection; // Calculate resource icon position using layout calculator const resourceIconPos = layoutCalculator.calculateResourceIconPosition( fromPos, toPos, isBidirectional, conn.from, conn.to, showStats ); const bounds = layoutCalculator.getForeignObjectBounds( resourceIconPos.x, resourceIconPos.y, 28 ); return ( ); })}
{/* Legend - moved below the animation */}
Resource Exchanges: {RESOURCE_TYPES.map((resource) => { const Icon = resource.icon; return (
{resource.type}
); })}
); }; export default React.memo(ResourceExchangeVisualization);