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