t-SNE and UMAP Embedding Visualization — D3.js

A scatter plot displaying high-dimensional data projected into 2D space using non-linear dimensionality reduction techniques such as t-SNE or UMAP. Points are colored by cluster or class label, revealing groupings and latent structure in the data. This is a standard visualization in machine learning for exploring embeddings, single-cell RNA-seq data, and NLP document clustering, helping practitioners verify that learned representations capture meaningful distinctions.

t-SNE and UMAP Embedding Visualization rendered with D3.js

Renders

JavaScript source (D3.js)

// anyplot.ai
// scatter-embedding: t-SNE and UMAP Embedding Visualization
// Library: d3 7.9.0 | JavaScript 22.23.1
// Quality: 93/100 | Created: 2026-08-11

const t = window.ANYPLOT_TOKENS;
const { width, height } = window.ANYPLOT_SIZE;
const margin = { top: 130, right: 280, bottom: 70, left: 90 };
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;

// --- Deterministic RNG (LCG + Box-Muller) -----------------------------------
let seed = 42;
const rand = () => {
  seed = (seed * 1103515245 + 12345) & 0x7fffffff;
  return seed / 0x7fffffff;
};
const randNormal = () => {
  const u1 = Math.max(rand(), 1e-6);
  const u2 = rand();
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
};

// --- Data: synthetic NLP document embeddings, projected via t-SNE ----------
// Each topic cluster gets its own center, elongation, and rotation so the
// blobs read like genuine t-SNE output rather than perfect circles.
// Centers pushed further from the origin (vs. a tighter first draft) and the
// Technology/Finance ellipses de-aligned + slimmed so their spreads no longer
// bleed into one another near the top of the ring.
const clusterDefs = [
  { name: "Technology", n: 120, cx: -9.1, cy: 7.7, sx: 1.3, sy: 0.9, angle: -0.15 },
  { name: "Sports", n: 95, cx: 9.1, cy: 7.7, sx: 1.1, sy: 1.4, angle: -0.3 },
  { name: "Politics", n: 110, cx: -9.8, cy: -6.3, sx: 1.3, sy: 1.1, angle: 0.9 },
  { name: "Health", n: 85, cx: 7.7, cy: -7, sx: 1.0, sy: 1.6, angle: -0.6 },
  { name: "Finance", n: 100, cx: 0, cy: 11.9, sx: 1.5, sy: 0.8, angle: 0.1 },
  { name: "Entertainment", n: 90, cx: 0, cy: -11.9, sx: 1.5, sy: 0.9, angle: -0.2 },
];

const points = [];
for (const c of clusterDefs) {
  for (let i = 0; i < c.n; i++) {
    const rx = randNormal() * c.sx;
    const ry = randNormal() * c.sy;
    const px = c.cx + rx * Math.cos(c.angle) - ry * Math.sin(c.angle);
    const py = c.cy + rx * Math.sin(c.angle) + ry * Math.cos(c.angle);
    points.push({ x: px, y: py, cluster: c.name });
  }
}

const clusters = clusterDefs.map((c) => {
  const pts = points.filter((p) => p.cluster === c.name);
  return {
    name: c.name,
    cx: d3.mean(pts, (p) => p.x),
    cy: d3.mean(pts, (p) => p.y),
  };
});

// --- Scales -------------------------------------------------------------
const xExtent = d3.extent(points, (p) => p.x);
const yExtent = d3.extent(points, (p) => p.y);
const xPad = (xExtent[1] - xExtent[0]) * 0.1;
const yPad = (yExtent[1] - yExtent[0]) * 0.1;

const x = d3.scaleLinear().domain([xExtent[0] - xPad, xExtent[1] + xPad]).range([0, iw]);
const y = d3.scaleLinear().domain([yExtent[0] - yPad, yExtent[1] + yPad]).range([ih, 0]);
const color = d3.scaleOrdinal().domain(clusterDefs.map((c) => c.name)).range(t.palette);

// --- SVG mount ------------------------------------------------------------
const svg = d3.select("#container").append("svg").attr("width", width).attr("height", height);
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);

// --- Cluster hull outlines (d3.polygonHull) --------------------------------
// A genuine D3-distinctive technique: trace each cluster's convex hull in
// pixel space to give the grouping a deliberate visual boundary/focal shape
// beyond color alone, drawn beneath the points so markers stay on top.
const hullG = g.append("g").attr("class", "hulls");
for (const c of clusterDefs) {
  const hull = d3.polygonHull(
    points.filter((p) => p.cluster === c.name).map((p) => [x(p.x), y(p.y)])
  );
  if (!hull) continue;
  hullG
    .append("path")
    .attr("d", `M${hull.map((p) => p.join(",")).join("L")}Z`)
    .attr("fill", color(c.name))
    .attr("fill-opacity", 0.08)
    .attr("stroke", color(c.name))
    .attr("stroke-opacity", 0.4)
    .attr("stroke-width", 1.5)
    .attr("stroke-linejoin", "round");
}

// --- Points (moderate size + alpha to handle overlap at n=600) -------------
g.selectAll("circle")
  .data(points)
  .join("circle")
  .attr("cx", (d) => x(d.x))
  .attr("cy", (d) => y(d.y))
  .attr("r", 5.5)
  .attr("fill", (d) => color(d.cluster))
  .attr("fill-opacity", 0.62)
  .attr("stroke", t.pageBg)
  .attr("stroke-width", 0.6);

// --- Centroid labels (cluster name over a soft halo for legibility) --------
const centroidG = g
  .selectAll(".centroid")
  .data(clusters)
  .join("g")
  .attr("class", "centroid")
  .attr("transform", (d) => `translate(${x(d.cx)},${y(d.cy)})`);

centroidG.each(function (d) {
  const node = d3.select(this);
  const label = node
    .append("text")
    .attr("text-anchor", "middle")
    .attr("dy", "0.35em")
    .style("font-size", "15px")
    .style("font-weight", "600")
    .attr("fill", t.ink)
    .text(d.name);
  const bbox = label.node().getBBox();
  node
    .insert("rect", "text")
    .attr("x", bbox.x - 7)
    .attr("y", bbox.y - 3)
    .attr("width", bbox.width + 14)
    .attr("height", bbox.height + 6)
    .attr("rx", 4)
    .attr("fill", t.elevatedBg)
    .attr("fill-opacity", 0.95)
    .attr("stroke", t.grid)
    .attr("stroke-width", 1);
});

// --- Axis labels (no tick labels — embedding coordinates are not directly
// interpretable, only relative position and clustering carry meaning) ------
g.append("text")
  .attr("x", iw / 2)
  .attr("y", ih + 44)
  .attr("text-anchor", "middle")
  .attr("fill", t.inkSoft)
  .style("font-size", "14px")
  .text("t-SNE dimension 1");

g.append("text")
  .attr("transform", `translate(${-56},${ih / 2}) rotate(-90)`)
  .attr("text-anchor", "middle")
  .attr("fill", t.inkSoft)
  .style("font-size", "14px")
  .text("t-SNE dimension 2");

// --- Legend -----------------------------------------------------------------
const legend = svg
  .append("g")
  .attr("transform", `translate(${margin.left + iw + 50},${margin.top + 30})`);

const legendItems = legend
  .selectAll(".legend-item")
  .data(clusterDefs)
  .join("g")
  .attr("class", "legend-item")
  .attr("transform", (d, i) => `translate(0,${i * 40})`);

legendItems
  .append("circle")
  .attr("r", 8)
  .attr("cx", 8)
  .attr("cy", 0)
  .attr("fill", (d) => color(d.name))
  .attr("fill-opacity", 0.62);

legendItems
  .append("text")
  .attr("x", 26)
  .attr("y", 0)
  .attr("dy", "0.35em")
  .attr("fill", t.inkSoft)
  .style("font-size", "15px")
  .text((d) => d.name);

// --- Title + subtitle --------------------------------------------------------
svg
  .append("text")
  .attr("x", width / 2)
  .attr("y", 50)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "22px")
  .style("font-weight", "600")
  .text("scatter-embedding · javascript · d3 · anyplot.ai");

svg
  .append("text")
  .attr("x", width / 2)
  .attr("y", 82)
  .attr("text-anchor", "middle")
  .attr("fill", t.inkSoft)
  .style("font-size", "16px")
  .text("NLP document embeddings · t-SNE (perplexity=30)");

Part of t-SNE and UMAP Embedding Visualization on anyplot.ai.

Other implementations