Skip to content

Cryo-ET Tomogram with Particle Annotations

Ribosome and ferritin annotations drawn over a cryo-ET tomogram.

Data: CryoET Data Portal

ts
import {
  Idetik,
  ColorLike,
  Layer,
  PanZoomControls,
  PointsRenderable,
  OrthographicCamera,
  OmeZarrImageSource,
  LayerState,
  ImageLayer,
  Color,
  createNoPrefetchPolicy,
  Viewport,
} from "idetik";

import { vec3 } from "gl-matrix";
import GUI from "lil-gui";

const baseUrl =
  "https://files.cryoetdataportal.cziscience.com/10445/TS_100_3/Reconstructions/VoxelSpacing4.990";
const imageUrl = `${baseUrl}/Tomograms/100/TS_100_3.zarr`;
const ribosomesUrl = `${baseUrl}/Annotations/104/cytosolic_ribosome-1.0_point.ndjson`;
const ferritinUrl = `${baseUrl}/Annotations/101/ferritin_complex-1.0_point.ndjson`;
const virusLikeUrl = `${baseUrl}/Annotations/106/pp7_vlp-1.0_point.ndjson`;

// image scale for the highest res image --
// point coordinates are stored in the pixel-space of
// the highest res image, and need to be scaled
// by this factor to match our world coordinates
const IMAGE_SCALE_0 = 4.99; // nm/px
// image scale for the lowest res --
// this is the scale of the image we show
// it is used for scaling of the slice/depth
const IMAGE_SCALE_2 = 19.96; // nm/px
const INITIAL_Z_POSITION = 918.16;

const fetchPositionsFromNDJson = async (url: string) => {
  return await fetch(url).then(async (response) => {
    const allPoints = await response.text();
    const points = allPoints
      .split("\n")
      .filter((line) => line.length > 0)
      .map((line) => JSON.parse(line));
    return points.map((point) => {
      let { x, y, z } = point["location"];
      x = x * IMAGE_SCALE_0;
      y = y * IMAGE_SCALE_0;
      z = z * IMAGE_SCALE_0;
      return [x, y, z] as vec3;
    });
  });
};

const ribosomeLocations = await fetchPositionsFromNDJson(ribosomesUrl);
const ferritinLocations = await fetchPositionsFromNDJson(ferritinUrl);
const virusLikeLocations = await fetchPositionsFromNDJson(virusLikeUrl);

type Marker = "circle" | "square" | "triangle";

class Particles extends Layer {
  public readonly type = "Particles";
  private readonly points_: vec3[] = [];
  private readonly color_: Color;
  private readonly marker_: Marker;
  private needsUpdate_: boolean = true;
  private depth_: number = 0;

  constructor(points: vec3[], color: ColorLike, marker: Marker) {
    super();
    this.setState("initialized");
    this.points_ = points;
    this.color_ = Color.from(color);
    this.marker_ = marker;
    this.refreshPointsRenderable();
    this.setState("ready");
  }

  public setDepth(z: number) {
    this.depth_ = z;
    this.needsUpdate_ = true;
  }

  public update(): boolean {
    if (!this.needsUpdate_) {
      return false;
    }

    this.refreshPointsRenderable();
    this.needsUpdate_ = false;
    return true;
  }

  private refreshPointsRenderable() {
    // TODO: change this to not re-create the renderable object
    // once Geometry supports updating buffers
    const { r, g, b } = this.color_;
    const scaledPoints = this.points_.map((p) => {
      const zDist = Math.abs(p[2] - this.depth_);
      // 64 is just a magic number that looks okay
      const zScale = zDist / 64.0 + 1.0;
      return {
        position: p,
        color: Color.from([r / zScale, g / zScale, b / zScale]),
        size: 20.0 / zScale,
        marker: this.marker_,
      };
    });
    const pointsRenderable = new PointsRenderable(scaledPoints);
    this.clearObjects();
    this.addObject(pointsRenderable);
  }
}
const ribosomes = new Particles(ribosomeLocations, Color.RED, "circle");
const ferritin = new Particles(ferritinLocations, Color.GREEN, "triangle");
const virusLike = new Particles(
  virusLikeLocations,
  new Color(1.0, 0.0, 1.0),
  "square"
);
ribosomes.setDepth(INITIAL_Z_POSITION);
ferritin.setDepth(INITIAL_Z_POSITION);
virusLike.setDepth(INITIAL_Z_POSITION);

const imageSource = await OmeZarrImageSource.fromHttp({ url: imageUrl });
const dimensions = imageSource.getDimensions();

const zDimName = "z";
const zMin = 0;
const lowestLod = dimensions.numLods - 1;
const zMax = dimensions.z!.lods[lowestLod].size;
const xLod = dimensions.x!.lods[lowestLod];
const yLod = dimensions.y!.lods[lowestLod];
const xExtent = xLod.size * xLod.scale;
const yExtent = yLod.size * yLod.scale;

const sliceCoords = { [zDimName]: INITIAL_Z_POSITION };

const imageLayer = new ImageLayer({
  source: imageSource,
  sliceCoords,
  policy: createNoPrefetchPolicy(),
  channelProps: [{ color: Color.WHITE, contrastLimits: [-0.00001, 0.00001] }],
});

const camera = new OrthographicCamera({
  left: 0,
  right: xExtent,
  top: 0,
  bottom: yExtent,
  near: -10000,
  far: 10000,
});
const canvas = document.querySelector<HTMLCanvasElement>("canvas")!;
const viewport = new Viewport({
  domElement: canvas,
  camera,
  layers: [imageLayer],
});
new Idetik({ canvas, viewports: [viewport] }).start();

const onFirstImageLoad = (newState: LayerState) => {
  if (newState === "ready") {
    viewport.cameraControls = new PanZoomControls(camera);
    camera.update();

    viewport.addLayer(ribosomes);
    viewport.addLayer(ferritin);
    viewport.addLayer(virusLike);

    // remove the callback to only set the camera frame once
    imageLayer.removeStateChangeCallback(onFirstImageLoad);
  }
};

imageLayer.addStateChangeCallback(onFirstImageLoad);

const controls = { zIndex: Math.round(INITIAL_Z_POSITION / IMAGE_SCALE_2) };
new GUI({ width: 280 })
  .close()
  .add(controls, "zIndex", zMin, zMax - 1, 1)
  .name("Z slice")
  .onChange((index: number) => {
    const worldZ = index * IMAGE_SCALE_2;
    sliceCoords[zDimName] = worldZ;
    ribosomes.setDepth(worldZ);
    ferritin.setDepth(worldZ);
    virusLike.setDepth(worldZ);
  });

Released under the MIT License. © 2026-present Chan Zuckerberg Biohub, Inc.