Canvas JavaScript

K-Means Clustering - Normal Data

This JavaScript program demonstrates how to generate k-means clusters on a 2d graph of randomly generated points from normal distributions. The clusters are diplayed in different colors and the centroids are shown as larger dots.

NormalizedKMeans.html

<!DOCTYPE html>
<html>
  <head>
    <title>XoaX.net's Javascript</title>
    <script type="text/javascript" src="NormalizedKMeans.js"></script>
  </head>
  <body onload="Initialize()">
  	<div style="width:708px;height:622px;">
    	<canvas id="idCanvas" width="600" height ="600" style="background-color:#C0C0C0;float:left;"></canvas>
    	<div style="width:108px;height:600px;float:right;">
    		<input id="idHighY" type="text" size="9" style="width:100px;height:16px;"/>
    		<div style="width:108px;height:556px;"></div>
    		<input id="idLowY" type="text" size="9" style="width:100px;height:16px;"/>
    	</div>
    	<div style="width:600px;height:22px;float:left;">
    		<input id="idLowX" type="text" size="9" style="width:100px;height:16px;float:left;"/>
    		<div style="width:384px;height:22px;float:left;">
    			<div style="width:fit-content;margin-left:auto;margin-right:auto;"></div>
    		</div>
    		<input id="idHighX" type="text" size="9" style="width:100px;height:16px;float:left;"/>    		
    	</div>
    	<div style="width:108px;height:22px;float:right;"></div>
    </div>
  </body>
</html>

NormalizedKMeans.js

class CPoint2D {
	constructor(dX, dY) {
		this.mdX = dX;
		this.mdY = dY;
	}
	Set(dX, dY) {
		this.mdX = dX;
		this.mdY = dY;
	}
	GetX() {
		return this.mdX;
	}
	GetY() {
		return this.mdY;
	}
	DistanceSquared(dP){
		var dDX = dP.mdX - this.mdX;
		var dDY = dP.mdY - this.mdY;
		return dDX*dDX + dDY*dDY;
	}
}

class Clusters {
	constructor(kqaPoints, kiClusterCount) {
		// Allocate a 2D array of the number of clusters to store the clusters
		this.mqaaClusters = new Array(kiClusterCount);
		// The first point of each cluster array will be reserved for the centroid (mean)
		const kiPoints = kqaPoints.length;
		if (kiPoints < kiClusterCount) {
			throw new Error("Error: There are fewer points than the number of clusters");
		}
		// Arbitrarily assign the first points to be the centers of the clusters
		for (var i = 0; i < kiClusterCount; ++i) {
			this.mqaaClusters[i] = new Array();
			this.mqaaClusters[i].push(kqaPoints[i]);
		}
		// Loop over the points and add them randomly to cluster arrays
		for(var qPoint of kqaPoints) {
			var iClosest = this.FindClosestCluster(qPoint);
			this.mqaaClusters[iClosest].push(qPoint);
		}
	}
	FindClosestCluster(qPoint) {
		var dMinDistSq = this.mqaaClusters[0][0].DistanceSquared(qPoint);
		var iClosestCluster = 0;
		for (var i = 1; i < this.mqaaClusters.length; ++i) {
			var dDistSq = this.mqaaClusters[i][0].DistanceSquared(qPoint);
			if (dDistSq < dMinDistSq) {
				dMinDistSq = dDistSq
				iClosestCluster = i;
			}
		}
		return iClosestCluster;
	}
	RecalcuateCentroids() {
		for (var i = 0; i < this.mqaaClusters.length; ++i) {
			var dX = 0;
			var dY = 0;
			for (var j = 1; j < this.mqaaClusters[i].length; ++j) {
				dX += this.mqaaClusters[i][j].GetX()/(this.mqaaClusters[i].length - 1);
				dY += this.mqaaClusters[i][j].GetY()/(this.mqaaClusters[i].length - 1);
			}
			this.mqaaClusters[i][0].Set(dX, dY);
		}		
	}
	RecalculateClusters() {
		var bHasChanges = false;
		for (var i = 0; i < this.mqaaClusters.length; ++i) {
			// Run through the points bacward so that removals will not affect the remaining points.
			for (var j = this.mqaaClusters[i].length - 1; j > 0; --j) {
				// Find the closest cluster centroid
				var iClosestCluster = this.FindClosestCluster(this.mqaaClusters[i][j]);
				if (iClosestCluster != i) {
					bHasChanges = true;
					// Add the point to the closest cluster
					this.mqaaClusters[iClosestCluster].push(this.mqaaClusters[i][j]);
					// Remove it from the current cluster
					this.mqaaClusters[i].splice(j, 1);
				}
			}
		}		
		return bHasChanges;
	}
	GenerateClusters() {
		this.RecalcuateCentroids();
		while (this.RecalculateClusters()) {
			this.RecalcuateCentroids();
		}
		this.RecalcuateCentroids();
	}
	DrawClusters(qContext2D, qGraphRange) {
		// Pick a set of colors
		// There are 3 color channels. Take the number of clusters, take the ceiling of division by 2^3 - 1 = 7, and divide 255 by the number, rounding down.
		// So, 13 clusters divided by 7 is 2, if we take the ceiling. Divide 255 by 2 to get 127, taking the floor.
		// This 127 is the minimum color difference within a channel.
		const kiClusterCount = this.mqaaClusters.length;
		const kiDivisor = Math.ceil(kiClusterCount / 8);
		// The minimum difference between colors within a single channel: RGB
		const kiMinColorDiff = Math.floor(255/kiDivisor);
		for (var i = 0; i < kiClusterCount; ++i) {
			var iQuotient = Math.floor(i/7);
			var iRemainder = (i % 8);
			// This quotient needs to be fixed for general color selection
			var iR = (iQuotient + (iRemainder & 1))*kiMinColorDiff;
			var iG = (iQuotient + ((iRemainder & 2) >> 1))*kiMinColorDiff;
			var iB = (iQuotient + ((iRemainder & 4) >> 2))*kiMinColorDiff;
			// Draw the centroid
			qContext2D.fillStyle = "rgb("+iR+", "+iG+", "+iB+")";
			// The centroid of each cluster is at index 0
			var dX = qGraphRange.XtoPixelX(this.mqaaClusters[i][0].GetX());
			var dY = qGraphRange.YtoPixelY(this.mqaaClusters[i][0].GetY());
			qContext2D.beginPath();
			qContext2D.arc(dX, dY, 4, 0, 2.0*Math.PI, true);
			qContext2D.fill();
			// Draw the rest of the points in the cluster
			for (var j = 1; j < this.mqaaClusters[i].length; ++j) {
				qContext2D.fillStyle = "rgb("+iR+", "+iG+", "+iB+", "+.25+")";
				dX = qGraphRange.XtoPixelX(this.mqaaClusters[i][j].GetX());
				dY = qGraphRange.YtoPixelY(this.mqaaClusters[i][j].GetY());
				qContext2D.beginPath();
				qContext2D.arc(dX, dY, 1.5, 0, 2.0*Math.PI, true);
				qContext2D.fill();
			}
		}
	}
}

