Skip to content

Data and encodings

An item is one thing in the collection: a unique, stable id, an optional weight (mark area is proportional to it; the default is 1), and its category in each dimension, by id, in values.

A dimension is one way of grouping the items: an id, a label and its categories in the order you want them drawn. A category can carry its own color and an href. Mark a dimension ordered: true when its categories run in a meaningful order, such as decades or sizes.

A value that is null, missing, or not one of the dimension’s categories is unknown. Unknown items are drawn, last, in their own category, labelled by the dimension’s unknownLabel.

The demos on this site share one made-up library:

library.ts
import type { FingerprintDimension, FingerprintItem } from "@data-profile/fingerprint";
// A made-up library for the demos: books with a genre, a publication decade and a reading state.
// Any collection works the same way; only the dimensions and their categories change.
export const dimensions: FingerprintDimension[] = [
{
id: "genre",
label: "Genre",
categories: [
{ id: "fiction", label: "Fiction" },
{ id: "science", label: "Science" },
{ id: "history", label: "History" },
{ id: "poetry", label: "Poetry" },
{ id: "travel", label: "Travel" },
{ id: "cookery", label: "Cookery" },
{ id: "art", label: "Art" },
],
unknownLabel: "Uncatalogued",
},
{
id: "decade",
label: "Published",
// Decades run in order, so this dimension may drive lightness.
ordered: true,
categories: ["1950", "1960", "1970", "1980", "1990", "2000", "2010", "2020"].map((decade) => ({
id: decade,
label: `${decade}s`,
})),
unknownLabel: "Unknown date",
},
{
id: "state",
label: "Reading state",
categories: [
{ id: "read", label: "Read" },
{ id: "reading", label: "Reading" },
{ id: "unread", label: "Unread" },
],
},
];
// Deterministic "randomness", so every visit draws the same library.
function unit(key: string): number {
let hash = 2166136261;
for (let i = 0; i < key.length; i++) hash = Math.imul(hash ^ key.charCodeAt(i), 16777619);
return (hash >>> 0) / 2 ** 32;
}
const pick = <T>(options: readonly T[], key: string, skew: number) =>
options[Math.floor(unit(key) ** skew * options.length)]!;
const genres = dimensions[0]!.categories.map((category) => category.id);
const decades = dimensions[1]!.categories.map((category) => category.id).reverse();
/** `count` books, mostly fiction and mostly recent, with a few unknown values. */
export function library(count: number): FingerprintItem[] {
return Array.from({ length: count }, (_, i) => {
const id = `book-${i}`;
return {
id,
values: {
genre: unit(`${id}:g?`) < 0.03 ? null : pick(genres, `${id}:g`, 1.8),
decade: unit(`${id}:d?`) < 0.05 ? null : pick(decades, `${id}:d`, 1.4),
state: pick(["read", "unread", "reading"], `${id}:s`, 1.3),
},
};
});
}

One dimension arranges the marks: it orders them along the ridges, so its categories form runs from the core outwards. Colour, shape and lightness can each show a dimension too, so one print can show three things at once:

  • leave color and shape out and they follow arrange; set one to null to turn it off;
  • lightness needs an ordered dimension: the first category is faintest. On unordered categories, fainter and stronger would read as less and more, so that is an error;
  • colour stays distinguishable to about 10 categories and the default shapes to 5; beyond those, layout.warnings says so;
  • size is never an encoding: mark area is reserved for weight.

A view is a named encoding. With several, FingerprintViewSwitch moves between them: marks glide to their new places and the ridges stay put, because the print’s form comes from all the data, not the view.

A selection is a category of a dimension, so choosing “Unread” in the reading-state legend picks out unread books whatever their genre. Choose it again to clear.

annotations pin short notes to the print. You write the words; the canvas points a leader at a real mark of the categories you name, and drops a note whose categories have no mark. Callouts sit beside the print in a wide container and are numbered beneath it in a narrow one.

Running the code below: combined-view.tsx
combined-view.tsx
import { useMemo } from "react";
import type { FingerprintAnnotation } from "@data-profile/fingerprint";
import {
FingerprintCanvas,
FingerprintLegend,
FingerprintViewSwitch,
useFingerprint,
type FingerprintView,
} from "@data-profile/fingerprint/react";
import "@data-profile/fingerprint/styles.css";
import { dimensions, library } from "./library";
// One combined view showing three things at once, then one view per dimension.
const views: FingerprintView[] = [
{
id: "combined",
label: "Combined",
encoding: { arrange: "genre", color: "genre", shape: "state", lightness: "decade" },
},
{ id: "genre", label: "Genre", encoding: { arrange: "genre" } },
{ id: "decade", label: "Published", encoding: { arrange: "decade" } },
{ id: "state", label: "Reading state", encoding: { arrange: "state" } },
];
// Callouts: you write the words; the canvas points at a real mark of the named categories.
const annotations: FingerprintAnnotation[] = [
{
id: "fiction",
dimensionId: "genre",
categoryIds: ["fiction"],
title: "Mostly fiction",
body: "The largest genre forms the core.",
},
{
id: "unread",
dimensionId: "state",
categoryIds: ["unread"],
title: "A long reading list",
body: "Unread books, wherever they sit.",
},
];
export default function CombinedView() {
const items = useMemo(() => library(320), []);
const fp = useFingerprint({ items, dimensions, views, unit: "books" });
return (
<div className="demo-split">
<div>
<FingerprintViewSwitch views={fp.views} value={fp.viewId} onChange={fp.setViewId} />
<FingerprintCanvas {...fp.canvasProps} title="A library" annotations={annotations} />
</div>
<div>
{fp.layout.legends.map((group) => (
<FingerprintLegend key={group.dimensionId} {...fp.legendProps(group)} hideEmpty />
))}
</div>
</div>
);
}

layoutFingerprint({ items, dimensions, encoding }) is the whole layout as plain data, with no React: positioned nodes, one of legends per encoded dimension, and the ridges as polylines, in a fixed width × height frame. It throws FingerprintDataError on data it cannot draw truthfully; useFingerprint returns that as error instead. See the API reference.

Fingerprints come in three patterns: loop, whorl and arch. formFromItems, which useFingerprint uses, picks one from the data (an arch under 12 items, a loop when one category of the first dimension holds 40% or more, otherwise a whorl) and places the core from the other dimensions. It changes in coarse steps, so a collection keeps its print as it grows by an item or two. Pass form to override any part of it.