machinelearningalgorithms / templates /Hierarchical-three.html
deedrop1140's picture
Upload 182 files
d0a6b4f verified
<!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>
<!-- 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>