Snips, the building blocks of our labbook
Snips are the main building blocks of the lab book. Every element that is rendered on a page in the lab book can be considered as snip. We implement a number of basic snip types, which can be extended and used for your own purposes.
This allows you to integrate your measuring instruments into the lab book workflow, giving yourself or your researchers an easy and automatic way to store, access and document their work.
General data structure
Generally, snips consist of serializable data and some logic for displaying the snip and optional user interactions with the snip. We further split the serializable data into a data
and view
part. The data
part contains the raw data that the snip represents, while the view
part contains information on how to display this data.
For the Base
snip class, which is the foundation for all other snips, the data only holds the type identifier itself and the view hold the position of the snip on the page and an optional rotation angle and x-mirroring.
type ID = number;
export interface BaseData {
//Empty (type sepcific)
}
export interface BaseView {
x: number;
y: number;
rot?: number;
mirror?: boolean;
}
export interface SnipData<D extends BaseData, V extends BaseView> {
// required fields
type: string;
book_id: number;
data: D;
// optional fields
page_id?: number;
view?: V;
// automatically populated on db insert
id: ID;
last_updated: Date;
created: Date;
created_by: ID;
}
We define the typing for each snip using the arktype library, this allows validation and also gives us the ability to propergate meaningful error messages to the users. But basically this looks very similar to the interfaces above.
You can find all defined snips in the snips
package, i.e. the /packages/snips
folder. Feel free to have a look, there are a number of basic and generic snip types which are already implemented and can be extended freely.
Implementing custom snips
In the following we want to guide you threw the process of adding a new snip to the labbook. For example you might have some specific ideas on how to visualize some custom data.
Let's say we have a simple sensor which records temperature data during an experiment. We want to display this data in a graph on the page after the measurement is run. To do that we want to implement a snip type which stores the data and renders the graph on the page.
Setup a namespace
To keep our main repo relatively clean but still allow outside contribution for snip types, we have created namespaces for custom snip implementations, where each contributor can create their own namespace to house their snip types. This not only helps in organizing the codebase but also allows for easy integration and management of various snip types contributed by different developers or teams.
You may find all snips in the packages/snips
packages inside our monorepo. Here each namespace is given by a folder.
src
├── [namespace_folder]
├── general
├── uprp
└── get_snip_from_data.ts
To create a valid namespace, create a folder with a __mapping.ts
file, exporting a Map which registering all custom snips.
const TYPE_TO_SNIP: Map<string, typeof BaseSnip> = new Map();
TYPE_TO_SNIP.set("customSnipType", CustomSnip);
export default TYPE_TO_SNIP;
For the full example have a look at the example
namespace, which can be used as a template for your own custom snip implementations.
Defining the types
First of we think about the data which is recorded by our sensor. In our example, the data consists of two arrays containing temperatures and timestamps.
import { type } from "arktype";
const TemperatureDataSchema = type({
// A temperature arry with atleast one entry
temperature: "number[] > 0"
// Iso date strings
time: "string.parse.date.iso[]"
})
export type TemperatureData = typeof TemperatureData.infer;
Next we think about the visualizaton, which for instance could contain the color and style of the drawn lines. Here we extend the Base View typing to already include generic view fields such as the coordinates and rotation of the snip.
import { type } from "arktype";
import { BaseViewSchema } from "@/general/base";
const TemperatureViewSchema = type(BaseViewSchema, "&", {
line_color: "string",
line_style: "string"
});
export type TemperatureView = typeof TemperatureViewSchema.infer;
Implementing the serialization logic and visualizaton
We can now have a look at the visualizaton of snippet, in a typical object oriented pattern we extend the Abstract Base Class and implement some necessary method. In general each new snip has to implement a constructor
, from_data
, view
and data
method for serialization purpose and render method. And a render
method for the visualizaton.
Additionally, if you want hover checks (interactions) to work you may want to also implement height
, width
and polygon
getters. Please have a look at the base class for more information on this.
import { type } from "arktype";
import { BaseSnip ,SnipDataSchema} from "@/general/base";
const TemperatureSnipSchema = type(SnipDataSchema, "&", {
data: TemperatureDataSchema,
"view?": TemperatureViewSchema
});
type TemperatureArgs = BaseSnipArgs &
TemperatureData &
TemperatureView;
export class TemperatureSnip extends BaseSnip {
static readonly _schema = TemperatureSnipSchema;
public type = "temperature";
temperature: number[];
time:Date[];
line: {
color: string;
style: "." | "-";
}
constructor({
temperature,
time,
line_color = "black",
line_style = "-"
...baseArgs
}:TemperatureArgs){
super({...baseArgs});
this.temperature = temperature;
this.time = time;
assert this.time.length == tis.temperature.length;
this.line = {
color:line_color,
style:line_style
}
}
static from_data(data: SnipData<TemperatureData,TemperatureView>){
const validation = TemperatureSnipSchema(data);
if (validation instanceof type.errors) {
throw DataValidationError.from_ark(validation);
}
return new TemperatureSnip({
temperature: validation.temperature,
time: validation.time,
//Base
id: validation.id,
page_id: validation.page_id,
book_id: validation.book_id,
last_updated: validation.last_updated,
created: validation.created,
x: validation.view?.x,
y: validation.view?.y,
rot: validation.view?.rot,
mirror: validation.view?.mirror,
})
}
data(): TemperatureData {
return {
temperature:this.temperature,
time:this.time
}
}
view(): TemperatureView {
const view = super.view() as TemperatureView;
if (this.line.color != "black") {
view.line_color = this.line.color,
}
if (this.line.style != "-"){
view.line.style = self.line.style
}
return view;
}
// Rendering
// very basically we just draw a line or dots
render(ctx: RenderContext) {
ctx.strokeStyle = this.color;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(this.times[0], this.temperature[0]);
for (let i = 1; i < this.times.length ; i += 1) {
ctx.lineTo(this.times[i]!, this.temperature[i]!);
}
ctx.stroke();
}
}
Ofcourse in deployment we would polish this example a bit more, especially the render function, for instance add axis labels, more configurable line styles...
Even thought we implemented all which is needed for the temperature snippet, we still need to register it such that it can be used. We can do this by adding the class to the global or namespace snip mapping.
// __mapping.ts
const TYPE_TO_SNIP: Map<string, typeof BaseSnip> = new Map();
TYPE_TO_SNIP.set("temperature", TemperatureSnip);
export default TYPE_TO_SNIP;