first commit
This commit is contained in:
316
src/components/dashboard/EcosystemWheel.jsx
Normal file
316
src/components/dashboard/EcosystemWheel.jsx
Normal file
@@ -0,0 +1,316 @@
|
||||
|
||||
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>);
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user