| <!DOCTYPE html>
|
| <html lang="en" class="dark">
|
| <head>
|
| <meta charset="UTF-8">
|
| <meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| <title>Hierarchical Clustering Simulator</title>
|
|
|
|
|
| <script src="https://cdn.tailwindcss.com"></script>
|
| <script>
|
| tailwind.config = {
|
| darkMode: 'class',
|
| theme: {
|
| extend: {
|
| colors: {
|
| border: "hsl(var(--border))",
|
| input: "hsl(var(--input))",
|
| ring: "hsl(var(--ring))",
|
| background: "hsl(var(--background))",
|
| foreground: "hsl(var(--foreground))",
|
| primary: {
|
| DEFAULT: "hsl(var(--primary))",
|
| foreground: "hsl(var(--primary-foreground))",
|
| },
|
| secondary: {
|
| DEFAULT: "hsl(var(--secondary))",
|
| foreground: "hsl(var(--secondary-foreground))",
|
| },
|
| muted: {
|
| DEFAULT: "hsl(var(--muted))",
|
| foreground: "hsl(var(--muted-foreground))",
|
| },
|
| accent: {
|
| DEFAULT: "hsl(var(--accent))",
|
| foreground: "hsl(var(--accent-foreground))",
|
| },
|
| canvas: {
|
| DEFAULT: "hsl(var(--canvas))",
|
| grid: "hsl(var(--canvas-grid))",
|
| }
|
| }
|
| }
|
| }
|
| }
|
| </script>
|
|
|
|
|
| <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>
|
| <script src="https://unpkg.com/framer-motion@10.16.4/dist/framer-motion.js"></script>
|
| <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
|
| <script src="https://unpkg.com/lucide@latest"></script>
|
|
|
| <style>
|
| :root {
|
| --background: 224 71% 4%;
|
| --foreground: 213 31% 91%;
|
| --primary: 263.4 70% 50.4%;
|
| --primary-foreground: 210 40% 98%;
|
| --secondary: 222.2 47.4% 11.2%;
|
| --secondary-foreground: 210 40% 98%;
|
| --muted: 217.2 32.6% 17.5%;
|
| --muted-foreground: 215 20.2% 65.1%;
|
| --accent: 330 80% 60%;
|
| --accent-foreground: 210 40% 98%;
|
| --border: 217.2 32.6% 17.5%;
|
| --canvas: 224 71% 5%;
|
| --canvas-grid: 217.2 32.6% 17.5%;
|
| }
|
|
|
| body {
|
| background-color: hsl(var(--background));
|
| color: hsl(var(--foreground));
|
| font-family: system-ui, -apple-system, sans-serif;
|
| }
|
|
|
| .glass-card {
|
| background: rgba(30, 41, 59, 0.5);
|
| backdrop-filter: blur(12px);
|
| border: 1px solid hsl(var(--border));
|
| border-radius: 1rem;
|
| }
|
|
|
| .gradient-text {
|
| background: linear-gradient(to right, hsl(var(--primary)), hsl(var(--accent)));
|
| -webkit-background-clip: text;
|
| -webkit-text-fill-color: transparent;
|
| }
|
| </style>
|
| </head>
|
| <body>
|
| <div id="root"></div>
|
|
|
| <script type="text/babel">
|
| const { useState, useEffect, useCallback, useMemo } = React;
|
| const { motion, AnimatePresence } = window.Motion;
|
|
|
|
|
| function cn(...classes) {
|
| return classes.filter(Boolean).join(' ');
|
| }
|
|
|
|
|
|
|
| const Icon = ({ name, size = 24, className, ...props }) => {
|
| const LucideIcon = window.lucide && window.lucide.icons ? window.lucide.icons[name] : null;
|
| if (!LucideIcon) return null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| return null;
|
| };
|
|
|
|
|
| const Icons = {
|
| Play: (props) => <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" {...props}><polygon points="5 3 19 12 5 21 5 3"/></svg>,
|
| Pause: (props) => <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" {...props}><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>,
|
| SkipForward: (props) => <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" {...props}><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" x2="19" y1="5" y2="19"/></svg>,
|
| SkipBack: (props) => <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" {...props}><polygon points="19 20 9 12 19 4 19 20"/><line x1="5" x2="5" y1="19" y2="5"/></svg>,
|
| RotateCcw: (props) => <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" {...props}><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>,
|
| Shuffle: (props) => <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" {...props}><path d="M2 18h1.4c1.3 0 2.5-.6 3.3-1.7l14.2-12.6"/><path d="M22 6h-1.4c-1.3 0-2.5.6-3.3 1.7L3.1 20.3"/><path d="M2 6h1.4c1.3 0 2.5.6 3.3 1.7l2.5 2.3"/><path d="M22 18h-1.4c-1.3 0-2.5-.6-3.3-1.7l-2.5-2.3"/></svg>,
|
| GitMerge: (props) => <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" {...props}><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></svg>,
|
| Scissors: (props) => <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" {...props}><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg>,
|
| Info: (props) => <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" {...props}><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>,
|
| ArrowRight: (props) => <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" {...props}><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>,
|
| CheckCircle: (props) => <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" {...props}><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>,
|
| Target: (props) => <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" {...props}><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>,
|
| Layers: (props) => <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" {...props}><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></svg>,
|
| BookOpen: (props) => <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" {...props}><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>,
|
| SplitSquareVertical: (props) => <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" {...props}><path d="M5 8V5c0-1 1-2 2-2h10c1 0 2 1 2 2v3"/><path d="M19 16v3c0 1-1 2-2 2H7c-1 0-2-1-2-2v-3"/><line x1="4" x2="20" y1="12" y2="12"/></svg>,
|
| CircleDot: (props) => <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" {...props}><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>,
|
| };
|
|
|
| const CLUSTER_COLORS = [
|
| "hsl(340, 80%, 55%)", "hsl(200, 85%, 50%)", "hsl(45, 95%, 50%)",
|
| "hsl(160, 70%, 45%)", "hsl(280, 75%, 55%)", "hsl(20, 90%, 55%)",
|
| "hsl(100, 70%, 45%)", "hsl(320, 75%, 50%)",
|
| ];
|
|
|
|
|
| function generateRandomPoints(count, width, height) {
|
| const padding = 60;
|
| return Array.from({ length: count }, (_, i) => ({
|
| id: i,
|
| x: padding + Math.random() * (width - padding * 2),
|
| y: padding + Math.random() * (height - padding * 2),
|
| clusterId: i,
|
| }));
|
| }
|
|
|
| function calculateDistance(p1, p2) {
|
| return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
|
| }
|
|
|
| function getCentroid(points, pointIds) {
|
| const clusterPoints = points.filter(p => pointIds.includes(p.id));
|
| if (clusterPoints.length === 0) return { x: 0, y: 0 };
|
| const sumX = clusterPoints.reduce((sum, p) => sum + p.x, 0);
|
| const sumY = clusterPoints.reduce((sum, p) => sum + p.y, 0);
|
| return {
|
| x: sumX / clusterPoints.length,
|
| y: sumY / clusterPoints.length,
|
| };
|
| }
|
|
|
|
|
| function initializeClusters(points) {
|
| return points.map((p, i) => ({
|
| id: p.id,
|
| points: [p.id],
|
| color: CLUSTER_COLORS[i % CLUSTER_COLORS.length],
|
| centroid: { x: p.x, y: p.y },
|
| }));
|
| }
|
|
|
| function findClosestClusters(clusters, points) {
|
| if (clusters.length < 2) return null;
|
| let minDistance = Infinity;
|
| let closest = null;
|
|
|
| for (let i = 0; i < clusters.length; i++) {
|
| for (let j = i + 1; j < clusters.length; j++) {
|
| const centroid1 = getCentroid(points, clusters[i].points);
|
| const centroid2 = getCentroid(points, clusters[j].points);
|
| const distance = calculateDistance(centroid1, centroid2);
|
|
|
| if (distance < minDistance) {
|
| minDistance = distance;
|
| closest = { cluster1: clusters[i], cluster2: clusters[j], distance };
|
| }
|
| }
|
| }
|
| return closest;
|
| }
|
|
|
| function mergeClusters(clusters, cluster1Id, cluster2Id, newClusterId, points) {
|
| const cluster1 = clusters.find(c => c.id === cluster1Id);
|
| const cluster2 = clusters.find(c => c.id === cluster2Id);
|
| if (!cluster1 || !cluster2) return clusters;
|
|
|
| const mergedPoints = [...cluster1.points, ...cluster2.points];
|
| const newCentroid = getCentroid(points, mergedPoints);
|
|
|
| const newCluster = {
|
| id: newClusterId,
|
| points: mergedPoints,
|
| color: cluster1.color,
|
| centroid: newCentroid,
|
| };
|
|
|
| return clusters.filter(c => c.id !== cluster1Id && c.id !== cluster2Id).concat(newCluster);
|
| }
|
|
|
| function generateAllMergeSteps(points) {
|
| const steps = [];
|
| let clusters = initializeClusters(points);
|
| let nextClusterId = points.length;
|
|
|
| while (clusters.length > 1) {
|
| const closest = findClosestClusters(clusters, points);
|
| if (!closest) break;
|
|
|
| const { cluster1, cluster2, distance } = closest;
|
| const step = {
|
| cluster1Id: cluster1.id,
|
| cluster2Id: cluster2.id,
|
| newClusterId: nextClusterId,
|
| distance,
|
| explanation: `Merging clusters with ${cluster1.points.length} and ${cluster2.points.length} points (distance: ${distance.toFixed(1)}px)`,
|
| };
|
|
|
| steps.push(step);
|
| clusters = mergeClusters(clusters, cluster1.id, cluster2.id, nextClusterId, points);
|
| nextClusterId++;
|
| }
|
| return steps;
|
| }
|
|
|
| function buildDendrogram(points, steps) {
|
| const nodes = new Map();
|
| points.forEach(p => {
|
| nodes.set(p.id, { id: p.id, height: 0, points: [p.id] });
|
| });
|
|
|
| steps.forEach((step, index) => {
|
| const left = nodes.get(step.cluster1Id);
|
| const right = nodes.get(step.cluster2Id);
|
| const newNode = {
|
| id: step.newClusterId,
|
| left, right,
|
| height: index + 1,
|
| points: [...left.points, ...right.points],
|
| };
|
| nodes.set(step.newClusterId, newNode);
|
| });
|
|
|
| if (steps.length > 0) return nodes.get(steps[steps.length - 1].newClusterId);
|
| return nodes.get(0);
|
| }
|
|
|
|
|
| function initializeSingleCluster(points) {
|
| if (points.length === 0) return [];
|
| const allPointIds = points.map(p => p.id);
|
| return [{
|
| id: 0,
|
| points: allPointIds,
|
| color: CLUSTER_COLORS[0],
|
| centroid: getCentroid(points, allPointIds),
|
| }];
|
| }
|
|
|
| function splitCluster(cluster, points, newId1, newId2, colorIndex) {
|
| const clusterPoints = points.filter(p => cluster.points.includes(p.id));
|
|
|
|
|
| let maxDistance = -1;
|
| let p1 = clusterPoints[0];
|
| let p2 = clusterPoints[1];
|
|
|
| for (let i = 0; i < clusterPoints.length; i++) {
|
| for (let j = i + 1; j < clusterPoints.length; j++) {
|
| const dist = calculateDistance(clusterPoints[i], clusterPoints[j]);
|
| if (dist > maxDistance) {
|
| maxDistance = dist;
|
| p1 = clusterPoints[i];
|
| p2 = clusterPoints[j];
|
| }
|
| }
|
| }
|
|
|
| const group1 = [];
|
| const group2 = [];
|
|
|
| for (const p of clusterPoints) {
|
| const dist1 = calculateDistance(p, p1);
|
| const dist2 = calculateDistance(p, p2);
|
| if (dist1 <= dist2) group1.push(p.id);
|
| else group2.push(p.id);
|
| }
|
|
|
| return {
|
| cluster1: {
|
| id: newId1, points: group1, color: cluster.color, centroid: getCentroid(points, group1)
|
| },
|
| cluster2: {
|
| id: newId2, points: group2, color: CLUSTER_COLORS[colorIndex % CLUSTER_COLORS.length], centroid: getCentroid(points, group2)
|
| }
|
| };
|
| }
|
|
|
| function findClusterWithHighestVariance(clusters, points) {
|
| const splittable = clusters.filter(c => c.points.length > 1);
|
| if (splittable.length === 0) return null;
|
|
|
| let maxVar = -1;
|
| let target = null;
|
|
|
| for (const c of splittable) {
|
| const pts = points.filter(p => c.points.includes(p.id));
|
| const centroid = getCentroid(points, c.points);
|
|
|
| const variance = pts.reduce((sum, p) => sum + Math.pow(calculateDistance(p, centroid), 2), 0) / pts.length;
|
|
|
| if (variance > maxVar) {
|
| maxVar = variance;
|
| target = c;
|
| }
|
| }
|
| return target;
|
| }
|
|
|
| function applySplit(clusters, parentId, child1Id, child2Id, points, colorIndex) {
|
| const parent = clusters.find(c => c.id === parentId);
|
| if (!parent || parent.points.length < 2) return clusters;
|
| const { cluster1, cluster2 } = splitCluster(parent, points, child1Id, child2Id, colorIndex);
|
| return clusters.filter(c => c.id !== parentId).concat([cluster1, cluster2]);
|
| }
|
|
|
| function generateAllSplitSteps(points) {
|
| if (points.length <= 1) return [];
|
| const steps = [];
|
| let clusters = initializeSingleCluster(points);
|
| let nextClusterId = 1;
|
| let colorIndex = 1;
|
|
|
| while (true) {
|
| const target = findClusterWithHighestVariance(clusters, points);
|
| if (!target) break;
|
|
|
| const child1Id = nextClusterId++;
|
| const child2Id = nextClusterId++;
|
| steps.push({
|
| parentClusterId: target.id,
|
| child1Id, child2Id,
|
| explanation: `Splitting cluster with ${target.points.length} points into two smaller clusters`,
|
| });
|
| clusters = applySplit(clusters, target.id, child1Id, child2Id, points, colorIndex++);
|
| }
|
| return steps;
|
| }
|
|
|
| function buildDivisiveDendrogram(points, steps) {
|
| if (points.length === 0) return null;
|
| if (points.length === 1) return { id: points[0].id, height: 0, points: [points[0].id] };
|
|
|
| const nodes = new Map();
|
| const totalSteps = steps.length;
|
|
|
| const root = { id: 0, height: totalSteps, points: points.map(p => p.id) };
|
| nodes.set(0, root);
|
|
|
| steps.forEach((step, index) => {
|
| const parent = nodes.get(step.parentClusterId);
|
| if (!parent) return;
|
|
|
|
|
|
|
| let tempClusters = initializeSingleCluster(points);
|
| for(let i=0; i<=index; i++) {
|
| const s = steps[i];
|
| tempClusters = applySplit(tempClusters, s.parentClusterId, s.child1Id, s.child2Id, points, i+1);
|
| }
|
| const c1 = tempClusters.find(c => c.id === step.child1Id);
|
| const c2 = tempClusters.find(c => c.id === step.child2Id);
|
|
|
| if (c1 && c2) {
|
| const node1 = { id: step.child1Id, height: totalSteps - index - 1, points: c1.points };
|
| const node2 = { id: step.child2Id, height: totalSteps - index - 1, points: c2.points };
|
| parent.left = node1;
|
| parent.right = node2;
|
| nodes.set(step.child1Id, node1);
|
| nodes.set(step.child2Id, node2);
|
| }
|
| });
|
| return root;
|
| }
|
|
|
|
|
|
|
| const Button = ({ children, onClick, disabled, variant = "primary", size = "default", className = "" }) => {
|
| const baseStyles = "inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50";
|
| const variants = {
|
| primary: "bg-primary text-primary-foreground hover:bg-primary/90 shadow",
|
| secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
| outline: "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
| icon: "hover:bg-accent hover:text-accent-foreground",
|
| };
|
| const sizes = {
|
| default: "h-9 px-4 py-2 text-sm",
|
| sm: "h-8 rounded-md px-3 text-xs",
|
| icon: "h-9 w-9",
|
| };
|
| return (
|
| <button
|
| onClick={onClick}
|
| disabled={disabled}
|
| className={cn(baseStyles, variants[variant], sizes[size], className)}
|
| >
|
| {children}
|
| </button>
|
| );
|
| };
|
|
|
| const Dendrogram = ({ root, currentStep, totalSteps }) => {
|
| if (!root) return null;
|
| const width = 300;
|
| const height = 280;
|
| const padding = { top: 30, bottom: 40, left: 30, right: 30 };
|
|
|
| const getLeafCount = (node) => {
|
| if (!node.left && !node.right) return 1;
|
| return (node.left ? getLeafCount(node.left) : 0) + (node.right ? getLeafCount(node.right) : 0);
|
| };
|
|
|
| const leafCount = getLeafCount(root);
|
| const leafWidth = (width - padding.left - padding.right) / (leafCount || 1);
|
| const heightScale = (height - padding.top - padding.bottom) / (totalSteps || 1);
|
| let leafIndex = 0;
|
|
|
| const renderNode = (node) => {
|
| const y = height - padding.bottom - node.height * heightScale;
|
| const isVisible = node.height <= currentStep;
|
|
|
| if (!node.left && !node.right) {
|
| const x = padding.left + leafIndex * leafWidth + leafWidth / 2;
|
| leafIndex++;
|
| return {
|
| x,
|
| element: (
|
| <g key={`leaf-${node.id}`}>
|
| <motion.circle cx={x} cy={height - padding.bottom} r={8} fill="hsl(var(--primary))" initial={ {scale:0} } animate={ {scale:1} } />
|
| <text x={x} y={height - padding.bottom + 25} textAnchor="middle" className="text-xs fill-white font-medium">P{node.id + 1}</text>
|
| </g>
|
| )
|
| };
|
| }
|
|
|
| const leftRes = node.left ? renderNode(node.left) : null;
|
| const rightRes = node.right ? renderNode(node.right) : null;
|
|
|
| const x = (leftRes.x + rightRes.x) / 2;
|
| const leftY = node.left ? height - padding.bottom - node.left.height * heightScale : height - padding.bottom;
|
| const rightY = node.right ? height - padding.bottom - node.right.height * heightScale : height - padding.bottom;
|
|
|
| return {
|
| x,
|
| element: (
|
| <g key={`node-${node.id}`}>
|
| {leftRes?.element} {rightRes?.element}
|
| {isVisible && (
|
| <>
|
| <motion.line x1={leftRes.x} y1={leftY} x2={leftRes.x} y2={y} stroke="hsl(var(--primary))" strokeWidth={2} initial={ {pathLength:0} } animate={ {pathLength:1} } />
|
| <motion.line x1={rightRes.x} y1={rightY} x2={rightRes.x} y2={y} stroke="hsl(var(--primary))" strokeWidth={2} initial={ {pathLength:0} } animate={ {pathLength:1} } />
|
| <motion.line x1={leftRes.x} y1={y} x2={rightRes.x} y2={y} stroke="hsl(var(--primary))" strokeWidth={2} initial={ {pathLength:0} } animate={ {pathLength:1} } />
|
| <motion.circle cx={x} cy={y} r={5} fill="hsl(var(--accent))" initial={ {scale:0} } animate={ {scale:1} } />
|
| </>
|
| )}
|
| </g>
|
| )
|
| };
|
| };
|
|
|
| leafIndex = 0;
|
| const tree = renderNode(root);
|
|
|
| return (
|
| <div className="glass-card p-4">
|
| <h3 className="text-sm font-semibold mb-3">Dendrogram</h3>
|
| <svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="xMidYMid meet">
|
| {tree.element}
|
| </svg>
|
| </div>
|
| );
|
| };
|
|
|
| const ClusteringCanvas = ({ points, clusters, currentStep, steps, highlightedClusters = [] }) => {
|
| const getPointCluster = (id) => clusters.find(c => c.points.includes(id));
|
| const currentMerge = currentStep > 0 && currentStep <= steps.length ? steps[currentStep - 1] : null;
|
|
|
| return (
|
| <div className="relative w-full h-[400px] md:h-[500px] rounded-2xl overflow-hidden bg-canvas border border-border/50">
|
| {/* Grid */}
|
| <div className="absolute inset-0 opacity-20"
|
| style={ {backgroundImage: 'radial-gradient(hsl(var(--canvas-grid)) 1px, transparent 1px)', backgroundSize: '30px 30px'} }>
|
| </div>
|
|
|
| <svg className="absolute inset-0 w-full h-full pointer-events-none">
|
| <AnimatePresence>
|
| {/* Merge Lines */}
|
| {currentMerge && clusters.map(c1 =>
|
| clusters.filter(c2 => c2.id > c1.id).map(c2 => {
|
| const isHighlighted = (c1.id === currentMerge.cluster1Id && c2.id === currentMerge.cluster2Id) || (c1.id === currentMerge.cluster2Id && c2.id === currentMerge.cluster1Id);
|
| if(!isHighlighted) return null;
|
| const cent1 = getCentroid(points, c1.points);
|
| const cent2 = getCentroid(points, c2.points);
|
| return (
|
| <motion.line key={`merge-${c1.id}-${c2.id}`} x1={cent1.x} y1={cent1.y} x2={cent2.x} y2={cent2.y}
|
| stroke="hsl(var(--primary))" strokeWidth="3" strokeDasharray="8,4"
|
| initial={ {opacity:0, pathLength:0} } animate={ {opacity:1, pathLength:1} } exit={ {opacity:0} } />
|
| )
|
| })
|
| )}
|
|
|
| {/* Hulls */}
|
| {clusters.filter(c => c.points.length > 1).map(c => {
|
| const pts = points.filter(p => c.points.includes(p.id));
|
| const cent = getCentroid(points, c.points);
|
| const maxDist = Math.max(...pts.map(p => calculateDistance(p, cent)));
|
| const isHigh = highlightedClusters.includes(c.id);
|
| return (
|
| <motion.circle key={`hull-${c.id}`} cx={cent.x} cy={cent.y} r={maxDist + 30}
|
| fill={c.color} fillOpacity={isHigh ? 0.2 : 0.1}
|
| stroke={c.color} strokeWidth={isHigh ? 3 : 2} strokeOpacity={isHigh ? 0.8 : 0.4}
|
| initial={ {scale:0, opacity:0} } animate={ {scale:1, opacity:1} } exit={ {scale:0, opacity:0} }
|
| />
|
| );
|
| })}
|
| </AnimatePresence>
|
| </svg>
|
|
|
| <AnimatePresence>
|
| {points.map(p => {
|
| const cluster = getPointCluster(p.id);
|
| const isHigh = cluster && highlightedClusters.includes(cluster.id);
|
| return (
|
| <motion.div key={p.id} className="absolute rounded-full flex items-center justify-center font-bold text-[10px]"
|
| style={ {
|
| left: p.x - 12, top: p.y - 12, width: 24, height: 24,
|
| backgroundColor: cluster?.color || "hsl(var(--primary))",
|
| boxShadow: isHigh ? `0 0 20px ${cluster?.color}` : 'none'
|
| } }
|
| initial={ {scale:0} } animate={ {scale: isHigh ? 1.3 : 1, opacity: 1} } whileHover={ {scale: 1.2} }>
|
| {p.id + 1}
|
| </motion.div>
|
| )
|
| })}
|
| </AnimatePresence>
|
|
|
| <div className="absolute bottom-4 left-4 glass-card px-4 py-2 text-sm font-medium text-muted-foreground">
|
| Step {currentStep} of {steps.length}
|
| </div>
|
| </div>
|
| );
|
| };
|
|
|
| const DivisiveCanvas = ({ points, clusters, currentStep, steps, highlightedCluster }) => {
|
| const currentSplit = currentStep > 0 && currentStep <= steps.length ? steps[currentStep - 1] : null;
|
|
|
| return (
|
| <div className="relative w-full h-[400px] md:h-[500px] rounded-2xl overflow-hidden bg-canvas border border-border/50">
|
| <div className="absolute inset-0 opacity-20"
|
| style={ {backgroundImage: 'radial-gradient(hsl(var(--canvas-grid)) 1px, transparent 1px)', backgroundSize: '30px 30px'} }>
|
| </div>
|
|
|
| <svg className="absolute inset-0 w-full h-full pointer-events-none">
|
| <AnimatePresence>
|
| {/* Split Visual */}
|
| {currentSplit && (() => {
|
| const c1 = clusters.find(c => c.id === currentSplit.child1Id);
|
| const c2 = clusters.find(c => c.id === currentSplit.child2Id);
|
| if (!c1 || !c2) return null;
|
| const cent1 = getCentroid(points, c1.points);
|
| const cent2 = getCentroid(points, c2.points);
|
| const midX = (cent1.x + cent2.x) / 2;
|
| const midY = (cent1.y + cent2.y) / 2;
|
| const dx = cent2.x - cent1.x;
|
| const dy = cent2.y - cent1.y;
|
| const len = Math.sqrt(dx*dx + dy*dy);
|
| const px = -dy/len * 100;
|
| const py = dx/len * 100;
|
|
|
| return (
|
| <g key={`split-${currentStep}`}>
|
| <motion.line x1={midX - px} y1={midY - py} x2={midX + px} y2={midY + py}
|
| stroke="hsl(var(--accent))" strokeWidth="3" strokeDasharray="8,4"
|
| initial={ {opacity:0, pathLength:0} } animate={ {opacity:1, pathLength:1} } exit={ {opacity:0} } />
|
| </g>
|
| );
|
| })()}
|
|
|
| {/* Hulls */}
|
| {clusters.map(c => {
|
| const pts = points.filter(p => c.points.includes(p.id));
|
| const cent = getCentroid(points, c.points);
|
| const maxDist = pts.length > 1 ? Math.max(...pts.map(p => calculateDistance(p, cent))) : 0;
|
| const isHigh = c.id === highlightedCluster;
|
| const isSingle = c.points.length === 1;
|
| return (
|
| <motion.circle key={`hull-${c.id}`} cx={cent.x} cy={cent.y} r={isSingle ? 25 : maxDist + 30}
|
| fill={c.color} fillOpacity={isHigh ? 0.25 : 0.1}
|
| stroke={c.color} strokeWidth={isHigh ? 3 : 2} strokeOpacity={isHigh ? 0.9 : 0.4} strokeDasharray={isHigh ? "8,4" : "none"}
|
| initial={ {scale:0, opacity:0} } animate={ {scale:1, opacity:1} } exit={ {scale:0, opacity:0} }
|
| />
|
| );
|
| })}
|
| </AnimatePresence>
|
| </svg>
|
|
|
| <AnimatePresence>
|
| {points.map(p => {
|
| const c = clusters.find(c => c.points.includes(p.id));
|
| const isHigh = c && c.id === highlightedCluster;
|
| return (
|
| <motion.div key={p.id} className="absolute rounded-full flex items-center justify-center font-bold text-[10px]"
|
| style={ {
|
| left: p.x - 12, top: p.y - 12, width: 24, height: 24,
|
| backgroundColor: c?.color || "hsl(var(--primary))",
|
| boxShadow: isHigh ? `0 0 20px ${c?.color}` : 'none'
|
| } }
|
| initial={ {scale:0} } animate={ {scale: isHigh ? 1.3 : 1, opacity: 1} }>
|
| {p.id + 1}
|
| </motion.div>
|
| )
|
| })}
|
| </AnimatePresence>
|
| <div className="absolute top-4 left-4 glass-card px-3 py-1.5 flex items-center gap-2 text-xs font-medium text-accent">
|
| <Icons.Scissors className="w-4 h-4" /> Divisive Mode
|
| </div>
|
| </div>
|
| )
|
| }
|
|
|
| const ControlPanel = ({ isPlaying, currentStep, totalSteps, onPlay, onPause, onNext, onPrev, onReset, onRandomize }) => (
|
| <motion.div className="glass-card p-4 flex flex-col gap-4" initial={ {opacity:0, y:20} } animate={ {opacity:1, y:0} }>
|
| <h3 className="text-sm font-semibold">Controls</h3>
|
| <div className="flex items-center justify-center gap-2">
|
| <Button variant="outline" size="icon" onClick={onPrev} disabled={currentStep === 0}><Icons.SkipBack className="h-4 w-4" /></Button>
|
| <Button size="icon" className="h-12 w-12 rounded-full" onClick={isPlaying ? onPause : onPlay} disabled={currentStep >= totalSteps}>
|
| {isPlaying ? <Icons.Pause className="h-5 w-5" /> : <Icons.Play className="h-5 w-5 ml-0.5" />}
|
| </Button>
|
| <Button variant="outline" size="icon" onClick={onNext} disabled={currentStep >= totalSteps}><Icons.SkipForward className="h-4 w-4" /></Button>
|
| </div>
|
| <div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
| <motion.div className="h-full bg-primary" initial={ {width:0} } animate={ {width: `${(currentStep/totalSteps)*100}%`} } />
|
| </div>
|
| <div className="flex gap-2">
|
| <Button variant="secondary" size="sm" onClick={onReset} className="flex-1"><Icons.RotateCcw className="h-4 w-4 mr-1"/> Reset</Button>
|
| <Button variant="secondary" size="sm" onClick={onRandomize} className="flex-1"><Icons.Shuffle className="h-4 w-4 mr-1"/> New Data</Button>
|
| </div>
|
| </motion.div>
|
| );
|
|
|
| const ExplanationPanel = ({ currentStep, steps, totalClusters, mode }) => {
|
| const currentOp = currentStep > 0 && currentStep <= steps.length ? steps[currentStep - 1] : null;
|
| const isComplete = currentStep >= steps.length && steps.length > 0;
|
|
|
| return (
|
| <motion.div className="glass-card p-5" initial={ {opacity:0, y:20} } animate={ {opacity:1, y:0} } transition={ {delay:0.2} }>
|
| <h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
| <Icons.Info className="h-4 w-4 text-accent"/> What's Happening?
|
| </h3>
|
| <AnimatePresence mode="wait">
|
| {currentStep === 0 ? (
|
| <motion.div key="start" initial={ {opacity:0, x:-10} } animate={ {opacity:1, x:0} } exit={ {opacity:0, x:10} }>
|
| <p className="text-sm font-medium mb-1">Starting Point</p>
|
| <p className="text-sm text-muted-foreground">
|
| {mode === 'agglomerative'
|
| ? "Each point is its own cluster. We will merge the closest ones."
|
| : "All points start in one big cluster. We will split the widest spread cluster."}
|
| </p>
|
| </motion.div>
|
| ) : isComplete ? (
|
| <motion.div key="done" initial={ {opacity:0, x:-10} } animate={ {opacity:1, x:0} } exit={ {opacity:0, x:10} }>
|
| <p className="text-sm font-medium text-green-400 flex items-center gap-2"><Icons.CheckCircle className="h-4 w-4"/> Complete!</p>
|
| <p className="text-sm text-muted-foreground">The hierarchy is fully formed.</p>
|
| </motion.div>
|
| ) : currentOp ? (
|
| <motion.div key={`step-${currentStep}`} initial={ {opacity:0, x:-10} } animate={ {opacity:1, x:0} } exit={ {opacity:0, x:10} }>
|
| <div className="flex items-center gap-2 mb-2">
|
| <span className="px-2 py-1 bg-primary/20 rounded text-xs font-mono text-primary">Step {currentStep}</span>
|
| <span className="text-sm font-medium">{mode === 'agglomerative' ? 'Merging' : 'Splitting'}</span>
|
| </div>
|
| <p className="text-sm text-muted-foreground mb-3">{currentOp.explanation}</p>
|
| <div className="flex gap-4 p-3 bg-muted/50 rounded-lg text-center">
|
| <div className="flex-1">
|
| <span className="text-lg font-bold">{totalClusters}</span>
|
| <p className="text-xs text-muted-foreground">Clusters</p>
|
| </div>
|
| <div className="w-px bg-border"></div>
|
| <div className="flex-1">
|
| <span className="text-lg font-bold">{steps.length - currentStep}</span>
|
| <p className="text-xs text-muted-foreground">Steps Left</p>
|
| </div>
|
| </div>
|
| </motion.div>
|
| ) : null}
|
| </AnimatePresence>
|
| </motion.div>
|
| );
|
| }
|
|
|
| const AlgorithmInfo = () => (
|
| <div className="glass-card p-5 mt-6 grid md:grid-cols-2 gap-6">
|
| <div>
|
| <h3 className="text-sm font-semibold mb-3 flex items-center gap-2"><Icons.GitMerge className="h-4 w-4 text-primary"/> Agglomerative (Bottom-Up)</h3>
|
| <ul className="text-xs text-muted-foreground space-y-2">
|
| <li className="flex gap-2"><div className="p-1 bg-primary/10 rounded"><Icons.Target className="h-3 w-3"/></div> Start: Each point is a cluster</li>
|
| <li className="flex gap-2"><div className="p-1 bg-primary/10 rounded"><Icons.Layers className="h-3 w-3"/></div> Find closest pair</li>
|
| <li className="flex gap-2"><div className="p-1 bg-primary/10 rounded"><Icons.GitMerge className="h-3 w-3"/></div> Merge them</li>
|
| <li className="flex gap-2"><div className="p-1 bg-primary/10 rounded"><Icons.BookOpen className="h-3 w-3"/></div> Repeat until one cluster remains</li>
|
| </ul>
|
| </div>
|
| <div>
|
| <h3 className="text-sm font-semibold mb-3 flex items-center gap-2"><Icons.Scissors className="h-4 w-4 text-accent"/> Divisive (Top-Down)</h3>
|
| <ul className="text-xs text-muted-foreground space-y-2">
|
| <li className="flex gap-2"><div className="p-1 bg-accent/10 rounded"><Icons.CircleDot className="h-3 w-3"/></div> Start: One cluster contains all</li>
|
| <li className="flex gap-2"><div className="p-1 bg-accent/10 rounded"><Icons.SplitSquareVertical className="h-3 w-3"/></div> Find cluster with max variance</li>
|
| <li className="flex gap-2"><div className="p-1 bg-accent/10 rounded"><Icons.Scissors className="h-3 w-3"/></div> Split into two</li>
|
| <li className="flex gap-2"><div className="p-1 bg-accent/10 rounded"><Icons.BookOpen className="h-3 w-3"/></div> Repeat until single points remain</li>
|
| </ul>
|
| </div>
|
| </div>
|
| );
|
|
|
|
|
|
|
| const POINT_COUNT = 6;
|
| const CANVAS_WIDTH = 600;
|
| const CANVAS_HEIGHT = 400;
|
|
|
| const App = () => {
|
| const [mode, setMode] = useState("agglomerative");
|
| const [points, setPoints] = useState([]);
|
| const [clusters, setClusters] = useState([]);
|
| const [steps, setSteps] = useState([]);
|
| const [currentStep, setCurrentStep] = useState(0);
|
| const [isPlaying, setIsPlaying] = useState(false);
|
| const [dendrogram, setDendrogram] = useState(null);
|
|
|
| const initSim = useCallback(() => {
|
| const newPoints = generateRandomPoints(POINT_COUNT, CANVAS_WIDTH, CANVAS_HEIGHT);
|
|
|
| if (mode === 'agglomerative') {
|
| const newClusters = initializeClusters(newPoints);
|
| const newSteps = generateAllMergeSteps(newPoints);
|
| const newDendrogram = buildDendrogram(newPoints, newSteps);
|
| setClusters(newClusters);
|
| setSteps(newSteps);
|
| setDendrogram(newDendrogram);
|
| } else {
|
| const newClusters = initializeSingleCluster(newPoints);
|
| const newSteps = generateAllSplitSteps(newPoints);
|
| const newDendrogram = buildDivisiveDendrogram(newPoints, newSteps);
|
| setClusters(newClusters);
|
| setSteps(newSteps);
|
| setDendrogram(newDendrogram);
|
| }
|
|
|
| setPoints(newPoints);
|
| setCurrentStep(0);
|
| setIsPlaying(false);
|
| }, [mode]);
|
|
|
| useEffect(() => { initSim(); }, [initSim]);
|
|
|
| const applyStep = useCallback((stepIdx) => {
|
| if (stepIdx <= 0) {
|
| setClusters(mode === 'agglomerative' ? initializeClusters(points) : initializeSingleCluster(points));
|
| return;
|
| }
|
|
|
| if (mode === 'agglomerative') {
|
| let curr = initializeClusters(points);
|
| for(let i=0; i<stepIdx; i++) {
|
| const s = steps[i];
|
| curr = mergeClusters(curr, s.cluster1Id, s.cluster2Id, s.newClusterId, points);
|
| }
|
| setClusters(curr);
|
| } else {
|
| let curr = initializeSingleCluster(points);
|
| for(let i=0; i<stepIdx; i++) {
|
| const s = steps[i];
|
| curr = applySplit(curr, s.parentClusterId, s.child1Id, s.child2Id, points, i+1);
|
| }
|
| setClusters(curr);
|
| }
|
| }, [mode, points, steps]);
|
|
|
| const handleNext = useCallback(() => {
|
| if (currentStep < steps.length) {
|
| const next = currentStep + 1;
|
| setCurrentStep(next);
|
| applyStep(next);
|
| }
|
| }, [currentStep, steps, applyStep]);
|
|
|
| const handlePrev = useCallback(() => {
|
| if (currentStep > 0) {
|
| const prev = currentStep - 1;
|
| setCurrentStep(prev);
|
| applyStep(prev);
|
| }
|
| }, [currentStep, applyStep]);
|
|
|
| useEffect(() => {
|
| if(!isPlaying) return;
|
| const timer = setInterval(() => {
|
| if(currentStep >= steps.length) setIsPlaying(false);
|
| else handleNext();
|
| }, 1500);
|
| return () => clearInterval(timer);
|
| }, [isPlaying, currentStep, steps, handleNext]);
|
|
|
| const highlighted = useMemo(() => {
|
| if (currentStep === 0 || currentStep > steps.length) return mode === 'agglomerative' ? [] : null;
|
| const s = steps[currentStep - 1];
|
| return mode === 'agglomerative' ? [s.cluster1Id, s.cluster2Id] : s.parentClusterId;
|
| }, [currentStep, steps, mode]);
|
|
|
| return (
|
| <div className="min-h-screen p-4 md:p-8">
|
| <div className="max-w-7xl mx-auto">
|
| <header className="text-center mb-8">
|
| <h1 className="text-3xl md:text-5xl font-bold mb-3 gradient-text">Hierarchical Clustering</h1>
|
| <p className="text-lg text-muted-foreground max-w-2xl mx-auto">Visualize how data grouping works in machine learning.</p>
|
| </header>
|
|
|
| <div className="flex justify-center mb-8">
|
| <div className="glass-card p-1.5 flex gap-2">
|
| <button onClick={() => setMode("agglomerative")} className={cn("flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-all", mode === "agglomerative" ? "bg-primary text-primary-foreground shadow-lg" : "text-muted-foreground hover:bg-muted/50")}>
|
| <Icons.GitMerge className="w-4 h-4" /> Agglomerative
|
| </button>
|
| <button onClick={() => setMode("divisive")} className={cn("flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-all", mode === "divisive" ? "bg-accent text-accent-foreground shadow-lg" : "text-muted-foreground hover:bg-muted/50")}>
|
| <Icons.Scissors className="w-4 h-4" /> Divisive
|
| </button>
|
| </div>
|
| </div>
|
|
|
| <div className="grid lg:grid-cols-[1fr_320px] gap-6">
|
| <motion.div initial={ {opacity:0, scale:0.95} } animate={ {opacity:1, scale:1} }>
|
| {mode === 'agglomerative'
|
| ? <ClusteringCanvas points={points} clusters={clusters} currentStep={currentStep} steps={steps} highlightedClusters={highlighted} />
|
| : <DivisiveCanvas points={points} clusters={clusters} currentStep={currentStep} steps={steps} highlightedCluster={highlighted} />
|
| }
|
| <AlgorithmInfo />
|
| </motion.div>
|
|
|
| <div className="space-y-4">
|
| <ControlPanel isPlaying={isPlaying} currentStep={currentStep} totalSteps={steps.length}
|
| onPlay={() => setIsPlaying(true)} onPause={() => setIsPlaying(false)}
|
| onNext={handleNext} onPrev={handlePrev} onReset={() => {setCurrentStep(0); applyStep(0); setIsPlaying(false);}} onRandomize={initSim}
|
| />
|
| <ExplanationPanel currentStep={currentStep} steps={steps} totalClusters={clusters.length} mode={mode} />
|
| <Dendrogram root={dendrogram} currentStep={currentStep} totalSteps={steps.length} />
|
| </div>
|
| </div>
|
| </div>
|
| {/* Centered Back Button */}
|
| <div className="mt-12 flex justify-center pb-8 relative">
|
| <audio id="clickSound" src="https://www.soundjay.com/buttons/sounds/button-3.mp3"></audio>
|
| <a
|
| href="/hierarchical-clustering"
|
| onClick={(e) => {
|
| const audio = document.getElementById("clickSound");
|
| if(audio) audio.play().catch(err => console.log(err));
|
| }}
|
| className="inline-flex items-center justify-center text-center leading-none bg-blue-600 hover:bg-blue-500 text-white font-bold py-2 px-6 rounded-xl text-sm transition-all duration-150 shadow-[0_4px_0_rgb(29,78,216)] active:shadow-none active:translate-y-[4px] uppercase tracking-wider"
|
| >
|
| Back to Core
|
| </a>
|
| </div>
|
| </div>
|
| );
|
| };
|
|
|
| const root = ReactDOM.createRoot(document.getElementById('root'));
|
| root.render(<App />);
|
| </script>
|
| </body>
|
| </html> |