Skip to content

Visualization Examples

Examples for visualizing projection data using Canvas or SVG.

Try It Yourself

Upload a .cloupe file to visualize projections:

Interactive Example: Projection Visualization

📁

Drop a .cloupe file here or click to select

or

Basic Canvas Visualization

html
<canvas id="canvas" width="800" height="600"></canvas>

<script type="module">
  import { CloupeReader } from "cloupe.js";

  async function visualize(file) {
    const reader = await CloupeReader.open(file);

    // Load projection data
    const projection = await reader.getDefaultProjection();
    if (!projection) {
      console.error("No projection available");
      return;
    }

    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");
    const { width, height } = canvas;

    // Get bounds for coordinate normalization
    const bounds = projection.getBounds();
    const xRange = bounds.max[0] - bounds.min[0];
    const yRange = bounds.max[1] - bounds.min[1];

    // Padding
    const padding = 40;
    const plotWidth = width - padding * 2;
    const plotHeight = height - padding * 2;

    // Coordinate transformation functions
    function transformX(x) {
      return padding + ((x - bounds.min[0]) / xRange) * plotWidth;
    }

    function transformY(y) {
      // Flip Y axis (canvas is top to bottom)
      return height - padding - ((y - bounds.min[1]) / yRange) * plotHeight;
    }

    // Background
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, width, height);

    // Draw points
    const x = projection.coordinates[0];
    const y = projection.coordinates[1];

    ctx.fillStyle = "rgba(59, 130, 246, 0.5)"; // Semi-transparent blue

    for (let i = 0; i < projection.numPoints; i++) {
      ctx.beginPath();
      ctx.arc(transformX(x[i]), transformY(y[i]), 2, 0, Math.PI * 2);
      ctx.fill();
    }

    // Title
    ctx.fillStyle = "#000000";
    ctx.font = "16px sans-serif";
    ctx.textAlign = "center";
    ctx.fillText(projection.name, width / 2, 20);

    reader.close();
  }
</script>

Cluster-colored Visualization

typescript
import { CloupeReader, CellTrack } from "cloupe.js";

async function visualizeWithClusters(file: File, canvas: HTMLCanvasElement, trackName: string) {
  const reader = await CloupeReader.open(file);

  const projection = await reader.getDefaultProjection();
  const track = await reader.getCellTrack(trackName);

  if (!projection) {
    throw new Error("No projection available");
  }

  const ctx = canvas.getContext("2d")!;
  const { width, height } = canvas;

  // Color palette
  const colors = [
    "#3B82F6",
    "#EF4444",
    "#10B981",
    "#F59E0B",
    "#8B5CF6",
    "#EC4899",
    "#06B6D4",
    "#84CC16",
    "#F97316",
    "#6366F1",
  ];

  // Map categories to colors
  const categoryColors = new Map<string | number, string>();
  track.categories.forEach((cat, i) => {
    categoryColors.set(cat, colors[i % colors.length]);
  });

  // Calculate bounds
  const bounds = projection.getBounds();
  const xRange = bounds.max[0] - bounds.min[0];
  const yRange = bounds.max[1] - bounds.min[1];

  const padding = 40;
  const plotWidth = width - padding * 2;
  const plotHeight = height - padding * 2;

  function transformX(x: number) {
    return padding + ((x - bounds.min[0]) / xRange) * plotWidth;
  }

  function transformY(y: number) {
    return height - padding - ((y - bounds.min[1]) / yRange) * plotHeight;
  }

  // Background
  ctx.fillStyle = "#ffffff";
  ctx.fillRect(0, 0, width, height);

  // Draw points
  const x = projection.coordinates[0];
  const y = projection.coordinates[1];

  for (let i = 0; i < projection.numPoints; i++) {
    const category = track.getCategoryForCell(i);
    const color = categoryColors.get(category!) || "#888888";

    ctx.fillStyle = color;
    ctx.beginPath();
    ctx.arc(transformX(x[i]), transformY(y[i]), 2, 0, Math.PI * 2);
    ctx.fill();
  }

  // Legend
  let legendY = padding;
  ctx.font = "12px sans-serif";
  ctx.textAlign = "left";

  for (const [category, color] of categoryColors) {
    ctx.fillStyle = color;
    ctx.fillRect(width - 120, legendY, 12, 12);
    ctx.fillStyle = "#000000";
    ctx.fillText(String(category), width - 100, legendY + 10);
    legendY += 18;
  }

  reader.close();
}

