Download templates/Hierarchical-three.html from deedrop1140/machinelearningalgorithms: direct link, hf CLI and curl.
- Browser
- Download file 52.4 kB
-
https://huggingface.co/spaces/deedrop1140/machinelearningalgorithms/resolve/main/templates/Hierarchical-three.html
- Command line
-
hf download hf://spaces/deedrop1140/machinelearningalgorithms/templates/Hierarchical-three.html
-
curl -L -o Hierarchical-three.html https://huggingface.co/spaces/deedrop1140/machinelearningalgorithms/resolve/main/templates/Hierarchical-three.html
52.4 kB
| <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> | |
| <!-- Tailwind CSS --> | |
| <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> | |
| <!-- React & Libraries --> | |
| <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> | |
| <!-- Lucide Icons --> | |
| <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; | |
| // --- UTILS --- | |
| function cn(...classes) { | |
| return classes.filter(Boolean).join(' '); | |
| } | |
| // --- ICONS --- | |
| // Using Lucide React icons manually since we're in a standalone environment | |
| const Icon = ({ name, size = 24, className, ...props }) => { | |
| const LucideIcon = window.lucide && window.lucide.icons ? window.lucide.icons[name] : null; | |
| if (!LucideIcon) return null; | |
| // Convert the lucide icon object to an SVG string/element | |
| // Since lucide.icons[name] gives us the JS representation, we render an svg manually for React | |
| // Actually, simplest way in this specific standalone babel setup without full build chain: | |
| // We'll just SVG paths mapped manually for stability, or rely on lucide global if available. | |
| // For robustness, I'll use the SVG paths provided in your original code + a few more, | |
| // wrapped in a clean component. | |
| return null; | |
| }; | |
| // Defining Icons manually to ensure 100% reliability without external dep failures | |
| 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%)", | |
| ]; | |
| // --- ALGORITHMS --- | |
| 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, | |
| }; | |
| } | |
| // --- AGGLOMERATIVE LOGIC --- | |
| 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); | |
| } | |
| // --- DIVISIVE LOGIC --- | |
| 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)); | |
| // Find 2 most distant points as seeds | |
| 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); | |
| // Using distance variance as metric | |
| 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; | |
| // We need to re-calculate splitting to know which points go where for the tree structure | |
| // This is a bit inefficient but accurate for visualization | |
| 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; | |
| } | |
| // --- COMPONENTS --- | |
| 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> | |
| ); | |
| // --- MAIN APP --- | |
| 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> |