Last active
April 28, 2026 14:28
-
-
Save pemre/9abbaae452506a195e75aca30f7aae52 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Risk vs Probability Matrix</title> | |
| <!-- Tailwind CSS --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <!-- React & ReactDOM --> | |
| <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script> | |
| <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script> | |
| <!-- Babel for JSX --> | |
| <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> | |
| </head> | |
| <body class="bg-[#f3f4f6]"> | |
| <div id="root"></div> | |
| <script type="text/babel"> | |
| const { useState, useEffect } = React; | |
| // Icons components | |
| const Target = ({ className }) => <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>; | |
| const Users = ({ className }) => <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; | |
| const HelpCircle = ({ className }) => <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" x2="12.01" y1="17" y2="17"/></svg>; | |
| const ArrowRight = ({ className }) => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>; | |
| const ArrowUp = ({ className }) => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>; | |
| const Maximize = ({ className }) => <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>; | |
| const Minimize = ({ className }) => <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/></svg>; | |
| // Parsed and merged data from the provided JSONs | |
| const DATA = [ | |
| { id: '019d9136-b3ae-7e58-a10e-37e814e4a354', label: 'Being cross-platform', avgX: 2.857, avgY: 2.286, color: '#ef4444', points: [{ x: 2, y: 1, c: 1 }, { x: 0, y: 4, c: 1 }, { x: 0, y: 0, c: 1 }, { x: 3, y: 1, c: 1 }, { x: 5, y: 3, c: 2 }, { x: 5, y: 4, c: 1 }] }, | |
| { id: '019d9136-b3b0-7bcb-9181-57774f4a1bb0', label: 'Our remote setup', avgX: 3.429, avgY: 0.571, color: '#f97316', points: [{ x: 1, y: 1, c: 1 }, { x: 4, y: 1, c: 1 }, { x: 0, y: 0, c: 1 }, { x: 4, y: 0, c: 1 }, { x: 5, y: 0, c: 1 }, { x: 5, y: 1, c: 2 }] }, | |
| { id: '019d9136-b3b3-71a4-b6d9-13a8e7582f58', label: 'Lack of organization', avgX: 3.714, avgY: 4.143, color: '#f59e0b', points: [{ x: 2, y: 2, c: 1 }, { x: 4, y: 4, c: 3 }, { x: 5, y: 5, c: 1 }, { x: 3, y: 5, c: 1 }, { x: 4, y: 5, c: 1 }] }, | |
| { id: '019d9136-b3b4-73b7-81d0-2527ebeb636b', label: 'Wrong expectations', avgX: 3.000, avgY: 3.143, color: '#84cc16', points: [{ x: 1, y: 1, c: 1 }, { x: 1, y: 3, c: 1 }, { x: 5, y: 5, c: 1 }, { x: 3, y: 1, c: 1 }, { x: 2, y: 4, c: 1 }, { x: 5, y: 4, c: 1 }, { x: 4, y: 4, c: 1 }] }, | |
| { id: '019d9136-b3b6-728c-8858-7562a2e58735', label: 'Language/communic.', avgX: 2.000, avgY: 2.286, color: '#10b981', points: [{ x: 1, y: 1, c: 1 }, { x: 1, y: 4, c: 1 }, { x: 4, y: 5, c: 1 }, { x: 2, y: 2, c: 2 }, { x: 1, y: 2, c: 1 }, { x: 3, y: 0, c: 1 }] }, | |
| { id: '019d9136-b3b8-7563-b361-4e52f5425ac5', label: 'Weak leadership', avgX: 2.429, avgY: 3.714, color: '#0ea5e9', points: [{ x: 1, y: 1, c: 1 }, { x: 0, y: 4, c: 1 }, { x: 5, y: 5, c: 1 }, { x: 3, y: 4, c: 1 }, { x: 1, y: 4, c: 1 }, { x: 4, y: 5, c: 1 }, { x: 3, y: 3, c: 1 }] }, | |
| { id: '019d9136-b3bb-7c8b-a3a8-dd36336edf66', label: 'Lack of fun', avgX: 1.429, avgY: 2.000, color: '#6366f1', points: [{ x: 2, y: 1, c: 1 }, { x: 0, y: 0, c: 1 }, { x: 1, y: 2, c: 1 }, { x: 1, y: 1, c: 1 }, { x: 0, y: 5, c: 1 }, { x: 3, y: 2, c: 1 }, { x: 3, y: 3, c: 1 }] }, | |
| { id: '019d9136-b3bc-7397-ad5d-5c3844200c1b', label: 'Heavy workload', avgX: 2.714, avgY: 2.429, color: '#d946ef', points: [{ x: 0, y: 0, c: 2 }, { x: 4, y: 4, c: 1 }, { x: 3, y: 3, c: 2 }, { x: 4, y: 3, c: 1 }, { x: 5, y: 4, c: 1 }] }, | |
| { id: '019d9136-b3bf-745a-ba9c-ce176e1915c7', label: 'No self-dev. time', avgX: 1.571, avgY: 0.714, color: '#f43f5e', points: [{ x: 0, y: 0, c: 3 }, { x: 3, y: 1, c: 1 }, { x: 2, y: 1, c: 1 }, { x: 3, y: 0, c: 1 }, { x: 3, y: 3, c: 1 }] } | |
| ]; | |
| const METADATA = { | |
| title: "Rate the risks of our new team", | |
| participants: 7, | |
| xAxis: "Higher probability", | |
| yAxis: "Higher risk" | |
| }; | |
| function App() { | |
| const [hoveredId, setHoveredId] = useState(null); | |
| const [isFullscreen, setIsFullscreen] = useState(false); | |
| const toggleFullscreen = () => { | |
| if (!document.fullscreenElement) { | |
| document.documentElement.requestFullscreen().catch(err => { | |
| console.error(`Error attempting to enable full-screen mode: ${err.message}`); | |
| }); | |
| setIsFullscreen(true); | |
| } else { | |
| document.exitFullscreen(); | |
| setIsFullscreen(false); | |
| } | |
| }; | |
| // Listen for the 'Esc' key or browser-level fullscreen changes | |
| useEffect(() => { | |
| const handler = () => setIsFullscreen(!!document.fullscreenElement); | |
| document.addEventListener('fullscreenchange', handler); | |
| return () => document.removeEventListener('fullscreenchange', handler); | |
| }, []); | |
| // SVG dimensions and scaling | |
| const width = 800; | |
| const height = 800; | |
| const padding = 80; | |
| const innerWidth = width - padding * 2; | |
| const innerHeight = height - padding * 2; | |
| const getX = (val) => padding + (val / 5) * innerWidth; | |
| const getY = (val) => height - padding - (val / 5) * innerHeight; // Invert Y so 5 is top | |
| const hoveredItem = DATA.find(d => d.id === hoveredId); | |
| return ( | |
| <div className={`min-h-screen text-slate-800 font-sans flex flex-col items-center transition-all duration-500 ${isFullscreen ? 'bg-white py-12 px-8' : 'py-10 px-4 sm:px-8'}`}> | |
| {/* Header */} | |
| <div className="max-w-6xl w-full flex justify-between items-end mb-8"> | |
| <div> | |
| <h1 className="text-3xl md:text-4xl font-extrabold text-slate-900 tracking-tight"> | |
| {METADATA.title} | |
| </h1> | |
| <p className="text-slate-500 mt-2 flex items-center gap-2"> | |
| <Users className="w-[18px] h-[18px]" /> | |
| {METADATA.participants} Participants • Explat Team Ice Breaker | |
| </p> | |
| </div> | |
| <div className="hidden md:flex flex-col items-end"> | |
| <button | |
| onClick={toggleFullscreen} | |
| className="group bg-white hover:bg-slate-900 hover:text-white px-5 py-2.5 rounded-full shadow-md border border-slate-200 text-sm font-bold flex items-center gap-2 transition-all active:scale-95" | |
| > | |
| {isFullscreen ? ( | |
| <> | |
| <Minimize className="w-4 h-4 text-indigo-500 group-hover:text-indigo-300"/> | |
| Exit Fullscreen | |
| </> | |
| ) : ( | |
| <> | |
| <Maximize className="w-4 h-4 text-indigo-500 group-hover:text-indigo-300"/> | |
| Fullscreen | |
| </> | |
| )} | |
| </button> | |
| </div> | |
| </div> | |
| <div className="max-w-6xl w-full flex flex-col lg:flex-row gap-8"> | |
| {/* Left: SVG Chart */} | |
| <div className="flex-1 bg-white rounded-3xl shadow-xl shadow-slate-200/50 p-4 border border-slate-100 relative overflow-hidden"> | |
| {/* Y Axis Label with Arrow */} | |
| <div className="absolute left-6 top-8 flex flex-col items-center text-slate-500 gap-2"> | |
| <ArrowUp className="w-5 h-5" /> | |
| <div className="text-xs font-bold uppercase tracking-widest [writing-mode:vertical-rl] rotate-180"> | |
| Higher Risk | |
| </div> | |
| </div> | |
| {/* X Axis Label with Arrow */} | |
| <div className="absolute right-8 bottom-6 flex items-center text-slate-500 gap-2"> | |
| <div className="text-xs font-bold uppercase tracking-widest"> | |
| Higher Probability | |
| </div> | |
| <ArrowRight className="w-5 h-5" /> | |
| </div> | |
| <svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto drop-shadow-sm"> | |
| {/* Grid & Background */} | |
| <rect x={padding} y={padding} width={innerWidth} height={innerHeight} fill="#f8fafc" rx="12" /> | |
| {/* Grid Lines */} | |
| {[0, 1, 2, 3, 4, 5].map((val) => ( | |
| <g key={`grid-${val}`}> | |
| {/* Vertical */} | |
| <line | |
| x1={getX(val)} y1={padding} x2={getX(val)} y2={height - padding} | |
| stroke={val === 2.5 ? "#cbd5e1" : "#e2e8f0"} | |
| strokeWidth={val === 2.5 ? 2 : 1} | |
| strokeDasharray={val === 2.5 ? "none" : "4 4"} | |
| /> | |
| {/* Horizontal */} | |
| <line | |
| x1={padding} y1={getY(val)} x2={width - padding} y2={getY(val)} | |
| stroke={val === 2.5 ? "#cbd5e1" : "#e2e8f0"} | |
| strokeWidth={val === 2.5 ? 2 : 1} | |
| strokeDasharray={val === 2.5 ? "none" : "4 4"} | |
| /> | |
| </g> | |
| ))} | |
| {/* Quadrant Background Tints (More visible opacity=0.1) */} | |
| <rect x={getX(2.5)} y={padding} width={innerWidth/2} height={innerHeight/2} fill="#ef4444" opacity="0.1" className="transition-opacity" /> {/* Top Right: Danger */} | |
| <rect x={padding} y={getY(2.5)} width={innerWidth/2} height={innerHeight/2} fill="#10b981" opacity="0.1" className="transition-opacity" /> {/* Bottom Left: Safe */} | |
| {/* Individual Data Points & Connecting Lines (Spider Web) */} | |
| {DATA.map((item) => { | |
| const isActive = hoveredId === item.id; | |
| if (!isActive) return null; | |
| return ( | |
| <g key={`lines-${item.id}`} className="transition-all duration-300"> | |
| {item.points.map((pt, idx) => ( | |
| <g key={`pt-${idx}`}> | |
| {/* Connection Line */} | |
| <line | |
| x1={getX(item.avgX)} | |
| y1={getY(item.avgY)} | |
| x2={getX(pt.x)} | |
| y2={getY(pt.y)} | |
| stroke={item.color} | |
| strokeWidth="1.5" | |
| strokeDasharray="4 4" | |
| opacity="0.4" | |
| /> | |
| {/* Vote Dot */} | |
| <circle | |
| cx={getX(pt.x)} | |
| cy={getY(pt.y)} | |
| r={4 + (pt.c - 1) * 2} // Slightly larger if multiple people voted same | |
| fill={item.color} | |
| opacity="0.8" | |
| /> | |
| {/* Count label if stacked */} | |
| {pt.c > 1 && ( | |
| <text | |
| x={getX(pt.x)} | |
| y={getY(pt.y) + 1} | |
| fontSize="9" | |
| fontWeight="bold" | |
| fill="white" | |
| textAnchor="middle" | |
| dominantBaseline="middle" | |
| > | |
| {pt.c} | |
| </text> | |
| )} | |
| </g> | |
| ))} | |
| </g> | |
| ); | |
| })} | |
| {/* Main Average Nodes with Labels */} | |
| {DATA.map((item, i) => { | |
| const isHovered = hoveredId === item.id; | |
| const isDimmed = hoveredId !== null && hoveredId !== item.id; | |
| const labelText = item.label.length > 14 ? item.label.substring(0, 11) + '...' : item.label; | |
| return ( | |
| <g | |
| key={`node-${item.id}`} | |
| className="transition-all duration-300 cursor-pointer" | |
| style={{ opacity: isDimmed ? 0.2 : 1 }} | |
| onMouseEnter={() => setHoveredId(item.id)} | |
| onMouseLeave={() => setHoveredId(null)} | |
| > | |
| {/* Outer pulse ring on hover */} | |
| {isHovered && ( | |
| <circle | |
| cx={getX(item.avgX)} | |
| cy={getY(item.avgY)} | |
| r="24" | |
| fill={item.color} | |
| opacity="0.2" | |
| className="animate-pulse" | |
| /> | |
| )} | |
| {/* Main Circle */} | |
| <circle | |
| cx={getX(item.avgX)} | |
| cy={getY(item.avgY)} | |
| r={isHovered ? "18" : "16"} | |
| fill={item.color} | |
| stroke="white" | |
| strokeWidth="3" | |
| className="drop-shadow-md transition-all duration-300" | |
| /> | |
| {/* Number inside circle */} | |
| <text | |
| x={getX(item.avgX)} | |
| y={getY(item.avgY) + 1} | |
| fontSize="13" | |
| fontWeight="800" | |
| fill="white" | |
| textAnchor="middle" | |
| dominantBaseline="middle" | |
| > | |
| {i + 1} | |
| </text> | |
| {/* Pill Label under the node */} | |
| <g className="drop-shadow-sm"> | |
| <rect | |
| x={getX(item.avgX) - 50} | |
| y={getY(item.avgY) + 23} | |
| width="100" | |
| height="18" | |
| rx="8" | |
| fill="white" | |
| /> | |
| <text | |
| x={getX(item.avgX)} | |
| y={getY(item.avgY) + 33} | |
| fontSize="11" | |
| fontWeight="600" | |
| fill="#334155" | |
| textAnchor="middle" | |
| dominantBaseline="middle" | |
| > | |
| {labelText} | |
| </text> | |
| </g> | |
| </g> | |
| ); | |
| })} | |
| </svg> | |
| </div> | |
| {/* Right: Legend & Sidebar */} | |
| <div className="w-full lg:w-96 flex flex-col gap-6"> | |
| {/* Dynamic Insight Card Container - Fixed height (280px) to prevent jumping */} | |
| <div className="hidden lg:block h-[280px] relative w-full"> | |
| <div className={`absolute inset-0 bg-white rounded-2xl shadow-lg border-t-4 p-6 transition-all duration-300 ${hoveredItem ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4 pointer-events-none'}`} | |
| style={{ borderColor: hoveredItem?.color || '#cbd5e1' }}> | |
| <h3 className="text-sm font-bold text-slate-400 uppercase tracking-wider mb-1">Risk Detail</h3> | |
| <h2 className="text-xl font-bold text-slate-800 mb-4">{hoveredItem?.label || 'Hover an item'}</h2> | |
| {hoveredItem && ( | |
| <div className="grid grid-cols-2 gap-4"> | |
| <div className="bg-slate-50 p-3 rounded-xl border border-slate-100"> | |
| <div className="text-xs text-slate-500 mb-1">Avg Probability</div> | |
| <div className="text-2xl font-bold" style={{ color: hoveredItem.color }}> | |
| {hoveredItem.avgX.toFixed(1)} <span className="text-sm text-slate-400 font-normal">/ 5</span> | |
| </div> | |
| </div> | |
| <div className="bg-slate-50 p-3 rounded-xl border border-slate-100"> | |
| <div className="text-xs text-slate-500 mb-1">Avg Risk</div> | |
| <div className="text-2xl font-bold" style={{ color: hoveredItem.color }}> | |
| {hoveredItem.avgY.toFixed(1)} <span className="text-sm text-slate-400 font-normal">/ 5</span> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <div className="mt-4 pt-4 border-t border-slate-100 text-sm text-slate-500 flex items-start gap-2"> | |
| <HelpCircle className="shrink-0 mt-0.5 text-slate-400 w-4 h-4"/> | |
| <p>Showing {hoveredItem?.points?.length} response clusters. Connected dots represent individual participant votes.</p> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Legend List */} | |
| <div className="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden flex-1 flex flex-col"> | |
| <div className="p-4 border-b border-slate-100 bg-slate-50 flex justify-between items-center"> | |
| <h3 className="font-bold text-slate-700">Ranked Risks</h3> | |
| <span className="text-xs font-semibold text-slate-400 uppercase">Avg (X, Y)</span> | |
| </div> | |
| <div className="overflow-y-auto p-2 flex-1 space-y-1 max-h-[500px]"> | |
| {DATA.map((item, i) => ( | |
| <div | |
| key={item.id} | |
| onMouseEnter={() => setHoveredId(item.id)} | |
| onMouseLeave={() => setHoveredId(null)} | |
| className={`flex items-center justify-between p-2 rounded-xl cursor-pointer transition-all duration-200 ${ | |
| hoveredId === item.id | |
| ? 'bg-slate-200 scale-[1.02] shadow-sm' | |
| : 'hover:bg-slate-200' | |
| }`} | |
| > | |
| <div className="flex items-center gap-3"> | |
| <div | |
| className="w-8 h-8 rounded-full text-white flex items-center justify-center font-bold text-sm shadow-sm shrink-0" | |
| style={{ backgroundColor: item.color }} | |
| > | |
| {i + 1} | |
| </div> | |
| <span className={`font-medium ${hoveredId === item.id ? 'text-slate-900' : 'text-slate-700'}`}> | |
| {item.label} | |
| </span> | |
| </div> | |
| <div className="text-xs font-mono font-medium text-slate-400 bg-white px-2 py-1 rounded-md border border-slate-100 whitespace-nowrap ml-2"> | |
| {item.avgX.toFixed(1)}, {item.avgY.toFixed(1)} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| const root = ReactDOM.createRoot(document.getElementById('root')); | |
| root.render(<App />); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment