From b4d5e881f0fcf42baf0ebc40fb754d5d05ef8815 Mon Sep 17 00:00:00 2001
From: Sebastian Mohr <sebastian@mohrenclan.de>
Date: Fri, 28 Mar 2025 12:55:07 +0100
Subject: [PATCH] Added a generic parts system for hopefully easier transfer of
 plotting to allow for mouse highlighting and copy.

---
 packages/snips/src/parts/abc.ts         |  25 +
 packages/snips/src/parts/table.ts       | 199 ++++++++
 packages/snips/src/parts/text.ts        | 196 ++++++++
 packages/snips/src/uprp/stampy.ts       | 618 +++++++++++++++---------
 packages/snips/stampy_example_snip.json |  16 +-
 5 files changed, 816 insertions(+), 238 deletions(-)
 create mode 100644 packages/snips/src/parts/abc.ts
 create mode 100644 packages/snips/src/parts/table.ts
 create mode 100644 packages/snips/src/parts/text.ts

diff --git a/packages/snips/src/parts/abc.ts b/packages/snips/src/parts/abc.ts
new file mode 100644
index 00000000..e0ee76ca
--- /dev/null
+++ b/packages/snips/src/parts/abc.ts
@@ -0,0 +1,25 @@
+import { RenderContext } from "@/general/base";
+
+/** An abstract class for all parts.
+ *
+ * Parts may be rendered but have far less functionality than snips.
+ */
+export abstract class Part {
+    render(ctx: RenderContext): void {
+        ctx.save();
+        this._render(ctx);
+        ctx.restore();
+    }
+
+    /** The actual render function. This should be implemented by all parts.
+     *
+     * This function is called by the render function and should not be called
+     * directly.
+     */
+    abstract _render(ctx: RenderContext): void;
+
+    abstract size(ctx: RenderContext): {
+        width: number;
+        height: number;
+    };
+}
diff --git a/packages/snips/src/parts/table.ts b/packages/snips/src/parts/table.ts
new file mode 100644
index 00000000..7161c0a9
--- /dev/null
+++ b/packages/snips/src/parts/table.ts
@@ -0,0 +1,199 @@
+import { RenderContext } from "@/general/base";
+import { Part } from "./abc";
+
+interface TableStyling {
+    columnWidths?: number[];
+    borderStyle?: string;
+    textStyle?: {
+        fontSize?: number;
+        headerFontSize?: number;
+        fill?: string;
+    };
+    gap?: {
+        row?: number;
+        column?: number;
+    };
+    labelWidth?: number;
+    columnAlign?: ("left" | "right" | "center")[];
+}
+
+/** Minimal table with minimal styling.
+ */
+export class Table extends Part {
+    private style: TableStyling;
+    private data: string[][];
+
+    private headers?: string[]; // titles horizontal
+    private labels?: string[]; // titles vertical
+    private title?: string; // title of the table (placed top left)
+
+    constructor(
+        data: string[][],
+        styling?: TableStyling,
+        headers?: string[],
+        labels?: string[],
+        title?: string,
+    ) {
+        super();
+        this.style = styling || {};
+
+        this.data = data;
+        this.headers = headers;
+        this.labels = labels;
+        this.title = title;
+    }
+
+    get columnWidths() {
+        return this.style.columnWidths || this.data[0]!.map(() => 100);
+    }
+
+    get fontSize() {
+        return this.style.textStyle?.fontSize || 24;
+    }
+
+    get headerFontSize() {
+        return this.style.textStyle?.headerFontSize || 24;
+    }
+
+    get labelWidth() {
+        return this.style.labelWidth || 100;
+    }
+
+    get width() {
+        let w = this.columnWidths.reduce((a, b) => a + b);
+
+        if (this.labels) {
+            w += this.labelWidth;
+        }
+
+        return w;
+    }
+
+    get height() {
+        const rowGap = this.style.gap?.row || 0;
+        let h = this.data.length * (this.fontSize + rowGap);
+
+        if (this.headers) {
+            h += this.headerFontSize + rowGap;
+        }
+
+        return h;
+    }
+
+    size(ctx: RenderContext) {
+        return {
+            width: this.width,
+            height: this.height,
+        };
+    }
+
+    _render(ctx: RenderContext) {
+        ctx.save();
+
+        // render rect around
+        ctx.textBaseline = "top";
+        ctx.strokeStyle = "#000";
+        ctx.lineWidth = 1;
+
+        // Render headers
+        if (this.headers) {
+            ctx.font = `bold ${this.headerFontSize}px Arial`;
+            ctx.fillStyle = this.style.textStyle?.fill || "#000";
+            if (this.title) {
+                this.renderRow(ctx, [this.title]);
+            }
+            if (this.labels || this.title) {
+                // skip title
+                ctx.translate(this.labelWidth, 0);
+            }
+            this.renderRow(ctx, this.headers);
+            ctx.translate(
+                this.labels ? -this.labelWidth : 0,
+                this.fontSize + (this.style.gap?.row || 0),
+            );
+
+            //Draw line
+            const m = ctx.measureText(this.headers[0]!);
+            console.log(m);
+            ctx.beginPath();
+            ctx.moveTo(0, -m.hangingBaseline - (this.style.gap?.row || 0) / 2);
+            ctx.lineTo(
+                this.width,
+                -m.hangingBaseline - (this.style.gap?.row || 0) / 2,
+            );
+            ctx.stroke();
+        }
+
+        // Set font
+        ctx.font = `${this.fontSize}px PlexMono`;
+        ctx.fillStyle = this.style.textStyle?.fill || "#000";
+
+        // Render data
+        this.renderRows(ctx, this.data);
+        ctx.restore();
+    }
+
+    private renderRows(ctx: RenderContext, data: string[][]) {
+        const rowGap = this.style.gap?.row || 0;
+
+        let height = 0;
+        for (let i = 0; i < data.length; i++) {
+            let offset = 0;
+            // resolve labels
+            if (this.labels && this.labels[i]) {
+                ctx.font = `bold ${this.fontSize}px PlexMono`;
+                offset = this.labelWidth;
+                const rowLabel = this.labels[i]!;
+
+                // center
+                ctx.fillText(
+                    rowLabel,
+                    (offset - ctx.measureText(rowLabel).width) / 2,
+                    0,
+                );
+            }
+
+            ctx.font = `${this.fontSize}px PlexMono`;
+            ctx.translate(offset, 0);
+            height += this.renderRow(ctx, data[i]!) + rowGap;
+            ctx.translate(-offset, this.fontSize + rowGap);
+        }
+
+        // Draw line
+        if (this.labels) {
+            ctx.beginPath();
+            ctx.moveTo(this.labelWidth, this.fontSize / 3);
+            ctx.lineTo(this.labelWidth, -(height + this.fontSize / 1.5));
+            ctx.stroke();
+        }
+    }
+    private renderRow(ctx: RenderContext, row: string[]) {
+        let currentX = 0;
+        const colGap = this.style?.gap?.column || 0;
+
+        for (let i = 0; i < row.length; i++) {
+            const width = this.columnWidths[i]!;
+            const text = row[i]!;
+            const align = this.style.columnAlign?.[i] || "center";
+            const textWidth = ctx.measureText(text).width;
+
+            let x = 0;
+            switch (align) {
+                case "left":
+                    x = currentX + colGap / 2;
+                    break;
+                case "right":
+                    x = currentX + width - textWidth - colGap / 2;
+                    break;
+                case "center":
+                    x = currentX + (width / 2 - textWidth / 2);
+                    break;
+            }
+            console.log(text, x, currentX, width / 2, textWidth);
+            ctx.fillText(text, x, 0);
+            currentX += width;
+        }
+
+        return this.fontSize;
+    }
+}
diff --git a/packages/snips/src/parts/text.ts b/packages/snips/src/parts/text.ts
new file mode 100644
index 00000000..d979612d
--- /dev/null
+++ b/packages/snips/src/parts/text.ts
@@ -0,0 +1,196 @@
+import { Part } from "./abc";
+
+import { RenderContext } from "@/general/base";
+
+export interface FontStyle {
+    fontSize: number;
+    fontFamily: string;
+    fontWeight: string;
+    textAlign: "left" | "right" | "center";
+    textBaseline: "top" | "middle" | "bottom";
+    fill: string;
+}
+export class TextPart extends Part {
+    protected text: string;
+    protected fontStyle: Required<FontStyle>;
+
+    constructor(text: string, fontStyle?: Partial<FontStyle>) {
+        super();
+        this.text = text;
+
+        this.fontStyle = {
+            fontSize: fontStyle?.fontSize || 24,
+            fontFamily: fontStyle?.fontFamily || "Arial",
+            fontWeight: fontStyle?.fontWeight || "normal",
+            textAlign: fontStyle?.textAlign || "left",
+            textBaseline: fontStyle?.textBaseline || "top",
+            fill: fontStyle?.fill || "black",
+        };
+    }
+
+    get fontString() {
+        return `${this.fontStyle.fontWeight} ${this.fontStyle.fontSize}px ${this.fontStyle.fontFamily}`;
+    }
+
+    get fontSize() {
+        return this.fontStyle.fontSize;
+    }
+
+    _render(ctx: RenderContext) {
+        ctx.font = this.fontString;
+        ctx.fillStyle = this.fontStyle.fill;
+        ctx.textAlign = this.fontStyle.textAlign;
+        ctx.textBaseline = this.fontStyle.textBaseline;
+        ctx.fillText(this.text, 0, 0);
+    }
+
+    size(ctx: RenderContext) {
+        return {
+            width: ctx.measureText(this.text).width,
+            height: this.fontSize,
+        };
+    }
+}
+
+export class KeyValuePart extends TextPart {
+    private value: string;
+    private fontStyleValue: Required<FontStyle>;
+
+    constructor(
+        key: string,
+        value: string,
+        fontStyleKey?: Partial<FontStyle>,
+        fontStyleValue?: Partial<FontStyle>,
+    ) {
+        super(
+            key + ": ",
+            fontStyleKey || {
+                fontSize: 24,
+                fontFamily: "Arial",
+                fontWeight: "bold",
+                textAlign: "left",
+                textBaseline: "top",
+                fill: "black",
+            },
+        );
+        this.value = value;
+
+        this.fontStyleValue = {
+            fontSize: fontStyleValue?.fontSize || 24,
+            fontFamily: fontStyleValue?.fontFamily || "Arial",
+            fontWeight: fontStyleValue?.fontWeight || "normal",
+            textAlign: fontStyleValue?.textAlign || "left",
+            textBaseline: fontStyleValue?.textBaseline || "top",
+            fill: fontStyleValue?.fill || "black",
+        };
+    }
+
+    get fontStringValue() {
+        return `${this.fontStyleValue.fontWeight} ${this.fontStyleValue.fontSize}px ${this.fontStyleValue.fontFamily}`;
+    }
+
+    _render(ctx: RenderContext) {
+        // Render key
+        super._render(ctx);
+        const metrics = super.size(ctx);
+
+        ctx.font = this.fontStringValue;
+        ctx.fillStyle = this.fontStyleValue.fill;
+        ctx.textAlign = this.fontStyleValue.textAlign;
+        ctx.textBaseline = this.fontStyleValue.textBaseline;
+        ctx.fillText(this.value, metrics.width, 0);
+    }
+
+    size(ctx: RenderContext) {
+        const metrics = super.size(ctx);
+        const metricsValue = ctx.measureText(this.value);
+        return {
+            width: metrics.width + metricsValue.width,
+            height: Math.max(metrics.height, this.fontStyleValue.fontSize),
+        };
+    }
+}
+
+/** Text block with out wrapping */
+export class TextBlock extends Part {
+    protected text: string;
+    protected fontStyle: Required<FontStyle>;
+    protected maxWidth: number;
+
+    constructor(
+        text: string,
+        maxWidth: number,
+        fontStyle?: Partial<FontStyle>,
+    ) {
+        super();
+        this.text = text;
+        this.maxWidth = maxWidth;
+
+        this.fontStyle = {
+            fontSize: fontStyle?.fontSize || 24,
+            fontFamily: fontStyle?.fontFamily || "Arial",
+            fontWeight: fontStyle?.fontWeight || "normal",
+            textAlign: fontStyle?.textAlign || "left",
+            textBaseline: fontStyle?.textBaseline || "top",
+            fill: fontStyle?.fill || "black",
+        };
+    }
+
+    get fontString() {
+        return `${this.fontStyle.fontWeight} ${this.fontStyle.fontSize}px ${this.fontStyle.fontFamily}`;
+    }
+
+    _render(ctx: RenderContext): void {
+        ctx.font = this.fontString;
+        ctx.fillStyle = this.fontStyle.fill;
+        ctx.textAlign = this.fontStyle.textAlign;
+        ctx.textBaseline = this.fontStyle.textBaseline;
+
+        const block = this.text.split("\\n");
+        for (let i = 0; i < block.length; i++) {
+            const [, y] = this.__renderWrap(ctx, block[i]!);
+            ctx.translate(0, y);
+        }
+    }
+
+    __renderWrap(ctx: RenderContext, text: string): [number, number] {
+        const parts = text.match(/\S+|\s+/g) || [];
+        let currentX = 0;
+        let currentY = 0;
+        for (let part of parts) {
+            const metrics = ctx.measureText(part);
+            if (currentX + metrics.width > this.maxWidth) {
+                currentX = 0;
+                currentY +=
+                    metrics.fontBoundingBoxAscent +
+                    metrics.fontBoundingBoxDescent;
+                part = part.trimStart();
+            }
+            ctx.fillText(part, currentX, currentY);
+            currentX += ctx.measureText(part).width;
+        }
+        return [currentX, currentY + this.fontStyle.fontSize];
+    }
+
+    size(ctx: RenderContext) {
+        ctx.font = this.fontString;
+        const parts = this.text.match(/\S+|\s+/g) || [];
+        let currentX = 0;
+        let currentY = this.fontStyle.fontSize;
+        for (let part of parts) {
+            const metrics = ctx.measureText(part);
+            if (currentX + metrics.width > this.maxWidth) {
+                currentX = 0;
+                currentY +=
+                    metrics.fontBoundingBoxAscent +
+                    metrics.fontBoundingBoxDescent;
+                part = part.trimStart();
+            }
+            currentX += ctx.measureText(part).width;
+        }
+        return {
+            width: this.maxWidth,
+            height: currentY + this.fontStyle.fontSize,
+        };
+    }
+}
diff --git a/packages/snips/src/uprp/stampy.ts b/packages/snips/src/uprp/stampy.ts
index c237130c..25ebe5af 100644
--- a/packages/snips/src/uprp/stampy.ts
+++ b/packages/snips/src/uprp/stampy.ts
@@ -1,6 +1,7 @@
 import { type } from "arktype";
 
 import { DataValidationError } from "@/errors";
