turash/bugulma/frontend/components/landing/ResourceExchangeVisualization.tsx
Damir Mukimov 6347f42e20
Consolidate repositories: Remove nested frontend .git and merge into main repository
- Remove nested git repository from bugulma/frontend/.git
- Add all frontend files to main repository tracking
- Convert from separate frontend/backend repos to unified monorepo
- Preserve all frontend code and development history as tracked files
- Eliminate nested repository complexity for simpler development workflow

This creates a proper monorepo structure with frontend and backend
coexisting in the same repository for easier development and deployment.
2025-11-25 06:02:57 +01:00

781 lines
31 KiB
TypeScript

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<number, Set<number>> = 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<ResourceExchangeVisualizationProps> = ({
maxItems = 6,
showStats = false
}) => {
const { t } = useTranslation();
const { sectors: dynamicSectors, isLoading } = useDynamicSectors(maxItems);
const [hoveredSector, setHoveredSector] = useState<number | null>(null);
const [activeConnections, setActiveConnections] = useState<Set<string>>(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<string>();
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 (
<div className="w-full relative aspect-square max-w-lg mx-auto">
<div className="absolute inset-0 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
</div>
);
}
if (dynamicSectors.length === 0) {
return null;
}
return (
<div className="w-full flex flex-col items-center gap-4 max-w-lg mx-auto">
{/* Title */}
<div className="text-center">
<h3 className="text-sm font-semibold text-foreground mb-1">
Resource Exchange Network
</h3>
<p className="text-xs text-muted-foreground">
Businesses connect to exchange resources
</p>
</div>
{/* SVG Canvas for network visualization */}
<div className="relative w-full aspect-square min-h-[400px]">
<svg
viewBox={`0 0 ${LAYOUT_CONFIG.SVG_VIEWBOX_SIZE} ${LAYOUT_CONFIG.SVG_VIEWBOX_SIZE}`}
className="w-full h-full"
preserveAspectRatio="xMidYMid meet"
style={{ overflow: 'visible' }}
>
<defs>
{/* Gradient for connection lines */}
<linearGradient id="connectionGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.3" />
<stop offset="50%" stopColor="hsl(var(--primary))" stopOpacity="0.6" />
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0.3" />
</linearGradient>
{/* Glow filter for active connections */}
<filter id="glow">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
{/* Connection lines (edges) */}
<g>
{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 (
<g key={`connection-${idx}`}>
{/* Connection line - for bidirectional: source to destination (icons at midpoint); for unidirectional: from resource icon to destination */}
<motion.line
x1={connectionStart.x}
y1={connectionStart.y}
x2={toPos.x}
y2={toPos.y}
stroke="hsl(var(--primary))"
strokeWidth={isActive ? 2 : 1}
strokeDasharray={isActive ? "0" : "4 4"}
opacity={isActive ? conn.strength : 0.2}
filter={isActive ? "url(#glow)" : undefined}
initial={{ pathLength: 0 }}
animate={{ pathLength: isActive ? 1 : 0.3 }}
transition={{ duration: 1, delay: idx * 0.1 }}
/>
{/* 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 (
<motion.circle
key={`particle-${idx}-${particleIdx}`}
r={particleSize}
fill="hsl(var(--primary))"
initial={{
cx: particleStartX + offsetX,
cy: particleStartY + offsetY,
opacity: particleOpacity, // Start fully visible
}}
animate={{
cx: [particleStartX + offsetX, particleEndX + offsetX],
cy: [particleStartY + offsetY, particleEndY + offsetY],
opacity: [particleOpacity, particleOpacity, 0], // Stay fully visible, fade out only when reaching destination
}}
transition={{
duration: particleDuration,
repeat: Infinity,
delay: waitForLine + staggerDelay + randomDelay, // Wait for line to appear first
ease: "linear",
times: [0, 0.95, 1], // Fully visible (95%), fade out only at destination (5%)
}}
/>
);
})}
</>
)}
</g>
);
})}
</g>
{/* 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 (
<g key={sector.backendName || `sector-${index}`}>
{/* Connection highlight circle - consistent size */}
{isHovered && (
<motion.circle
cx={pos.x}
cy={pos.y}
r="90"
fill="none"
stroke="hsl(var(--primary))"
strokeWidth="1.5"
strokeDasharray="4 8"
opacity="0.25"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1.15, opacity: 0.3 }}
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
/>
)}
{/* Sector node circle - consistent size */}
<motion.circle
cx={pos.x}
cy={pos.y}
r="32"
fill={`hsl(var(--sector-${sector.colorKey}))`}
fillOpacity="0.15"
stroke={`hsl(var(--sector-${sector.colorKey}))`}
strokeWidth={isHovered ? 3 : 2}
filter={isHovered ? "url(#glow)" : undefined}
initial={{ scale: 0 }}
animate={
Object.keys(pulseProps).length > 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 */}
<motion.circle
cx={pos.x}
cy={pos.y}
r="22"
fill="hsl(var(--background))"
stroke={`hsl(var(--sector-${sector.colorKey}))`}
strokeWidth="2"
initial={{ scale: 0 }}
animate={
Object.keys(pulseProps).length > 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 */}
<motion.text
x={pos.x}
y={pos.y + 55}
textAnchor="middle"
fontSize="12"
fill="hsl(var(--foreground))"
fontWeight="600"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: index * 0.1 + 0.3 }}
>
{t(`${sector.nameKey}.name`)}
</motion.text>
{/* Connection count badge - shows number of connections */}
{showStats && sectorConnections.length > 0 && (
<g>
{/* Background circle - colored by sector */}
<motion.circle
cx={pos.x + 28}
cy={pos.y - 28}
r="14"
fill={`hsl(var(--sector-${sector.colorKey}))`}
stroke="hsl(var(--card))"
strokeWidth="2"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: index * 0.1 + 0.4 }}
/>
{/* Connection count text - use black for contrast on colored sector backgrounds */}
<motion.text
x={pos.x + 28}
y={pos.y - 28}
textAnchor="middle"
dominantBaseline="central"
fontSize="11"
fontWeight="700"
fill="black"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: index * 0.1 + 0.5 }}
>
{sectorConnections.length}
</motion.text>
<title>{sectorConnections.length} resource exchange connections</title>
</g>
)}
</g>
);
})}
{/* 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 (
<foreignObject
key={`icon-${sector.backendName || index}`}
x={bounds.x}
y={bounds.y}
width={bounds.width}
height={bounds.height}
style={{ overflow: 'visible' }}
>
<motion.div
className="flex items-center justify-center w-full h-full rounded-full bg-background border-2 pointer-events-none"
style={{
borderColor: `hsl(var(--sector-${sector.colorKey}))`,
}}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: index * 0.1 + 0.2 }}
>
{React.cloneElement(sector.icon, {
className: `w-5 h-5 text-sector-${sector.colorKey}`,
})}
</motion.div>
</foreignObject>
);
})}
{/* 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 (
<foreignObject
key={`resource-icon-${idx}`}
x={bounds.x}
y={bounds.y}
width={bounds.width}
height={bounds.height}
style={{ overflow: 'visible' }}
>
<motion.div
className="flex items-center justify-center w-full h-full rounded-full bg-background border-2 pointer-events-none shadow-md"
style={{
borderColor: `hsl(var(--sector-${sourceSector.colorKey}))`,
}}
initial={{ opacity: 0, scale: 0 }}
animate={{
opacity: isActive ? 1 : 0,
scale: isActive ? 1 : 0,
}}
transition={{ duration: 0.3 }}
>
<ResourceIcon className={`w-4 h-4 ${resourceType.color}`} />
</motion.div>
</foreignObject>
);
})}
</svg>
</div>
{/* Legend - moved below the animation */}
<div className="flex gap-4 items-center bg-background/80 backdrop-blur-sm px-4 py-2 rounded-full border shadow-lg">
<span className="text-xs text-muted-foreground font-medium">Resource Exchanges:</span>
{RESOURCE_TYPES.map((resource) => {
const Icon = resource.icon;
return (
<div key={resource.type} className="flex items-center gap-1.5">
<Icon className={`w-3 h-3 ${resource.color}`} />
<span className="text-xs text-muted-foreground capitalize">{resource.type}</span>
</div>
);
})}
</div>
</div>
);
};
export default React.memo(ResourceExchangeVisualization);