class GraphRange2D {
	constructor(dLowX, dHighX, dLowY, dHighY, iCanvasW, iCanvasH) {
		this.mdLowX = dLowX;
		this.mdHighX = dHighX;
		this.mdLowY = dLowY;
		this.mdHighY = dHighY;
		this.miCanvasW = iCanvasW;
		this.miCanvasH = iCanvasH;
	}
	PixelW() {
		return (this.mdHighX - this.mdLowX)/this.miCanvasW;
	}
	PixelH() {
		return (this.mdHighY - this.mdLowY)/this.miCanvasH;
	}
	// The pixel x range is [-.5, CanvasW - .5]
	XtoPixelX(dX) {
		var dT = (dX - this.mdLowX)/(this.mdHighX - this.mdLowX);
		return (dT*this.miCanvasW - .5);
	}
	// The pixel y range is [-.5, CanvasH - .5], but the orientation is reversed
	YtoPixelY(dY) {
		var dT = (dY - this.mdLowY)/(this.mdHighY - this.mdLowY);
		return (1.0 - dT)*(this.miCanvasH - .5);
	}
	PixelXToX(dPixelX) {
		var dT = (dPixelX - .5)/(this.miCanvasW - 1.0);
		return (dT*(this.mdHighX - this.mdLowX) + this.mdLowX);
	}
	PixelYToY(dPixelY) {
		var dT = (dPixelY - .5)/(this.miCanvasH - 1.0);
		return ((1.0 - dT)*(this.mdHighY - this.mdLowY) + this.mdLowY);
	}
	
	// Find the smallest power greater than x
	FindSmallestGreaterPowerOfTwo(dX) {
		var dPow = 1.0;
		while (dPow < dX) {
			dPow *= 2;
		}
		if (dPow == 1.0) {
			while (dPow >= dX) {
				dPow /= 2;
			}
		}
		return dPow;
	}
	FindLeastMultipleBelow(dX, dLowX) {
		Math.floor(dLowX/dX)*dX;
	}
	
