317 lines
12 KiB
JavaScript
317 lines
12 KiB
JavaScript
|
|
import React, { useState } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
|
|
export default function EcosystemWheel({ layers, onLayerClick, selectedLayer, onLayerHover }) {
|
|
const [hoveredIndex, setHoveredIndex] = useState(null);
|
|
|
|
const centerX = 300;
|
|
const centerY = 300;
|
|
|
|
// Icon positions - precisely positioned in center of each puzzle piece
|
|
const iconPositions = [
|
|
{ x: 385, y: 160 }, // 30° - Buyer (top-right)
|
|
{ x: 460, y: 300 }, // 60° - Enterprises (top-right)
|
|
{ x: 380, y: 440 }, // 120° - Sectors (bottom-right)
|
|
{ x: 220, y: 440 }, // 180° - Partner (bottom-center)
|
|
{ x: 140, y: 300}, // 240° - Approved Vendor (bottom-left)
|
|
{ x: 220, y: 160} // 300° - Workforce (top-left)
|
|
];
|
|
|
|
// Create interlocking puzzle pieces
|
|
const createOuterPuzzle = (index, total) => {
|
|
const startAngle = index * 360 / total - 90;
|
|
const endAngle = (index + 1) * 360 / total - 90;
|
|
const midAngle = (startAngle + endAngle) / 2;
|
|
|
|
const innerRadius = 110;
|
|
const outerRadius = 210;
|
|
const tabSize = 18;
|
|
|
|
const toRad = (deg) => deg * Math.PI / 180;
|
|
|
|
const startInner = {
|
|
x: centerX + innerRadius * Math.cos(toRad(startAngle)),
|
|
y: centerY + innerRadius * Math.sin(toRad(startAngle))
|
|
};
|
|
|
|
const endInner = {
|
|
x: centerX + innerRadius * Math.cos(toRad(endAngle)),
|
|
y: centerY + innerRadius * Math.sin(toRad(endAngle))
|
|
};
|
|
|
|
const startOuter = {
|
|
x: centerX + outerRadius * Math.cos(toRad(startAngle)),
|
|
y: centerY + outerRadius * Math.sin(toRad(startAngle))
|
|
};
|
|
|
|
const endOuter = {
|
|
x: centerX + outerRadius * Math.cos(toRad(endAngle)),
|
|
y: centerY + outerRadius * Math.sin(toRad(endAngle))
|
|
};
|
|
|
|
const innerTabAngle = startAngle + (endAngle - startAngle) * 0.5;
|
|
const outerTabAngle = startAngle + (endAngle - startAngle) * 0.5;
|
|
|
|
const innerTabOut = {
|
|
x: centerX + (innerRadius - tabSize) * Math.cos(toRad(innerTabAngle)),
|
|
y: centerY + (innerRadius - tabSize) * Math.sin(toRad(innerTabAngle))
|
|
};
|
|
|
|
const outerTabOut = {
|
|
x: centerX + (outerRadius + tabSize) * Math.cos(toRad(outerTabAngle)),
|
|
y: centerY + (outerRadius + tabSize) * Math.sin(toRad(outerTabAngle))
|
|
};
|
|
|
|
let path = `M ${startInner.x} ${startInner.y}`;
|
|
|
|
const innerArc1End = startAngle + (endAngle - startAngle) * 0.4;
|
|
const innerArc2Start = startAngle + (endAngle - startAngle) * 0.6;
|
|
|
|
path += ` A ${innerRadius} ${innerRadius} 0 0 1 ${centerX + innerRadius * Math.cos(toRad(innerArc1End))} ${centerY + innerRadius * Math.sin(toRad(innerArc1End))}`;
|
|
path += ` Q ${innerTabOut.x} ${innerTabOut.y} ${centerX + innerRadius * Math.cos(toRad(innerArc2Start))} ${centerY + innerRadius * Math.sin(toRad(innerArc2Start))}`;
|
|
path += ` A ${innerRadius} ${innerRadius} 0 0 1 ${endInner.x} ${endInner.y}`;
|
|
|
|
path += ` L ${endOuter.x} ${endOuter.y}`;
|
|
|
|
const outerArc1End = endAngle - (endAngle - startAngle) * 0.4;
|
|
const outerArc2Start = endAngle - (endAngle - startAngle) * 0.6;
|
|
|
|
path += ` A ${outerRadius} ${outerRadius} 0 0 0 ${centerX + outerRadius * Math.cos(toRad(outerArc1End))} ${centerY + outerRadius * Math.sin(toRad(outerArc1End))}`;
|
|
path += ` Q ${outerTabOut.x} ${outerTabOut.y} ${centerX + outerRadius * Math.cos(toRad(outerArc2Start))} ${centerY + outerRadius * Math.sin(toRad(outerArc2Start))}`;
|
|
path += ` A ${outerRadius} ${outerRadius} 0 0 0 ${startOuter.x} ${startOuter.y}`;
|
|
|
|
path += ` L ${startInner.x} ${startInner.y}`;
|
|
path += " Z";
|
|
|
|
return path;
|
|
};
|
|
|
|
const createCenterPuzzle = () => {
|
|
const radius = 90;
|
|
const numTabs = layers.length;
|
|
let path = "";
|
|
|
|
for (let i = 0; i < numTabs; i++) {
|
|
const angle1 = i * 360 / numTabs - 90;
|
|
const angle2 = (i + 1) * 360 / numTabs - 90;
|
|
const midAngle = (angle1 + angle2) / 2;
|
|
const tabSize = 18;
|
|
|
|
const toRad = (deg) => deg * Math.PI / 180;
|
|
|
|
const start = {
|
|
x: centerX + radius * Math.cos(toRad(angle1)),
|
|
y: centerY + radius * Math.sin(toRad(angle1))
|
|
};
|
|
|
|
const end = {
|
|
x: centerX + radius * Math.cos(toRad(angle2)),
|
|
y: centerY + radius * Math.sin(toRad(angle2))
|
|
};
|
|
|
|
const tabIn = {
|
|
x: centerX + (radius + tabSize) * Math.cos(toRad(midAngle)),
|
|
y: centerY + (radius + tabSize) * Math.sin(toRad(midAngle))
|
|
};
|
|
|
|
if (i === 0) {
|
|
path += `M ${start.x} ${start.y}`;
|
|
}
|
|
|
|
const arc1End = angle1 + (angle2 - angle1) * 0.4;
|
|
const arc2Start = angle1 + (angle2 - angle1) * 0.6;
|
|
|
|
path += ` A ${radius} ${radius} 0 0 1 ${centerX + radius * Math.cos(toRad(arc1End))} ${centerY + radius * Math.sin(toRad(arc1End))}`;
|
|
path += ` Q ${tabIn.x} ${tabIn.y} ${centerX + radius * Math.cos(toRad(arc2Start))} ${centerY + radius * Math.sin(toRad(arc2Start))}`;
|
|
path += ` A ${radius} ${radius} 0 0 1 ${end.x} ${end.y}`;
|
|
}
|
|
|
|
path += " Z";
|
|
return path;
|
|
};
|
|
|
|
return (
|
|
<div className="relative w-full flex flex-col items-center py-8" style={{ minHeight: '700px' }}>
|
|
<div className="text-center mb-6">
|
|
<h3 className="text-2xl font-bold text-[#1C323E] mb-2">KROW Ecosystem</h3>
|
|
<p className="text-slate-600 text-sm">Hover over each piece to see details • Click to explore</p>
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<svg width="620" height="600" viewBox="0 0 620 600" className="mx-auto drop-shadow-xl">
|
|
<defs>
|
|
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
|
<feGaussianBlur in="SourceAlpha" stdDeviation="4" />
|
|
<feOffset dx="0" dy="3" result="offsetblur" />
|
|
<feComponentTransfer>
|
|
<feFuncA type="linear" slope="0.3" />
|
|
</feComponentTransfer>
|
|
<feMerge>
|
|
<feMergeNode />
|
|
<feMergeNode in="SourceGraphic" />
|
|
</feMerge>
|
|
</filter>
|
|
</defs>
|
|
|
|
{/* Outer Puzzle Pieces */}
|
|
{layers.map((layer, index) => {
|
|
const angle = index * 360 / layers.length - 90;
|
|
const rad = angle * Math.PI / 180;
|
|
|
|
// Use exact icon position from array
|
|
const iconX = iconPositions[index].x;
|
|
const iconY = iconPositions[index].y;
|
|
|
|
// Label position - Outside puzzle
|
|
const labelRadius = 240;
|
|
const labelX = centerX + labelRadius * Math.cos(rad);
|
|
const labelY = centerY + labelRadius * Math.sin(rad);
|
|
|
|
const isHovered = hoveredIndex === index;
|
|
const Icon = layer.icon;
|
|
|
|
return (
|
|
<g key={index}>
|
|
{/* Puzzle Piece */}
|
|
<motion.path
|
|
d={createOuterPuzzle(index, layers.length)}
|
|
fill={isHovered ? "#F8FAFC" : "#FFFFFF"}
|
|
stroke={isHovered ? "#0A39DF" : "#CBD5E1"}
|
|
strokeWidth={isHovered ? "3" : "2"}
|
|
filter="url(#shadow)"
|
|
className="cursor-pointer transition-all duration-300"
|
|
onMouseEnter={() => {
|
|
setHoveredIndex(index);
|
|
onLayerHover?.(layer);
|
|
}}
|
|
onMouseLeave={() => {
|
|
setHoveredIndex(null);
|
|
onLayerHover?.(null);
|
|
}}
|
|
onClick={() => onLayerClick?.(layer)}
|
|
animate={{
|
|
scale: isHovered ? 1.05 : 1
|
|
}}
|
|
transition={{ duration: 0.3 }}
|
|
style={{
|
|
transformOrigin: `${centerX}px ${centerY}px`
|
|
}} />
|
|
|
|
|
|
{/* Icon - Exact positioned in puzzle piece */}
|
|
<circle
|
|
cx={iconX}
|
|
cy={iconY}
|
|
r="24"
|
|
fill="#1C323E"
|
|
className="pointer-events-none transition-all duration-300" />
|
|
|
|
<foreignObject
|
|
x={iconX - 24}
|
|
y={iconY - 24}
|
|
width="48"
|
|
height="48"
|
|
className="pointer-events-none">
|
|
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<Icon className="w-7 h-7 text-white" />
|
|
</div>
|
|
</foreignObject>
|
|
|
|
{/* Label - Outside puzzle */}
|
|
<foreignObject
|
|
x={labelX - 90}
|
|
y={labelY - 25}
|
|
width="180"
|
|
height="50"
|
|
className="pointer-events-none">
|
|
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<div className="bg-white px-5 py-2.5 rounded-xl shadow-lg border border-slate-200">
|
|
<p className={`font-bold text-sm text-center leading-tight whitespace-nowrap transition-colors ${
|
|
isHovered ? 'text-[#0A39DF]' : 'text-[#1C323E]'}`
|
|
}>
|
|
{layer.name}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</foreignObject>
|
|
</g>);
|
|
|
|
})}
|
|
|
|
{/* Center Puzzle Piece */}
|
|
<motion.path
|
|
d={createCenterPuzzle()}
|
|
fill="#FFFFFF"
|
|
stroke="#CBD5E1"
|
|
strokeWidth="2"
|
|
filter="url(#shadow)"
|
|
animate={{
|
|
scale: hoveredIndex !== null ? 1.03 : 1
|
|
}}
|
|
transition={{ duration: 0.3 }}
|
|
style={{
|
|
transformOrigin: `${centerX}px ${centerY}px`
|
|
}} />
|
|
|
|
|
|
{/* Center Logo */}
|
|
<foreignObject
|
|
x={centerX - 40}
|
|
y={centerY - 40}
|
|
width="80"
|
|
height="80"
|
|
className="pointer-events-none">
|
|
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<img
|
|
src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png"
|
|
alt="KROW"
|
|
className="w-16 h-16 object-contain" />
|
|
|
|
</div>
|
|
</foreignObject>
|
|
</svg>
|
|
|
|
{/* Centered Metrics Display */}
|
|
<AnimatePresence>
|
|
{hoveredIndex !== null &&
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
|
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 pointer-events-none z-50">
|
|
|
|
<div className="bg-white rounded-2xl shadow-2xl border-2 border-[#0A39DF] p-6 min-w-[300px]">
|
|
<div className="flex items-center justify-center gap-3 mb-5 pb-4 border-b border-slate-200">
|
|
<div className="w-12 h-12 rounded-xl bg-[#0A39DF] flex items-center justify-center shadow-md">
|
|
{React.createElement(layers[hoveredIndex].icon, { className: "w-6 h-6 text-white" })}
|
|
</div>
|
|
<h4 className="font-bold text-[#1C323E] text-lg">{layers[hoveredIndex].name}</h4>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{Object.entries(layers[hoveredIndex].metrics).map(([key, value]) =>
|
|
<div key={key} className="flex justify-between items-center gap-6">
|
|
<span className="text-slate-600 text-sm capitalize font-medium">
|
|
{key.replace(/([A-Z])/g, ' $1').trim()}
|
|
</span>
|
|
<span className="font-bold text-lg text-[#0A39DF]">
|
|
{value}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="mt-5 pt-4 border-t border-slate-200 text-center">
|
|
<p className="text-xs text-slate-500">Click to view full dashboard</p>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>);
|
|
|
|
}
|