Gene Expression Heatmap

typescript
async function visualizeExpression(file: File, canvas: HTMLCanvasElement, geneName: string) {
  const reader = await CloupeReader.open(file);

  const projection = await reader.getDefaultProjection();
  const expression = await reader.getExpressionByFeatureName(geneName);

  if (!projection || !expression) {
    throw new Error("Data not available");
  }

  const ctx = canvas.getContext("2d")!;
  const { width, height } = canvas;

  // Normalize expression values
  const maxValue = Math.max(...expression.values);
  const expressionMap = new Map<number, number>();

  for (let i = 0; i < expression.indices.length; i++) {
    expressionMap.set(expression.indices[i], expression.values[i]);
  }

  // Color gradient function (gray → red)
  function getColor(value: number): string {
    const normalized = value / maxValue;
    const r = Math.round(128 + normalized * 127);
    const g = Math.round(128 * (1 - normalized));
    const b = Math.round(128 * (1 - normalized));
    return `rgb(${r}, ${g}, ${b})`;
  }

  // Bounds
  const bounds = projection.getBounds();
  const xRange = bounds.max[0] - bounds.min[0];
  const yRange = bounds.max[1] - bounds.min[1];

  const padding = 40;
  const plotWidth = width - padding * 2;
  const plotHeight = height - padding * 2;

  function transformX(x: number) {
    return padding + ((x - bounds.min[0]) / xRange) * plotWidth;
  }

  function transformY(y: number) {
    return height - padding - ((y - bounds.min[1]) / yRange) * plotHeight;
  }

  // Background
  ctx.fillStyle = "#ffffff";
  ctx.fillRect(0, 0, width, height);

  const x = projection.coordinates[0];
  const y = projection.coordinates[1];

  // Draw cells without expression first (gray)
  ctx.fillStyle = "#cccccc";
  for (let i = 0; i < projection.numPoints; i++) {
    if (!expressionMap.has(i)) {
      ctx.beginPath();
      ctx.arc(transformX(x[i]), transformY(y[i]), 2, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  // Draw cells with expression (colored)
  for (let i = 0; i < projection.numPoints; i++) {
    const value = expressionMap.get(i);
    if (value !== undefined) {
      ctx.fillStyle = getColor(value);
      ctx.beginPath();
      ctx.arc(transformX(x[i]), transformY(y[i]), 3, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  // Title
  ctx.fillStyle = "#000000";
  ctx.font = "16px sans-serif";
  ctx.textAlign = "center";
  ctx.fillText(`${geneName} Expression`, width / 2, 20);

  // Color bar
  const barWidth = 20;
  const barHeight = 100;
  const barX = width - padding - barWidth;
  const barY = height / 2 - barHeight / 2;

  const gradient = ctx.createLinearGradient(0, barY + barHeight, 0, barY);
  gradient.addColorStop(0, "rgb(128, 128, 128)");
  gradient.addColorStop(1, "rgb(255, 0, 0)");

  ctx.fillStyle = gradient;
  ctx.fillRect(barX, barY, barWidth, barHeight);

  ctx.font = "10px sans-serif";
  ctx.textAlign = "left";
  ctx.fillStyle = "#000000";
  ctx.fillText("0", barX + barWidth + 5, barY + barHeight);
  ctx.fillText(maxValue.toFixed(1), barX + barWidth + 5, barY + 10);

  reader.close();
}

SVG Visualization

typescript
async function createSVG(file: File): Promise<SVGSVGElement> {
  const reader = await CloupeReader.open(file);
  const projection = await reader.getDefaultProjection();

  if (!projection) {
    throw new Error("No projection available");
  }

  const width = 800;
  const height = 600;
  const padding = 40;

  const bounds = projection.getBounds();
  const xRange = bounds.max[0] - bounds.min[0];
  const yRange = bounds.max[1] - bounds.min[1];

  const scaleX = (width - padding * 2) / xRange;
  const scaleY = (height - padding * 2) / yRange;

  const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  svg.setAttribute("width", String(width));
  svg.setAttribute("height", String(height));
  svg.setAttribute("viewBox", `0 0 ${width} ${height}`);

  // Background
  const bg = document.createElementNS("http://www.w3.org/2000/svg", "rect");
  bg.setAttribute("width", "100%");
  bg.setAttribute("height", "100%");
  bg.setAttribute("fill", "white");
  svg.appendChild(bg);

  // Group for points
  const group = document.createElementNS("http://www.w3.org/2000/svg", "g");

  const x = projection.coordinates[0];
  const y = projection.coordinates[1];

  for (let i = 0; i < projection.numPoints; i++) {
    const cx = padding + (x[i] - bounds.min[0]) * scaleX;
    const cy = height - padding - (y[i] - bounds.min[1]) * scaleY;

    const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
    circle.setAttribute("cx", String(cx));
    circle.setAttribute("cy", String(cy));
    circle.setAttribute("r", "2");
    circle.setAttribute("fill", "rgba(59, 130, 246, 0.5)");
    circle.setAttribute("data-index", String(i));

    group.appendChild(circle);
  }

  svg.appendChild(group);

  // Title
  const title = document.createElementNS("http://www.w3.org/2000/svg", "text");
  title.setAttribute("x", String(width / 2));
  title.setAttribute("y", "25");
  title.setAttribute("text-anchor", "middle");
  title.setAttribute("font-size", "16");
  title.textContent = projection.name;
  svg.appendChild(title);

  reader.close();

  return svg;
}

// Usage
const svg = await createSVG(file);
document.body.appendChild(svg);

Adding Interactivity

typescript
function addInteractivity(canvas: HTMLCanvasElement, projection: Projection, barcodes: string[]) {
  const bounds = projection.getBounds();
  const padding = 40;
  const { width, height } = canvas;
  const plotWidth = width - padding * 2;
  const plotHeight = height - padding * 2;
  const xRange = bounds.max[0] - bounds.min[0];
  const yRange = bounds.max[1] - bounds.min[1];

  // Inverse transformation function
  function screenToData(sx: number, sy: number): [number, number] {
    const x = bounds.min[0] + ((sx - padding) / plotWidth) * xRange;
    const y = bounds.min[1] + ((height - padding - sy) / plotHeight) * yRange;
    return [x, y];
  }

  // Find nearest point
  function findNearest(mx: number, my: number): number | null {
    const [dx, dy] = screenToData(mx, my);
    let minDist = Infinity;
    let nearest: number | null = null;

    const x = projection.coordinates[0];
    const y = projection.coordinates[1];

    for (let i = 0; i < projection.numPoints; i++) {
      const dist = Math.hypot(x[i] - dx, y[i] - dy);
      if (dist < minDist) {
        minDist = dist;
        nearest = i;
      }
    }

    // Threshold (in data coordinates)
    const threshold = Math.max(xRange, yRange) * 0.02;
    return minDist < threshold ? nearest : null;
  }

  // Tooltip
  const tooltip = document.createElement("div");
  tooltip.style.cssText = `
    position: absolute;
    background: rgba(0, 0, 0, 0.8);
    color: white;
    padding: 4px 8px;
    border-radius: 4px;
    font-size: 12px;
    pointer-events: none;
    display: none;
  `;
  document.body.appendChild(tooltip);

  canvas.addEventListener("mousemove", (e) => {
    const rect = canvas.getBoundingClientRect();
    const mx = e.clientX - rect.left;
    const my = e.clientY - rect.top;

    const idx = findNearest(mx, my);

    if (idx !== null) {
      tooltip.textContent = `Cell ${idx}: ${barcodes[idx]}`;
      tooltip.style.left = `${e.clientX + 10}px`;
      tooltip.style.top = `${e.clientY + 10}px`;
      tooltip.style.display = "block";
      canvas.style.cursor = "pointer";
    } else {
      tooltip.style.display = "none";
      canvas.style.cursor = "default";
    }
  });

  canvas.addEventListener("mouseleave", () => {
    tooltip.style.display = "none";
  });
}

Released under the MIT License.