	DrawGridLinesAndAxes(qContext) {
		// Draw grid lines of two types
		// Get an estimate for a value that will be drawn about every 100 pixels and every 20
		var dThickFreqX = this.FindSmallestGreaterPowerOfTwo(this.PixelXToX(100) - this.PixelXToX(0));
		var dThinFreqX = dThickFreqX/4;
		var dThickFreqY = this.FindSmallestGreaterPowerOfTwo(this.PixelYToY(0) - this.PixelYToY(100));
		var dThinFreqY = dThickFreqY/4;

		// Draw the thin lines first, then the thick ones, and the axes.
		// Draw the thin lines first
		// Draw vertical grid lines
		// Find the least frequency value below the low x (greatest lower multiple GLM)
		var dGLM = Math.floor(this.mdLowX/dThickFreqX)*dThickFreqX;
		var dGridValue = dGLM + dThinFreqX;
		qContext.strokeStyle = "#E8E8E8";
		var i = 1;
		while (dGridValue < this.mdHighX) {
			if ((i % 4) != 0) {
				var dGridX = this.XtoPixelX(dGridValue);
				qContext.beginPath();
				qContext.moveTo(dGridX, 0);
				qContext.lineTo(dGridX, this.miCanvasH);
				qContext.stroke();
			}
			dGridValue += dThinFreqX;
			++i;
		}
		// Draw horizontal grid lines
		dGLM = Math.floor(this.mdLowY/dThickFreqY)*dThickFreqY;
		dGridValue = dGLM + dThinFreqY;
		i = 1;
		while (dGridValue < this.mdHighY) {
			if ((i % 4) != 0) {
				var dGridY = this.YtoPixelY(dGridValue);
				qContext.beginPath();
				qContext.moveTo(0, dGridY);
				qContext.lineTo(this.miCanvasW, dGridY);
				qContext.stroke();
			}
			dGridValue += dThinFreqY;
			++i;
		}
		
		// Draw the thick lines
		// Draw vertical grid lines
		qContext.strokeStyle = "#DDDDDD";
		dGLM = Math.floor(this.mdLowX/dThickFreqX)*dThickFreqX;
		dGridValue = dGLM + dThickFreqX;
		while (dGridValue < this.mdHighX) {
			var dGridX = this.XtoPixelX(dGridValue);
			qContext.beginPath();
			qContext.moveTo(dGridX, 0);
			qContext.lineTo(dGridX, this.miCanvasH);
			qContext.stroke();
			dGridValue += dThickFreqX;
		}
		// Draw horizontal grid lines
		dGLM = Math.floor(this.mdLowY/dThickFreqY)*dThickFreqY;
		dGridValue = dGLM + dThickFreqY;
		while (dGridValue < this.mdHighY) {
			var dGridY = this.YtoPixelY(dGridValue);
			qContext.beginPath();
			qContext.moveTo(0, dGridY);
			qContext.lineTo(this.miCanvasW, dGridY);
			qContext.stroke();
			dGridValue += dThickFreqY;
		}

		// Lastly, check whether zero is within each range in order to draw the axes.
		// Draw the Y-Axis
		qContext.strokeStyle = "gray";
		qContext.fillStyle = "gray";
		if (this.mdLowX <= 0 && this.mdHighX >= 0) {
			var dPixelX = this.XtoPixelX(0);
			DrawArrow(qContext, dPixelX, this.miCanvasH, dPixelX, 0);
		}
		// Draw the X-Axis
		if (this.mdLowY <= 0 && this.mdHighY >= 0) {
			var dPixelY = this.YtoPixelY(0);
			DrawArrow(qContext, 0, dPixelY, this.miCanvasW, dPixelY);
		}
	}
	
	FillInRanges(qLowX, qHighX, qLowY, qHighY) {
		// Remove any trailing zeroes and decimal points
		qLowX.value = this.mdLowX.toFixed(4).replace(/\.?0+$/, "");
		qHighX.value = this.mdHighX.toFixed(4).replace(/\.?0+$/, "");
		qLowY.value = this.mdLowY.toFixed(4).replace(/\.?0+$/, "");
		qHighY.value = this.mdHighY.toFixed(4).replace(/\.?0+$/, "");
	}
}

var gqGraphRange = null;
var gqContext = null;
var gbIsDragging = false;
var giCursorX = 0;
var giCursorY = 0;
var gqClusters = null;

