mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
781 lines
32 KiB
TypeScript
781 lines
32 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);
|