Styling and annotations

Most styling is set on a ModelView with setProperty. Labels, distances, and spheres are drawn on the model, and the theme sets the background. This page covers each one, and ends with custom three.js objects and screenshots.

Change colour, size and opacity

Each atom has color, opacity, radius (in Å), scale (a multiplier on the default radius), visible, and highlighted. setProperty(name, value) sets one of these across a view. The value can be:

Leave out the value to reset the property to its default. For color, opacity, radius, and scale, that means the CPK colour, full opacity, the default radius, and a scale of 1. For visible, it means visible.

Colours can be hex integers such as 0xff0000 or CSS strings such as '#8fdb95'. Setting an atom's colour or opacity also sets the same on the bonds at its ends. Opacity below 1 makes the atom transparent.

The example shrinks the oxygen atoms, recolours the silicon atoms, and makes the oxygen semi-transparent.

const cif = await fetch('data/CHA.cif').then((r) => r.text());
vis.loadModels(cif, 'cif', 'cha');
vis.displayModel('cha_CHA');

vis.displayed.find({ elements: 'O' }).setProperty('scale', 0.6);
vis.displayed.find({ elements: 'Si' }).setProperty('color', '#8fdb95');
vis.displayed.find({ elements: 'O' }).setProperty('opacity', 0.5);

console.log(vis.displayed.find({ elements: 'Si' }).length, 'Si and',
    vis.displayed.find({ elements: 'O' }).length, 'O atoms in the cell');

Colour atoms by a property

To colour atoms from data, pass a function. It receives each AtomImage and its index in the view, and returns a colour. Hex integers are the simplest return value, so a function that computes a number works directly.

The example maps each atom's fractional z coordinate onto a blue-to-red ramp:

const cif = await fetch('data/CHA.cif').then((r) => r.text());
vis.loadModels(cif, 'cif', 'cha');
vis.displayModel('cha_CHA');

// Blue at z = 0, red at z = 1
const ramp = (t) => {
    const r = Math.round(255 * Math.min(1, Math.max(0, t)));
    return (r << 16) | (255 - r);
};

vis.displayed.setProperty('color', (a) => ramp(a.fxyz[2]));
console.log('Coloured', vis.displayed.length, 'atoms by fractional z');

If you already have one value per atom, pass an array of the same length as the view instead of a function.

Show and hide atoms

vis.displayed is the set of atoms that is shown. To show a subset, set displayed to a new view. Setting it hides the old view and shows the new one.

vis.displayed = vis.displayed.remove(vis.model.find({ elements: 'O' }));

remove(view) returns a view without the atoms in view. Avoid not() here: it is the complement over every atom in the model, including atoms in other cells, not over the displayed set.

displayModel() centres the camera on the displayed atoms and zooms so they fill the view. After you change vis.displayed, call vis.fitCamera() to do the same for the new set. It takes any ModelView, vis.fitCamera(view, padding), and padding (default 0.1) sets the margin.

view.hide() and view.show() change visibility without changing displayed, so the next time you set displayed the change is undone. Set displayed instead. To hide one atom, set atom.visible = false. The value is kept if the model is not displayed, and applied when it is shown.

Adjust bonds

There is no view-level bond API. Each atom has atom.bonds, a list of BondImage objects with radius (default 0.2 Å), visible, length, atom1, and atom2. Set these properties on the bonds you want:

vis.displayed.map((a) => a.bonds).flat().forEach((b) => {
    b.radius = 0.1;
});

vis.displayed.map((a) => a.bonds).flat()
    .filter((b) => b.atom1.element === 'O' || b.atom2.element === 'O')
    .forEach((b) => {
        b.visible = false;
    });

With the default 1×1×1 supercell, a bond to an atom in a neighbouring cell is not drawn, because that atom is not part of the model. Load a larger supercell if you need those bonds.

Label atoms

addLabels(text, name, params) adds a text label to each atom in the view. text is a string, an array, or a function (atom, i), and it defaults to the element symbol. name identifies the label set, so that removeLabels(name) and labelProperties(name, property, value) can find it.

The third argument is an object, or a function that returns one per atom. The useful options are:

Call labelProperties(name, property) with no value to read a property, which returns an array. Call it with a value to set one, for example labelProperties('site', 'color', 0xf2d21b). It only changes atoms that already have that label.

If a view holds several images of the same site, call uniqueSites() first. It keeps one atom per crystLabel. The example loads an organic crystal with molecularCrystal: true, so each molecule is whole, then shows one molecule, hides the cell box, fits the camera to the molecule, and labels its heavy atoms. Hydrogens stay visible but unlabelled:

const cif = await fetch('data/org.cif').then((r) => r.text());
vis.loadModels(cif, 'cif', 'org', { molecularCrystal: true });
vis.displayModel('org_1501936');

vis.displayed = vis.model.find({ molecule: [vis.displayed.atoms[0]] });
vis.model.box.visible = false;
vis.fitCamera();

const heavy = vis.displayed.find({ elements: ['C', 'O'] });
heavy.addLabels((a) => a.crystLabel, 'site', { height: 0.03, shift: [0.2, 0.2, 0], onOverlay: true });

console.log(heavy.length, 'labelled atoms in one molecule');

Labels take the theme's label_color (white on dark, black on light) unless you pass color. A label keeps the colour it had when it was added, so set the theme before you add labels.

Draw distances and spheres

vis.model.addLink(from, to, name, label, params) draws a line between two points. from and to can be [x, y, z] in Å or AtomImage objects. The optional label is text drawn at the midpoint, and params takes color, dashed, linewidth, and onOverlay. Reusing a name replaces the earlier line. Use removeGraphics(name) to remove one graphic, and clearGraphics() to remove them all.