function Initialize() {
	var qCanvas = document.getElementById("idCanvas");
	gqContext = qCanvas.getContext("2d");
	
	// Get the canvas size
	var dLowX = -4.0;
	var dHighX = 4.0;
	var dLowY = -4.0;
	var dHighY = 4.0;
	gqGraphRange = new GraphRange2D(dLowX, dHighX, dLowY, dHighY, qCanvas.width, qCanvas.height);
	gqGraphRange.DrawGridLinesAndAxes(gqContext);
	
	var qLowX = document.getElementById("idLowX");
	var qHighX = document.getElementById("idHighX");
	var qLowY = document.getElementById("idLowY");
	var qHighY = document.getElementById("idHighY");
	gqGraphRange.FillInRanges(qLowX, qHighX, qLowY, qHighY);
	
	// Get the range and generate points for the clusters
	var qaPoints = new Array();
	const kiPointCount = 10000;
	var dDeltaX = dHighX - dLowX;
	var dDeltaY = dHighY - dLowY;
	let daaCenters = [[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]];
	for (let i = 0; i < daaCenters.length; ++i) {
		var dX = Math.random()*dDeltaX + dLowX;
		var dY = Math.random()*dDeltaY + dLowY;
		daaCenters[i][0] = dX;
		daaCenters[i][1] = dY;
	}
	for (var i = 0; i < kiPointCount; ++i) {
		let iCluster = Math.floor(Math.random()*daaCenters.length);
		let dStdDev = .5;
		var dX = InversePhi(Math.random())*dStdDev + daaCenters[iCluster][0];
		var dY = InversePhi(Math.random())*dStdDev + daaCenters[iCluster][1];
		qaPoints.push(new CPoint2D(dX, dY));
	}
	// Create the Clusters object.
	const kiClusterCount = 8;
	gqClusters = new Clusters(qaPoints, kiClusterCount);
	gqClusters.GenerateClusters();
	gqClusters.DrawClusters(gqContext, gqGraphRange);
}

function InversePhi(p) {
		// Constants for the approximation
		const p_low = 0.02425;
		const p_high = 1 - p_low;
		
		// Coefficients for the rational approximation
		const a1 = -3.969683028665376e+01;
		const a2 = 2.209460984245205e+02;
		const a3 = -2.759285104469687e+02;
		const a4 = 1.383577518672690e+02;
		const a5 = -3.066479806614716e+01;
		const a6 = 2.506628277459239e+00;

		const b1 = -5.447609879822406e+01;
		const b2 = 1.615858368580409e+02;
		const b3 = -1.556989798598866e+02;
		const b4 = 6.680131188771972e+01;
		const b5 = -1.328068155288572e+01;

		const c1 = -7.784894002430293e-03;
		const c2 = -3.223964580411365e-01;
		const c3 = -2.400758277161838e+00;
		const c4 = -2.549732539343734e+00;
		const c5 = 4.374664141464968e+00;
		const c6 = 2.938163982698783e+00;

		const d1 = 7.784695709041462e-03;
		const d2 = 3.224671290700398e-01;
		const d3 = 2.445134137142996e+00;
		const d4 = 3.754408661907416e+00;
		
		let x = 0.0;
		if (0.0 < p && p < 1) {
			if (p < p_low) {
				let q = Math.sqrt(-2*Math.log(p));
				x = (((((c1*q+c2)*q+c3)*q+c4)*q+c5)*q+c6) / ((((d1*q+d2)*q+d3)*q+d4)*q+1);
			} else if (p_high < p) {
				let q = Math.sqrt(-2*Math.log(1-p));
				x = -(((((c1*q+c2)*q+c3)*q+c4)*q+c5)*q+c6) / ((((d1*q+d2)*q+d3)*q+d4)*q+1);
			} else {
				let q = p - 0.5;
				let r = q*q;
				x = (((((a1*r+a2)*r+a3)*r+a4)*r+a5)*r+a6)*q / (((((b1*r+b2)*r+b3)*r+b4)*r+b5)*r+1);
			}
		}
		return x;
	}

function DrawArrow(qContext, dX1, dY1, dX2, dY2) {
	// Draw the line portion
	qContext.beginPath();
	qContext.moveTo(dX1, dY1);
	qContext.lineTo(dX2, dY2);
	qContext.stroke();
	// Draw the head portion
	DrawArrowhead(qContext,  dX1, dY1, dX2, dY2);
}

function DrawArrowhead(qContext,  dX1, dY1, dX2, dY2) {
	var dTheta = Math.atan2(dY2-dY1,dX2-dX1);
	var dThicknessAngle = Math.PI/6;
	var dHeadLength = 10;
	qContext.beginPath();
	qContext.moveTo(dX2, dY2);
	qContext.lineTo(dX2 - dHeadLength*Math.cos(dTheta-dThicknessAngle), dY2 - dHeadLength*Math.sin(dTheta-dThicknessAngle));
	qContext.lineTo(dX2 - dHeadLength*Math.cos(dTheta+dThicknessAngle), dY2 - dHeadLength*Math.sin(dTheta+dThicknessAngle));
	qContext.closePath();
	qContext.fill();
}
 

Output

 
 

© 2007–2026 XoaX.net LLC. All rights reserved.