+import { ArraySnip } from "@/general/array";
 import {
     BaseSnip,
     BaseSnipArgs,
@@ -9,6 +10,9 @@ import {
     SnipData,
     SnipDataSchema,
 } from "@/general/base";
+import { Part } from "@/parts/abc";
+import { Table } from "@/parts/table";
+import { FontStyle, KeyValuePart, TextBlock, TextPart } from "@/parts/text";
 
 /* -------------------------------------------------------------------------- */
 /*                              Schema for insert                             */
@@ -119,6 +123,17 @@ const StampySchema = type({
             cz: "number",
             stzrot: "number",
         },
+        detector: {
+            mdetx: "number",
+            mdety: "number",
+            mdetz: "number",
+        },
+        energy: {
+            mono: "number",
+            unit: "string",
+        },
+        innerLoop: "Record<string,string|number>[]",
+        outerLoop: "Record<string,string|number>[]",
     },
 });
 
@@ -151,15 +166,56 @@ export class StampySnip extends BaseSnip {
     public type = "stampy";
     stampy: StampyData["stampy"];
 
-
     // Indent of the text blocks
-    padding: number = 5;
+    padding: number = 8;
 
+    parts: Part[];
 
     constructor({ version, stampy, ...baseArgs }: StampySnipArgs) {
         super({ ...baseArgs });
 
         this.stampy = stampy;
+
+        // Crate render parts
+        const header = new TextPart(`Sample: ${this.stampy.sample.name}`, {
+            fontSize: 35,
+            fill: "#000",
+            fontWeight: "bold",
+        });
+        const id = new KeyValuePart("ID", "" + this.stampy.sample.id);
+        const date = new KeyValuePart("Date", this.stampy.meta.date);
+        const table = new MotorsTable(this.stampy.motors);
+        const timings = new TimingsSection("Timings:", {
+            Illumination: `${this.stampy.measurement.timing.illumination}s`,
+            Interval: `${this.stampy.measurement.timing.interval}s`,
+        });
+        const grid = new SampleGrid(this.stampy.measurement);
+        const comment = new CommentSection(
+            "Comment:",
+            this.stampy.sample.comment,
+            {
+                fontSize: 24,
+                fill: "#000",
+                fontWeight: "normal",
+            },
+        );
+        this.parts = [header, id, date, table, timings, grid, comment];
+        if (
+            this.stampy.motors.outerLoop.length > 0 ||
+            this.stampy.motors.innerLoop.length > 0
+        ) {
+            const loops = new LoopTables(
+                "Loops:",
+                this.stampy.motors.outerLoop,
+                this.stampy.motors.innerLoop,
+                {
+                    fontSize: 24,
+                    fill: "#000",
+                    fontWeight: "normal",
+                },
+            );
+            this.parts = [...this.parts, loops];
+        }
     }
 
     static readonly _schema = StampySnipSchema;
@@ -198,203 +254,41 @@ export class StampySnip extends BaseSnip {
     }
 
     public render(ctx: RenderContext): void {
-        // Apply translate, rotate and mirror
-        // see base.ts
-        let ty = 0;
-        const transform = ctx.getTransform();
-        this.transform(ctx);
-
-
-        ctx.translate(0, 35);
-        ctx.fillStyle = "#000";
-
-        // Header
-        ty = this.renderHeader(ctx);
-        ctx.translate(0, ty + this.padding);
-
-        // Motor positions
-        ty = this.renderMotorsTable(ctx);
-        ctx.translate(0, ty + this.padding);
-
-        // Illumination/Readout
-        ty = this.renderTimings(ctx);
-        ctx.translate(0, this.padding * 2 + ty - 24);
-
-        // Grid
-        const grid = new SampleGrid(this.stampy.measurement);
-        grid.render(ctx);
-        ctx.translate(0, grid.height + this.padding);
-
-        // Comment
-        ty = this.__renderBlock(
-            ctx,
-            "Comment",
-            this.stampy.sample.comment,
-        );
-
-        ctx.setTransform(transform);
-    }
-
-
-    private renderTimings(ctx: RenderContext): number {
-        ctx.save();
-        ctx.font = "bold 28px Arial";
-        ctx.fillText("Timings:", 0, 0);
-        ctx.translate(this.padding, 28);
-
-        // Illuminatin
-        ctx.font = "24px Arial";
-        ctx.fillText("Illumination:", 0, 0);
-        ctx.fillText(`${this.stampy.measurement.timing.illumination}s`, 200, 0);
-
-        // Timing
-        ctx.fillText("Interval:", 700 / 2, 0);
-        ctx.fillText(`${this.stampy.measurement.timing.interval}s`, 700 / 2 + 150, 0);
-        ctx.restore();
-
-        return 28 + 24;
-    }
-
-    private renderHeader(ctx: RenderContext): number {
-        // construct a temporary textnsip
-        ctx.save();
-        ctx.font = "bold 35px Arial";
-        ctx.fillStyle = "#000";
-        const header = `Sample: ${this.stampy.sample.name} `;
-        let measure = ctx.measureText(header);
-        ctx.fillText(header, 0, 0);
-        ctx.translate(0, 35);
-        ctx.font = "28px Arial";
-        // ID and Date row
-        let [_, ty] = this.__renderInline(ctx, "ID", "" + this.stampy.sample.id);
-        ctx.translate(0, ty + this.padding);
-        // Date
-        this.__renderInline(ctx, "Date", this.stampy.meta.date);
-
-        ctx.restore();
-        return 1 * 35 + 28 * 2 + this.padding;
-    }
-
-    private renderMotorsTable(ctx: RenderContext): number {
-        // Header
         ctx.save();
-        ctx.font = "bold 28px Arial";
-        ctx.fillText("Motors:", 0, 0);
-        ctx.translate(this.padding, 28);
-
-        // Render motors in 3 columns
-        ctx.font = "24px Arial";
-        const c1 = ["mdetx", "mdety", "mdetz"];
-        const c1values = [-1, 1.12312, 2.5]
-
-
-        // mdet values
-        for (let i = 0; i < c1.length; i++) {
-            ctx.textAlign = "left";
-            const measure = ctx.measureText(`${c1[i]}: `);
-            ctx.fillText(`${c1[i]}: `, 0, 24 * i);
-
-            ctx.textAlign = "right";
-            ctx.fillText(`${c1values[i]?.toFixed(3)} `, measure.width + 100, 24 * i);
-        }
-
-        const c2 = ["stx", "sty", "stz", "stzrot"];
-        const c2values = [-1, 1.12312, 2.5, 0.12312];
-
-        ctx.translate(700 / 3, 0);
-
-        //st values
-        for (let i = 0; i < c2.length; i++) {
-            ctx.textAlign = "left";
-            const measure = ctx.measureText(`${c2[i]}: `);
-            ctx.fillText(`${c2[i]}: `, 0, 24 * i);
-
-            ctx.textAlign = "right";
-            ctx.fillText(`${c2values[i]?.toFixed(3)} `, 150, 24 * i);
-        }
-
-        const c3 = ["cx", "cy", "cz", "?"];
-        const c3values = [-1, 1.12312, 2.5, 0.12312];
-        ctx.translate(700 / 3, 0);
-        // c values
-        for (let i = 0; i < c2.length; i++) {
-            ctx.textAlign = "left";
-            const measure = ctx.measureText(`${c2[i]}: `);
-            ctx.fillText(`${c3[i]}: `, 0, 24 * i);
-
-            ctx.textAlign = "right";
-            ctx.fillText(`${c3values[i]?.toFixed(3)} `, 150, 24 * i);
+        this.transform(ctx);
+        for (const part of this.parts) {
+            part.render(ctx);
+            const partsize = part.size(ctx);
+            ctx.translate(0, this.padding);
+            ctx.translate(0, partsize.height);
         }
-
         ctx.restore();
-        return 28 + this.padding + 24 * 4;
     }
-
-    private __renderInline(
-        ctx: RenderContext,
-        title: string,
-        label: string,
-        x = 0,
-    ): [number, number] {
-        ctx.font = "bold 28px Arial";
-        const measure1 = ctx.measureText(title + ": ");
-        ctx.fillText(title + ": ", x, 0);
-        ctx.font = "24px Arial";
-        const measure2 = ctx.measureText(label);
-        ctx.fillText(label, x + measure1.width, 0);
-        return [measure1.width + measure2.width, 28]
-    }
-
-
-    private __renderBlock(
-        ctx: RenderContext,
-        headerText: string,
-        blockText: string,
-    ) {
-        // Render header
-        ctx.translate(0, 28);
-        ctx.font = "bold 28px Arial";
-        const prefix = `${headerText}: `;
-        let currentY = 0;
-        ctx.fillText(prefix, 0, 0);
-        currentY += 28;
-
-        // Render block text
-        // 10 pixel padding inline
-        ctx.font = "24px Arial";
-        const block = blockText.split("\\n");
-        for (let i = 0; i < block.length; i++) {
-            const [_x, y] = renderTextWithWrap(
-                ctx,
-                block[i]!,
-                this.padding,
-                currentY,
-                this.width - 10,
-            );
-            currentY = y + 24;
-        }
-
-        return currentY + 28;
-    }
-
-
-
 }
 
 /** The sample grid.
  */
-class SampleGrid {
-
+class SampleGrid extends Part {
     measurement: StampyData["stampy"]["measurement"];
 
     width: number = 700;
     height: number = 700;
 
-
     constructor(measurement: StampyData["stampy"]["measurement"]) {
+        super();
         this.measurement = measurement;
     }
 
+    size(_ctx: RenderContext): {
+        width: number;
+        height: number;
+    } {
+        return {
+            width: this.width,
+            height: this.height,
+        };
+    }
+
     /** Compute the scale such that
      * the max values fits inside
      * the width and height
@@ -403,12 +297,10 @@ class SampleGrid {
         return Math.min(width, height) / max;
     }
 
-
-    render(ctx: RenderContext): void {
+    _render(ctx: RenderContext): void {
         // Draw rect around the area
-        ctx.save();
         ctx.strokeStyle = "#000";
-        ctx.lineWidth = 2;
+        ctx.lineWidth = 1;
         ctx.rect(0, 0, this.width, this.height);
         ctx.stroke();
 
@@ -425,7 +317,6 @@ class SampleGrid {
         scale.setScaleFactor(s, 1);
         scale.render(ctx);
 
-
         // Render grid
         const r = (this.measurement.volume.fovw * s) / 2;
         const positions = this.measurement.grid.values;
@@ -455,7 +346,6 @@ class SampleGrid {
             -this.height / 2 + 5,
             -this.height / 2 + 5,
         );
-        ctx.restore();
     }
 
     private drawCircle(
@@ -508,11 +398,81 @@ class SampleGrid {
     }
 }
 
+class MotorsTable extends Table {
+    header: TextPart;
+
+    constructor(motors: StampyData["stampy"]["motors"]) {
+        super(
+            [
+                [
+                    motors.detector.mdetx.toString(),
+                    motors.tomo.stx.toString(),
+                    motors.tomo.cx.toString(),
+                ],
+                [
+                    motors.detector.mdety.toString(),
+                    motors.tomo.sty.toString(),
+                    motors.tomo.cy.toString(),
+                ],
+                [
+                    motors.detector.mdetz.toString(),
+                    motors.tomo.stz.toString(),
+                    motors.tomo.cz.toString(),
+                ],
+                ["", motors.tomo.stzrot.toString(), ""],
+                [
+                    motors.energy.mono.toString() + " " + motors.energy.unit,
+                    "",
+                    "",
+                ],
+            ],
+            {
+                labelWidth: 50,
+                columnWidths: [550 / 3, 550 / 3, 550 / 3],
+                gap: {
+                    column: 5,
+                },
+                columnAlign: ["right", "right", "right"],
+                textStyle: {
+                    fontSize: 24,
+                },
+            },
+            ["mdet", "st", "c"],
+            ["x", "y", "z", "θ", "E"],
+        );
+
+        this.header = new TextPart("Motors:", {
+            fontSize: 24,
+            fontWeight: "bold",
+            fill: "#000",
+            textAlign: "left",
+            textBaseline: "top",
+        });
+    }
+
+    _render(ctx: RenderContext): void {
+        this.header.render(ctx);
+        ctx.translate(0, this.header.size(ctx).height);
+        super._render(ctx);
+    }
+
+    size(ctx: RenderContext): {
+        width: number;
+        height: number;
+    } {
+        const headerSize = this.header.size(ctx);
+        const tableSize = super.size(ctx);
+        return {
+            width: Math.max(headerSize.width, tableSize.width),
+            height: headerSize.height + tableSize.height,
+        };
+    }
+}
 
 /** Simple scale component usable to render a scale showing
  * a bar of the length of the scaleUnit.
  */
-class Scale {
+class Scale extends Part {
     // Positions
     x: number;
     y: number;
@@ -523,6 +483,7 @@ class Scale {
     padding: number = 15;
 
     constructor(x: number, y: number, height?: number, maxWidth?: number) {
+        super();
         this.x = x;
         this.y = y;
         if (height) {
@@ -540,7 +501,7 @@ class Scale {
 
     setScaleFactor(factor: number, mm: number) {
         // try to approximatly fill the maxWidth
-        let _mm = 1;
+        let _mm = mm;
         let s = factor;
         while (s < this.maxWidth) {
             _mm *= 2;
@@ -551,41 +512,35 @@ class Scale {
     }
 
     get text() {
-        return `${this.scaleUnit} mm`
+        return `${this.scaleUnit} mm`;
     }
 
-    render(ctx: RenderContext) {
-        ctx.save();
-
+    _render(ctx: RenderContext) {
         ctx.font = "26px Arial";
         ctx.textBaseline = "top";
-        const ts = ctx.measureText(this.text);;
+        const ts = ctx.measureText(this.text);
 
         // Translate to position regarding align
         // TODO: make dynamic
         const align = {
-            "horizontal": "right",
-            "vertical": "bottom"
-        }
+            horizontal: "right",
+            vertical: "bottom",
+        };
+        ctx.translate(this.x, this.y);
         ctx.translate(
-            this.x,
-            this.y
-        );
-        ctx.translate(
-            align.horizontal === "right" ? -this.scaleWidth - this.padding : this.padding,
-            align.vertical === "bottom" ? -this.height - 26 - this.padding - 5 : this.padding
+            align.horizontal === "right"
+                ? -this.scaleWidth - this.padding
+                : this.padding,
+            align.vertical === "bottom"
+                ? -this.height - 26 - this.padding - 5
+                : this.padding,
         );
 
         // Render scale rect
         ctx.globalAlpha = 0.8;
         ctx.fillStyle = "#000";
         ctx.beginPath();
-        ctx.fillRect(
-            0,
-            0,
-            this.scaleWidth,
-            this.height,
-        );
+        ctx.fillRect(0, 0, this.scaleWidth, this.height);
         ctx.fill();
 
         // Render text
@@ -596,37 +551,226 @@ class Scale {
             this.scaleWidth / 2 - ts.width / 2,
             this.height + 5,
         );
-        ctx.restore();
+    }
+
+    size(_ctx: RenderContext): {
+        width: number;
+        height: number;
+    } {
+        return {
+            width: this.scaleWidth,
+            height: this.height + 26 + this.padding,
+        };
     }
 }
 
-/* ---------------------------------- Utils --------------------------------- */
+class TimingsSection extends Part {
+    header: TextPart;
+    items: Record<string, string>;
 
-function renderTextWithWrap(
-    ctx: RenderContext,
-    text: string,
-    x: number,
-    y: number,
-    wrap: number,
-    x_snap?: number,
-) {
-    const parts = text.match(/\S+|\s+/g) || [];
-    let currentX = x;
-    let currentY = y;
-    parts.forEach((part) => {
-        const metrics = ctx.measureText(part);
-        if (currentX + metrics.width > wrap) {
-            // Reset X position to the start of the line
-            currentX = x_snap ?? x;
-            // Move Y position to the next line
-            currentY +=
-                metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent;
-            console.log(currentY);
-            part = part.trimStart();
+    fontStyle: FontStyle;
+
+    constructor(
+        headerText: string,
+        items: Record<string, string>,
+        fontStyle?: Partial<FontStyle>,
+    ) {
+        super();
+        this.header = new TextPart(headerText, {
+            ...fontStyle,
+            fontWeight: "bold",
+        });
+        this.items = items;
+        this.fontStyle = {
+            fontSize: fontStyle?.fontSize || 24,
+            fontFamily: fontStyle?.fontFamily || "Arial",
+            fontWeight: fontStyle?.fontWeight || "normal",
+            textAlign: fontStyle?.textAlign || "left",
+            textBaseline: fontStyle?.textBaseline || "top",
+            fill: fontStyle?.fill || "black",
+        };
+    }
+
+    _render(ctx: RenderContext): void {
+        this.header.render(ctx);
+        ctx.translate(0, this.header.size(ctx).height);
+
+        ctx.font = `${this.fontStyle.fontWeight} ${this.fontStyle.fontSize}px ${this.fontStyle.fontFamily}`;
+        ctx.fillStyle = this.fontStyle.fill;
+        ctx.textAlign = this.fontStyle.textAlign;
+
+        // 2 items per row
+        let c = 0;
+        for (const [key, value] of Object.entries(this.items)) {
+            if (c % 2 === 0 && c > 0) {
+                ctx.translate(0, this.fontStyle.fontSize);
+            }
+
+            ctx.fillText(
+                key + ": ",
+                0 + (c % 2) * 350,
+                this.fontStyle.fontSize,
+            );
+            const width = ctx.measureText(key + ": ").width;
+            ctx.fillText(value, width + (c % 2) * 350, this.fontStyle.fontSize);
+
+            c++;
         }
+    }
+
+    size(ctx: RenderContext): {
+        width: number;
+        height: number;
+    } {
+        const headerSize = this.header.size(ctx);
+        return {
+            width: 700,
+            height:
+                headerSize.height +
+                Math.ceil(Object.entries(this.items).length / 2) *
+                    this.fontStyle.fontSize,
+        };
+    }
+}
+
+class CommentSection extends TextBlock {
+    header: TextPart;
+
+    constructor(
+        headerText: string,
+        text: string,
+        fontStyle?: Partial<FontStyle>,
+    ) {
+        super(text, 700, fontStyle);
+        this.header = new TextPart(headerText, {
+            ...fontStyle,
+            fontWeight: "bold",
+        });
+    }
+
+    _render(ctx: RenderContext): void {
+        this.header.render(ctx);
+        ctx.translate(0, this.header.size(ctx).height);
+        super._render(ctx);
+    }
+
+    size(ctx: RenderContext): {
+        width: number;
+        height: number;
+    } {
+        const headerSize = this.header.size(ctx);
+        const textSize = super.size(ctx);
+        return {
+            width: Math.max(headerSize.width, textSize.width),
+            height: headerSize.height + textSize.height,
+        };
+    }
+}
+
+class LoopTables extends Part {
+    header: TextPart;
+    outer?: Table;
+    inner?: Table;
+
+    constructor(
+        headerText: string,
+        outer: Record<string, string | number>[],
+        inner: Record<string, string | number>[],
+        fontStyle?: Partial<FontStyle>,
+    ) {
+        super();
+        this.header = new TextPart(headerText, {
+            ...fontStyle,
+            fontWeight: "bold",
+        });
+
+        if (outer.length > 0) {
+            this.outer = new Table(
+                create2DArrayFromDynamicKeys(outer),
+                {
+                    columnWidths: Object.keys(outer[0]!).map(() => 100),
+                    gap: {
+                        column: 5,
+                    },
+                },
+                Object.keys(outer[0]!),
+                // loop iteration [1,2,...]
+                Array.from({ length: outer.length }, (_, i) =>
+                    (i + 1).toString(),
+                ),
+                "outer",
+            );
+        }
+        if (inner.length > 0) {
+            this.inner = new Table(
+                create2DArrayFromDynamicKeys(inner),
+                {
+                    columnWidths: Object.keys(inner[0]!).map(() => 100),
+                    gap: {
+                        column: 5,
+                    },
+                },
+                Object.keys(inner[0]!),
+                Array.from({ length: inner.length }, (_, i) =>
+                    (i + 1).toString(),
+                ),
+                "inner",
+            );
+        }
+    }
+
+    get padding() {
+        return this.header.fontSize / 2;
+    }
+
+    _render(ctx: RenderContext): void {
+        this.header.render(ctx);
+        ctx.translate(0, this.header.size(ctx).height);
+
+        // Render tables
+        if (this.outer) {
+            this.outer.render(ctx);
+            ctx.translate(0, this.outer.size(ctx).height);
+            ctx.translate(0, this.padding);
+        }
+
+        if (this.inner) {
+            this.inner.render(ctx);
+            ctx.translate(0, this.inner.size(ctx).height);
+        }
+    }
+
+    size(ctx: RenderContext): {
+        width: number;
+        height: number;
+    } {
+        const headerSize = this.header.size(ctx);
+        let h = headerSize.height;
+        let w = headerSize.width;
+        if (this.outer) {
+            const outerSize = this.outer.size(ctx);
+            h += outerSize.height + this.padding;
+            w = Math.max(w, outerSize.width);
+        }
+        if (this.inner) {
+            const innerSize = this.inner.size(ctx);
+            h += innerSize.height;
+            w = Math.max(w, innerSize.width);
+        }
+
+        return {
+            width: w,
+            height: h,
+        };
+    }
+}
+
+function create2DArrayFromDynamicKeys(
+    outerLoop: Record<string, string | number>[],
+) {
+    if (outerLoop.length === 0) return [];
 
-        ctx.fillText(part, currentX, currentY);
-        currentX += ctx.measureText(part).width;
-    });
-    return [currentX, currentY] as const;
+    // Get all keys from the first object (assuming all objects have the same keys)
+    const keys = Object.keys(outerLoop[0]!);
+    return outerLoop.map((item) => keys.map((key) => "" + (item[key] ?? "")));
 }
diff --git a/packages/snips/stampy_example_snip.json b/packages/snips/stampy_example_snip.json
index db988334..ba18a331 100644
--- a/packages/snips/stampy_example_snip.json
+++ b/packages/snips/stampy_example_snip.json
@@ -93,7 +93,21 @@
                     "stz": -1,
                     "cz": 2.07352,
                     "stzrot": 0.000194
-                }
+                },
+                "detector": { "mdetx": 0, "mdety": 0, "mdetz": 0 },
+                "energy": { "mono": 8, "unit": "keV" },
+                "innerLoop": [
+                    { "x": 0, "y": 0, "z": 0 },
+                    { "x": 1, "y": 1, "z": 1 },
+                    { "x": 2, "y": 2, "z": 2 },
+                    { "x": 3, "y": 3, "z": 3 }
+                ],
+                "outerLoop": [
+                    { "mdetx": 1.1, "mdety": 1.2, "mdetz": 1.3 },
+                    { "mdetx": 2.1, "mdety": 2.2, "mdetz": 2.3 },
+                    { "mdetx": 3.1, "mdety": 3.2, "mdetz": 3.3 },
+                    { "mdetx": 4.1, "mdety": 4.2, "mdetz": 4.3 }
+                ]
             }
         }
     },
-- 
GitLab