vis.model.addSphere(center, radius, name, params) draws a sphere with radius in Å. center is [x, y, z] or an AtomImage. Its defaults are a white colour and an opacity of 0.5.

A link label takes the link's color, or the theme's label_color if there is none. Use labelColor to set the label colour separately.

The example takes the silicon nearest the cell centre, links it to its nearest bonded oxygen, and links it to the nearest silicon that does not share an oxygen with it. It then draws a 3 Å sphere around the silicon. The labels show the distances:

const cif = await fetch('data/CHA.cif').then((r) => r.text());
vis.loadModels(cif, 'cif', 'cha');
vis.displayModel('cha_CHA');

const dist = (p, q) => Math.hypot(...p.map((v, i) => v - q[i]));
const byDist = (ref) => (p, q) => dist(p.xyz, ref) - dist(q.xyz, ref);

const sis = vis.displayed.find({ elements: 'Si' }).atoms;
const si = sis.sort(byDist(vis.model.fracToAbs([0.5, 0.5, 0.5])))[0];
const o = si.bondedAtoms.filter((b) => b.element === 'O').sort(byDist(si.xyz))[0];
const other = sis
    .filter((a) => a !== si && !a.bondedAtoms.some((b) => si.bondedAtoms.includes(b)))
    .sort(byDist(si.xyz))[0];

vis.model.addLink(si, o, 'si-o', `${dist(si.xyz, o.xyz).toFixed(2)} Å`, { dashed: true });
vis.model.addLink(si, other, 'si-si', `${dist(si.xyz, other.xyz).toFixed(2)} Å`, { color: 0x8fdb95 });
vis.model.addSphere(si, 3.0, 'shell', { color: 0x66ccff, opacity: 0.15 });

console.log('Si-O', dist(si.xyz, o.xyz).toFixed(3), 'Å; Si-Si', dist(si.xyz, other.xyz).toFixed(3), 'Å');

Note: addLink and addSphere do nothing, and do not throw, unless the model is displayed. Call them after displayModel.

Unit cell and theme

vis.theme is 'dark' (the default, with a black background) or 'light'. You can also pass an object with hex-integer values for the keys background, foreground, highlight, cell_line_color, and label_color. The library reads background, cell_line_color, and label_color. It stores foreground and highlight but does not use them, so give all five keys anyway.

The unit-cell box takes cell_line_color when its model is first displayed. Set the theme before displayModel, or set vis.model.box.color afterwards, as the example does. Labels and notifications take label_color when they are created.

The example switches to the light theme, sets a cobalt box, labels two sites, and adds a notification. The labels are black because they are created after the theme changes:

vis.theme = 'light';

const cif = await fetch('data/CHA.cif').then((r) => r.text());
vis.loadModels(cif, 'cif', 'cha');
vis.displayModel('cha_CHA');

vis.model.box.color = 0x2d47c9;
vis.displayed.find({ labels: ['T1', 'O1'] }).uniqueSites().addLabels((a) => a.crystLabel, 'site');
vis.addNotification('Light theme, cobalt cell box');

Add your own three.js objects and isosurfaces

vis.addPrimitive(object) adds any THREE.Object3D to the scene, and vis.removePrimitive(object) removes it. Its coordinates are in Å, in the same frame as the atom positions. The object stays in the scene across displayModel() calls until you remove it.

IsosurfaceMesh draws a surface through a volumetric field. Import it from the library's internal path, @ccp-nc/crystvis-js/lib/primitives/index.js. This is not part of the stable public API, so its constructor may change between releases.

new IsosurfaceMesh(field, threshold, lattice, options)

opacityMode is IsosurfaceMesh.RENDER_DITHER (the default), RENDER_PHONG, or RENDER_WFRAME. isoMethod is IsosurfaceMesh.ISO_SURFACE_NETS (the default), ISO_MARCHING_CUBES, or ISO_MARCHING_TETRAHEDRA. The color and opacity properties can be changed later. Opacity has no effect in wireframe mode.

The example builds a 24³ grid whose values are the distance from the cell centre, then draws the surface at a radius of 6 grid points:

import { IsosurfaceMesh } from '@ccp-nc/crystvis-js/lib/primitives/index.js';

const cif = await fetch('data/CHA.cif').then((r) => r.text());
vis.loadModels(cif, 'cif', 'cha');
vis.displayModel('cha_CHA');

// Distance from the cell centre, in grid points, on a 24 x 24 x 24 grid
const n = 24;
const c = n / 2;
const field = [];
for (let x = 0; x < n; x++) {
    field.push([]);
    for (let y = 0; y < n; y++) {
        field[x].push([]);
        for (let z = 0; z < n; z++) {
            field[x][y].push(Math.hypot(x - c, y - c, z - c));
        }
    }
}

// The surface is the sphere of radius 6 grid points
const iso = new IsosurfaceMesh(field, 6, vis.model.cell, { color: 0x66ccff, opacity: 0.4 });
vis.addPrimitive(iso);
console.log('Sphere radius', 6 / n, 'of the cell edge');

Save a screenshot

vis.getScreenshotData(transparent = true, pixelRatio = 3) returns a PNG data URL of the current view. It is synchronous: it renders a frame and reads the canvas before it returns. Pass false for transparent to get an opaque background in the theme's background colour.

const link = document.createElement('a');
link.href = vis.getScreenshotData(false, 2);
link.download = 'structure.png';
link.click();