Skip to content

Instantly share code, notes, and snippets.

@pemre
Last active April 28, 2026 14:28
Show Gist options
  • Select an option

  • Save pemre/9abbaae452506a195e75aca30f7aae52 to your computer and use it in GitHub Desktop.

Select an option

Save pemre/9abbaae452506a195e75aca30f7aae52 to your computer and use it in GitHub Desktop.
<!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