Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision

Target

Select target project
  • alexander.clausen/snip
  • irp/snip
2 results
Select Git revision
Show changes
Commits on Source (12)
Showing
with 1438 additions and 51 deletions
...@@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ...@@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## 1.12.2
- Hotfix: Fixed a small bug with newly inserted snippets being instantly rendered on a page
even tho they are not placed yet
## 1.12.1
### Fixed
- Hotfix: Fixed a small bug in the render backend which should prevent heavy cpu usage for pdf rendering
- Removed user error.message component for invitation problems as errors are stripped in production
- Collaborator remove modal now closes after removing a collaborator
## 1.12.0 ## 1.12.0
### Fixed ### Fixed
......
...@@ -320,7 +320,10 @@ function RemoveCollaboratorModal({ ...@@ -320,7 +320,10 @@ function RemoveCollaboratorModal({
</Button> </Button>
<Button <Button
variant="danger" variant="danger"
onClick={() => removeCollaborator(collaborator)} onClick={() => {
removeCollaborator(collaborator);
handleClose();
}}
> >
Remove Remove
</Button> </Button>
......
...@@ -17,11 +17,6 @@ export default function Error({ ...@@ -17,11 +17,6 @@ export default function Error({
<> <>
<h1 className="text-center">Invitation error</h1> <h1 className="text-center">Invitation error</h1>
<hr /> <hr />
{error.message && (
<div className="text-center p-1">
<code>{error.message}</code>
</div>
)}
<p className="text-center text-dark"> <p className="text-center text-dark">
The token provided is invalid or has already been used. Make The token provided is invalid or has already been used. Make
sure you are using the correct link we have sent you via email. sure you are using the correct link we have sent you via email.
......
...@@ -178,7 +178,10 @@ export class Page extends LocalPage { ...@@ -178,7 +178,10 @@ export class Page extends LocalPage {
// check if a snip is relevant for this page // check if a snip is relevant for this page
return ( return (
this.data.id === snip.page_id || this.data.id === snip.page_id ||
this.data.referenced_page_id === snip.page_id (
this.data.referenced_page_id !== null &&
this.data.referenced_page_id === snip.page_id
)
); );
} }
} }
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import { NotFoundError } from "@snip/database/errors";
import { import {
DataValidationError, DataValidationError,
DataValidationErrorArray, DataValidationErrorArray,
} from "@snip/snips/errors"; } from "@snip/snips/errors";
import { NotFoundError } from "@snip/database/errors";
export const errorHandler = ( export const errorHandler = (
err: Error, err: Error,
...@@ -56,8 +56,8 @@ export const asyncHandler = ...@@ -56,8 +56,8 @@ export const asyncHandler =
next: NextFunction, next: NextFunction,
) => Promise<unknown>, ) => Promise<unknown>,
) => ) =>
(req: Request, res: Response, next: NextFunction) => (req: Request, res: Response, next: NextFunction) =>
Promise.resolve(fn(req, res, next)).catch(next); Promise.resolve(fn(req, res, next)).catch(next);
/** Extend the error class to create an auth failed error */ /** Extend the error class to create an auth failed error */
export class AuthFailedError extends Error { export class AuthFailedError extends Error {
......
...@@ -47,8 +47,11 @@ const get_bitmap = async function (this: any) { ...@@ -47,8 +47,11 @@ const get_bitmap = async function (this: any) {
ImageSnipLegacy.prototype.get_bitmap = get_bitmap; ImageSnipLegacy.prototype.get_bitmap = get_bitmap;
ImageSnip.prototype.get_bitmap = get_bitmap; ImageSnip.prototype.get_bitmap = get_bitmap;
import path from "path"; import path from "path";
async function readFromAssetsAsResponse(p: string, ...args: unknown[]) { async function readFromAssetsAsResponse(
const buffer = await readFile(path.join("/assets", p)); p: string | URL | Request,
..._args: unknown[]
) {
const buffer = await readFile(path.join("/assets", String(p)));
return new Response(buffer); return new Response(buffer);
} }
......
...@@ -2,14 +2,14 @@ import { PDFDocument } from "pdf-lib"; ...@@ -2,14 +2,14 @@ import { PDFDocument } from "pdf-lib";
import { Readable } from "stream"; import { Readable } from "stream";
import stream2buffer from "@snip/common/stream2buffer"; import stream2buffer from "@snip/common/stream2buffer";
import { BookDataRet, PageData } from "@snip/database/types"; import { BookDataRet, PageDataRet } from "@snip/database/types";
import renderPage from "."; import renderPage from ".";
import { PageNumberLocation, SelectedPage } from "./renderParams"; import { PageNumberLocation, SelectedPage } from "./renderParams";
export async function render_book_pdf( export async function render_book_pdf(
bookData: BookDataRet, bookData: BookDataRet,
pages: PageData[], pages: PageDataRet[],
selectedPages?: SelectedPage[], selectedPages?: SelectedPage[],
pageNumberLocation?: PageNumberLocation, pageNumberLocation?: PageNumberLocation,
): Promise<[Readable, number]> { ): Promise<[Readable, number]> {
...@@ -41,20 +41,16 @@ export async function render_book_pdf( ...@@ -41,20 +41,16 @@ export async function render_book_pdf(
}); });
} }
const pdfsToMerge = await Promise.all(
pages.map((page) => {
return renderPage(page.id, {
width: 720,
format: "pdf",
force_render: false,
encoding: "gzip",
return_stream: true,
}) as Promise<Readable>;
}),
);
const mergedPdf = await PDFDocument.create(); const mergedPdf = await PDFDocument.create();
for (const pdfBytes of pdfsToMerge) { for (const page of pages) {
const pdfBytes = (await renderPage(page.id, {
width: 720,
format: "pdf",
force_render: false,
encoding: "gzip",
return_stream: true,
})) as Readable;
const pdf = await PDFDocument.load(await stream2buffer(pdfBytes)); const pdf = await PDFDocument.load(await stream2buffer(pdfBytes));
// Get the form containing all the fields // Get the form containing all the fields
const form = pdf.getForm(); const form = pdf.getForm();
......
import express, { Response } from "express"; import express, { Response } from "express";
import { createServer } from "http"; import { createServer } from "http";
import { AddressInfo } from "net";
import sharp from "sharp";
import { Readable } from "stream";
import { createGzip } from "zlib";
import { z } from "zod";
import { config } from "@snip/config/server";
import service from "@snip/database"; import service from "@snip/database";
import { NotFoundError } from "@snip/database/errors"; import { NotFoundError } from "@snip/database/errors";
import { LocalPage } from "@snip/render/page";
import { get_snip_from_data } from "@snip/snips";
import { import {
adminMiddleWare, adminMiddleWare,
...@@ -17,27 +25,17 @@ import { ...@@ -17,27 +25,17 @@ import {
BadUsage, BadUsage,
errorHandler, errorHandler,
} from "./common/errorHandler"; } from "./common/errorHandler";
import renderPage, { render } from "./render";
import { render_book_pdf } from "./render/pdf";
import { import {
parseSelectedPages, parseSelectedPages,
RenderParams, RenderParams,
validateRenderParams, validateRenderParams,
} from "./render/renderParams"; } from "./render/renderParams";
import { renderSnip } from "./render/snip";
const app = express(); const app = express();
export const server = createServer(app); export const server = createServer(app);
import { AddressInfo } from "net";
import sharp from "sharp";
import { Readable } from "stream";
import { createGzip } from "zlib";
import { z } from "zod";
import { config } from "@snip/config/server";
import { LocalPage } from "@snip/render/page";
import { get_snip_from_data } from "@snip/snips";
import renderPage, { render } from "./render";
import { render_book_pdf } from "./render/pdf";
import { renderSnip } from "./render/snip";
const renderSchema = z.object({ const renderSchema = z.object({
page_id: z.coerce.number(), page_id: z.coerce.number(),
......
{ {
"name": "snip", "name": "snip",
"version": "1.12.0", "version": "1.12.2",
"description": "our digital lab book", "description": "our digital lab book",
"author": "Sebastian B. Mohr, Markus Osterhoff", "author": "Sebastian B. Mohr, Markus Osterhoff",
"repository": { "repository": {
......
...@@ -29,6 +29,7 @@ ...@@ -29,6 +29,7 @@
"@types/sprintf-js": "^1.1.4", "@types/sprintf-js": "^1.1.4",
"tree-sitter-cli": "^0.25.1", "tree-sitter-cli": "^0.25.1",
"tree-sitter-python": "^0.23.6", "tree-sitter-python": "^0.23.6",
"skia-canvas": "catalog:",
"tsup": "catalog:", "tsup": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
"vitest": "catalog:" "vitest": "catalog:"
...@@ -39,7 +40,8 @@ ...@@ -39,7 +40,8 @@
"build": "tsup", "build": "tsup",
"watch": "tsc -w", "watch": "tsc -w",
"test": "vitest", "test": "vitest",
"generate-grammars": "tree-sitter build --wasm node_modules/tree-sitter-python -o ../../assets/grammars/tree-sitter-python.wasm" "generate-grammars": "tree-sitter build --wasm node_modules/tree-sitter-python -o ../../assets/grammars/tree-sitter-python.wasm",
"json_to_snip": "tsx ./src/json_to_snip.ts"
}, },
"type": "module" "type": "module"
} }
...@@ -265,15 +265,15 @@ export class TextSnip extends BaseSnip { ...@@ -265,15 +265,15 @@ export class TextSnip extends BaseSnip {
lineWrap: validation.view?.wrap, lineWrap: validation.view?.wrap,
baseline: validation.view?.baseline, baseline: validation.view?.baseline,
//Base //Base
id: data.id, id: validation.id,
page_id: data.page_id, page_id: validation.page_id,
book_id: data.book_id, book_id: validation.book_id,
last_updated: data.last_updated, last_updated: validation.last_updated,
created: data.created, created: validation.created,
x: data.view?.x, x: validation.view?.x,
y: data.view?.y, y: validation.view?.y,
rot: data.view?.rot, rot: validation.view?.rot,
mirror: data.view?.mirror, mirror: validation.view?.mirror,
}); });
} }
......
/** Debugging script to render a snippet
*
* Can be use to develop a new snippet type and test it easily.
*/
import fs from "fs";
import { Canvas } from "skia-canvas";
import { get_snip_from_data } from "./get_snip_from_data";
const file_name = process.argv[2];
let output_file = process.argv[3];
if (!file_name) {
console.error("Usage: tsx render_json.ts <file_name> [output_file]");
process.exit(1);
}
if (output_file && !output_file.endsWith(".png")) {
console.error("Output file must be a PNG file");
process.exit(1);
}
if (!fs.existsSync(file_name)) {
console.error("File does not exist");
process.exit(1);
}
if (!output_file) {
console.log("No output file specified, rendering to output.png");
output_file = "./output.png";
}
// Read file as JSON
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let data: any;
try {
const f = fs.readFileSync(file_name, "utf8");
data = JSON.parse(f);
} catch (e) {
console.error(`Error reading file: ${e}`);
process.exit(1);
}
const snip = get_snip_from_data(data);
const canvas = new Canvas(1400, 2100);
const ctx = canvas.getContext("2d");
ctx.fillStyle = "white";
ctx.fillRect(0, 0, canvas.width, canvas.height);
snip.render(ctx as unknown as OffscreenCanvasRenderingContext2D);
// Save to file
canvas.saveAsSync(output_file);
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;
};
}
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;
}
}
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,
};
}
}
...@@ -5,6 +5,7 @@ import { LogfileSnip } from "./spec/logfile"; ...@@ -5,6 +5,7 @@ import { LogfileSnip } from "./spec/logfile";
import { MacroSpecSnip } from "./spec/macrospec"; import { MacroSpecSnip } from "./spec/macrospec";
import { MotorsSnip } from "./spec/motors"; import { MotorsSnip } from "./spec/motors";
import { TimestampSnip } from "./spec/timestamp"; import { TimestampSnip } from "./spec/timestamp";
import { StampySnip } from "./stampy";
const TYPE_TO_SNIP: Map<string, typeof BaseSnip> = new Map(); const TYPE_TO_SNIP: Map<string, typeof BaseSnip> = new Map();
...@@ -25,4 +26,7 @@ TYPE_TO_SNIP.set("uprp/spec/matlab", ImageSnip); ...@@ -25,4 +26,7 @@ TYPE_TO_SNIP.set("uprp/spec/matlab", ImageSnip);
// SPOC mapping // SPOC mapping
// TODO:@Markus // TODO:@Markus
// Stampy
TYPE_TO_SNIP.set("uprp/stampy", StampySnip);
export default TYPE_TO_SNIP; export default TYPE_TO_SNIP;
import { type } from "arktype";
import { DataValidationError } from "@/errors";
import { ArraySnip } from "@/general/array";
import {
BaseSnip,
BaseSnipArgs,
BaseViewSchema,
RenderContext,
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 */
/* -------------------------------------------------------------------------- */
// The dataschema is a one to one mapping from the stampy json
// TODO: Might need some adjustments depending on what we actually need on the snip side
// I would suggest to remove all the optional fields and only keep the required ones
const StampySchema = type({
meta: {
version: "number",
instrument: "string",
setup: "string",
experiment: "string",
date: "string",
},
sample: {
name: "string",
id: "number",
comment: "string",
},
measurement: {
volume: {
diameter: "number",
height: "number",
fovw: "number",
fovh: "number",
unit: "'mm' | 'cm'",
},
stack: {
motor: "string",
factorh: "number",
values: "number[]",
},
detector: {
name: "string",
pixelSize: "number",
pixels: ["number", "number"],
},
grid: {
motors: "('cx'|'cy')[]",
method: "'hexagonal'",
factorw: "number",
prefill: "number",
values: "number[][]",
},
timing: {
illumination: "number",
interval: "number",
unit: "'second' | 'minute' | 'hour'",
mode: "string",
topupSync: "'no' | 'yes'",
topupParameter: "number[]",
trgLine: "number",
trgDelay: "number",
},
tomo: {
motor: "string",
frames: "number",
commands: "string[]",
angles: {
range: "number",
safety: "number",
unit: "string",
},
velocity: {
tomo: "number",
moving: "number",
},
},
empty: {
frames: "number",
commands: "string[]",
scheme: "string",
pattern: "string",
},
dark: {
frames: "number",
commands: "string[]",
},
range: "string",
},
motors: {
tomo: {
stx: "number",
cx: "number",
sty: "number",
cy: "number",
stz: "number",
cz: "number",
stzrot: "number",
},
empty: {
stx: "number",
cx: "number",
sty: "number",
cy: "number",
stz: "number",
cz: "number",
stzrot: "number",
},
final: {
stx: "number",
cx: "number",
sty: "number",
cy: "number",
stz: "number",
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>[]",
},
});
export const StampyDataSchema = type({
"version?": type("1").describe(
"The version of the stampy snippet, empty for latest.",
),
stampy: StampySchema.describe("The data returned from stampy."),
});
export const StampyViewSchema = type({}, "&", BaseViewSchema).describe(
BaseViewSchema.description,
);
export const StampySnipSchema = type(SnipDataSchema, "&", {
data: StampyDataSchema,
"view?": StampyViewSchema,
}).describe("Represents a stampy snippet.");
export type StampyData = typeof StampyDataSchema.infer;
export type StampyView = typeof StampyViewSchema.infer;
export type StampySnipArgs = BaseSnipArgs & StampyData;
/* -------------------------------------------------------------------------- */
/* StampySnip implementation */
/* -------------------------------------------------------------------------- */
export class StampySnip extends BaseSnip {
public type = "stampy";
stampy: StampyData["stampy"];
// Indent of the text blocks
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;
static from_data(data: SnipData<StampyData, StampyView>): StampySnip {
// Validate
const validation = this._schema(data);
if (validation instanceof type.errors) {
throw DataValidationError.from_ark(validation);
}
// We can check the version here if necessary
// in the future
return new StampySnip({
stampy: validation.data.stampy,
// 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,
});
}
// TODO: Make this dynamic
get width(): number {
return 700;
}
get height(): number {
return 700;
}
public render(ctx: RenderContext): void {
ctx.save();
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();
}
}
/** The sample grid.
*/
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
*/
private computeScale(width: number, height: number, max: number): number {
return Math.min(width, height) / max;
}
_render(ctx: RenderContext): void {
// Draw rect around the area
ctx.strokeStyle = "#000";
ctx.lineWidth = 1;
ctx.rect(0, 0, this.width, this.height);
ctx.stroke();
// translate to center
ctx.translate(this.width / 2, this.height / 2);
// Render Scale
const s = this.computeScale(
this.width,
this.height,
this.measurement.volume.diameter * 2.5,
);
const scale = new Scale(this.width / 2, this.height / 2);
scale.setScaleFactor(s, 1);
scale.render(ctx);
// Render grid
const r = (this.measurement.volume.fovw * s) / 2;
const positions = this.measurement.grid.values;
for (let i = 0; i < positions.length; i++) {
const x = positions[i]![0]!;
const y = positions[i]![1]!;
this.drawCircle(ctx, x * s, y * s, r, {
stroke: "#000",
fill: "#000",
fill_alpha: 0.2,
});
}
// Render sample circle
this.drawCircle(ctx, 0, 0, (this.measurement.volume.diameter / 2) * s, {
stroke: "#000",
fill: "red",
fill_alpha: 0.2,
});
// Label (top right)
ctx.font = "bold 28px Arial";
ctx.fillStyle = "#000";
ctx.textBaseline = "top";
ctx.fillText(
`Grid(${this.measurement.grid.method})`,
-this.height / 2 + 5,
-this.height / 2 + 5,
);
}
private drawCircle(
ctx: RenderContext,
x: number,
y: number,
radius: number,
style: {
stroke: string;
fill?: string;
fill_alpha?: number;
stroke_alpha?: number;
},
) {
if (style.fill_alpha === undefined) {
style.fill_alpha = 1;
}
if (style.stroke_alpha === undefined) {
style.stroke_alpha = 1;
}
// Draw center point
ctx.save();
ctx.fillStyle = "#000";
ctx.beginPath();
ctx.arc(x, y, 3, 0, 2 * Math.PI);
ctx.closePath();
ctx.fill();
// Draw stroke
ctx.globalAlpha = style.stroke_alpha;
ctx.strokeStyle = style.stroke;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI);
ctx.closePath();
ctx.stroke();
// Draw fill if fill color is defined
if (style.fill) {
ctx.globalAlpha = style.fill_alpha;
ctx.fillStyle = style.fill;
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI);
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
}
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 extends Part {
// Positions
x: number;
y: number;
height: number = 8;
maxWidth: number = 200;
// Styling
padding: number = 15;
constructor(x: number, y: number, height?: number, maxWidth?: number) {
super();
this.x = x;
this.y = y;
if (height) {
this.height = height;
}
if (maxWidth) {
this.maxWidth = maxWidth;
}
}
// ScaleFactor of the component
// i.e. 1mm equals this scaleFactor
scaleWidth: number = 100;
scaleUnit: number = 1;
setScaleFactor(factor: number, mm: number) {
// try to approximatly fill the maxWidth
let _mm = mm;
let s = factor;
while (s < this.maxWidth) {
_mm *= 2;
s *= 2;
}
this.scaleWidth = s;
this.scaleUnit = _mm;
}
get text() {
return `${this.scaleUnit} mm`;
}
_render(ctx: RenderContext) {
ctx.font = "26px Arial";
ctx.textBaseline = "top";
const ts = ctx.measureText(this.text);
// Translate to position regarding align
// TODO: make dynamic
const align = {
horizontal: "right",
vertical: "bottom",
};
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,
);
// Render scale rect
ctx.globalAlpha = 0.8;
ctx.fillStyle = "#000";
ctx.beginPath();
ctx.fillRect(0, 0, this.scaleWidth, this.height);
ctx.fill();
// Render text
ctx.globalAlpha = 1;
ctx.fillStyle = "#000";
ctx.fillText(
`${this.scaleUnit} mm`,
this.scaleWidth / 2 - ts.width / 2,
this.height + 5,
);
}
size(_ctx: RenderContext): {
width: number;
height: number;
} {
return {
width: this.scaleWidth,
height: this.height + 26 + this.padding,
};
}
}
class TimingsSection extends Part {
header: TextPart;
items: Record<string, string>;
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 [];
// 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] ?? "")));
}
{
"type": "uprp/stampy",
"book_id": -1,
"data": {
"stampy": {
"meta": {
"version": 1,
"instrument": "GINIX",
"setup": "PB tomo",
"experiment": "run123",
"date": "2024-9-27"
},
"sample": {
"name": "sample01a",
"id": 0,
"comment": "NR_PREV\\nImaging of top part"
},
"measurement": {
"volume": {
"diameter": 1,
"height": 1,
"fovw": 1.4,
"fovh": 1.2,
"unit": "mm"
},
"stack": { "motor": "stz", "factorh": 1.1, "values": [0] },
"detector": {
"name": "PCO.edge",
"pixelSize": 0.65e-6,
"pixels": [2560, 2160]
},
"grid": {
"motors": ["cx", "cy"],
"method": "hexagonal",
"factorw": 1.1,
"prefill": 1,
"values": [[0, 0]]
},
"timing": {
"illumination": 0.035,
"interval": 0.015,
"unit": "second",
"mode": "continuous-1",
"topupSync": "no",
"topupParameter": [100.9, 100.8],
"trgLine": 2,
"trgDelay": 2000
},
"tomo": {
"motor": "stzrot",
"frames": 3000,
"commands": ["oslow; sleep(1);", "cslow;"],
"angles": {
"range": 360.014,
"safety": 20,
"unit": "degree"
},
"velocity": { "tomo": 11972, "moving": 240000 }
},
"empty": {
"frames": 500,
"commands": ["", ""],
"scheme": "pre-volume",
"pattern": "eted"
},
"dark": { "frames": 100, "commands": ["cslow; sleep(1);", ""] },
"range": "all"
},
"motors": {
"tomo": {
"stx": 180,
"cx": 2.50001,
"sty": -3.94607,
"cy": 1.60297,
"stz": -1,
"cz": 2.07352,
"stzrot": 0.000194
},
"empty": {
"stx": 180,
"cx": 2.50001,
"sty": -3.94607,
"cy": -4,
"stz": -1,
"cz": 2.07352,
"stzrot": 0.000194
},
"final": {
"stx": 180,
"cx": 2.50001,
"sty": -3.94607,
"cy": 1.60297,
"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 }
]
}
}
},
"view": {
"x": 10,
"y": 0
}
}
...@@ -1013,6 +1013,9 @@ importers: ...@@ -1013,6 +1013,9 @@ importers:
'@types/sprintf-js': '@types/sprintf-js':
specifier: ^1.1.4 specifier: ^1.1.4
version: 1.1.4 version: 1.1.4
skia-canvas:
specifier: 'catalog:'
version: 2.0.2
tree-sitter-cli: tree-sitter-cli:
specifier: ^0.25.1 specifier: ^0.25.1
version: 0.25.1 version: 0.25.1
......