Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/canvas-single-render.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

The canvas renders centered on the net from its first frame, instead of jumping there after a first paint at the origin. Component instances grow with their port count so their ports have room, and auto-layout on import no longer depends on the compact/classic setting.
5 changes: 5 additions & 0 deletions .changeset/core-owns-canvas-geometry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut-core": patch
---

The layout module exports the canvas geometry: render node dimensions (`compactNodeDimensions`, `classicNodeDimensions`, `getComponentInstanceHeight`), net bounds (`getBoundsOfCenteredBoxes`) and zoom limits (`getMinZoomForBounds`, `ZOOM_PADDING`). `layoutNodeDimensions` is now derived from the render dimensions instead of maintained by hand.
10 changes: 10 additions & 0 deletions libs/@hashintel/petrinaut-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,19 @@ export type {
export { mutationActionInputSchemas } from "./action-schemas";
export {
calculateGraphLayout,
classicNodeDimensions,
compactNodeDimensions,
getBoundsOfCenteredBoxes,
getComponentInstanceHeight,
getMinZoomForBounds,
layoutNodeDimensions,
type LayoutDimensions,
type NodeDimensions,
type NodePosition,
type Rect,
type RenderNodeDimensions,
type Size,
ZOOM_PADDING,
} from "./layout";

// --- AI ---
Expand Down
37 changes: 37 additions & 0 deletions libs/@hashintel/petrinaut-core/src/layout/dimensions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";

import {
classicNodeDimensions,
compactNodeDimensions,
getComponentInstanceHeight,
layoutNodeDimensions,
} from "./dimensions";

describe("getComponentInstanceHeight", () => {
it("uses the base height for few ports", () => {
expect(getComponentInstanceHeight(compactNodeDimensions, 0)).toBe(96);
expect(getComponentInstanceHeight(compactNodeDimensions, 2)).toBe(96);
});

it("grows with the port count", () => {
expect(getComponentInstanceHeight(compactNodeDimensions, 3)).toBe(112);
expect(getComponentInstanceHeight(compactNodeDimensions, 10)).toBe(308);
});
});

describe("layoutNodeDimensions", () => {
it("is the per-axis maximum of the compact and classic dimensions", () => {
for (const kind of ["place", "transition", "componentInstance"] as const) {
expect(layoutNodeDimensions[kind]).toEqual({
width: Math.max(
compactNodeDimensions[kind].width,
classicNodeDimensions[kind].width,
),
height: Math.max(
compactNodeDimensions[kind].height,
classicNodeDimensions[kind].height,
),
});
}
});
});
76 changes: 68 additions & 8 deletions libs/@hashintel/petrinaut-core/src/layout/dimensions.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,76 @@
import type { LayoutDimensions } from "./calculate-graph-layout";

export type NodeDimensions = { width: number; height: number };

/**
* Dimensions for every node kind the canvas renders.
*/
export type RenderNodeDimensions = {
place: NodeDimensions;
transition: NodeDimensions;
componentInstance: NodeDimensions;
};

/**
* How nodes are drawn on the canvas, per visualization mode
* (`userSettings.compactNodes`). The canvas renders nodes at exactly these
* sizes and derives bounds and viewport math from them, so React Flow never
* has to measure the DOM.
*/
export const compactNodeDimensions: RenderNodeDimensions = {
place: { width: 180, height: 50 },
transition: { width: 180, height: 50 },
componentInstance: { width: 180, height: 96 },
};

export const classicNodeDimensions: RenderNodeDimensions = {
place: { width: 130, height: 130 },
transition: { width: 160, height: 80 },
componentInstance: { width: 180, height: 96 },
};

const PORT_ROW_HEIGHT = 28;

/**
* Component instances grow vertically with their port count, so the ports
* have room to spread along the node's edge.
*/
export const getComponentInstanceHeight = (
dimensions: RenderNodeDimensions,
portCount: number,
): number =>
Math.max(
dimensions.componentInstance.height,
(portCount + 1) * PORT_ROW_HEIGHT,
);

const maxDimensions = (
first: NodeDimensions,
second: NodeDimensions,
): NodeDimensions => ({
width: Math.max(first.width, second.width),
height: Math.max(first.height, second.height),
});

/**
* Layout-stable node dimensions used by {@link calculateGraphLayout}.
*
* Per-axis maximum of the compact and classic rendering dimensions (see
* `ui/views/SDCPN/node-dimensions.ts`) so auto-layout output is invariant to
* the user's compact/classic visualization choice. Without this, toggling
* `userSettings.compactNodes` after running layout would visually shift every
* node.
* Per-axis maximum of the compact and classic rendering dimensions, so
* auto-layout output is invariant to the user's compact/classic visualization
* choice. Without this, toggling `userSettings.compactNodes` after running
* layout would visually shift every node.
*/
export const layoutNodeDimensions: LayoutDimensions = {
place: { width: 180, height: 130 },
transition: { width: 180, height: 80 },
componentInstance: { width: 180, height: 120 },
place: maxDimensions(
compactNodeDimensions.place,
classicNodeDimensions.place,
),
transition: maxDimensions(
compactNodeDimensions.transition,
classicNodeDimensions.transition,
),
componentInstance: maxDimensions(
compactNodeDimensions.componentInstance,
classicNodeDimensions.componentInstance,
),
};
66 changes: 66 additions & 0 deletions libs/@hashintel/petrinaut-core/src/layout/geometry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";

import {
getBoundsOfCenteredBoxes,
getMinZoomForBounds,
ZOOM_PADDING,
} from "./geometry";

describe("getBoundsOfCenteredBoxes", () => {
it("returns null for no boxes", () => {
expect(getBoundsOfCenteredBoxes([])).toBeNull();
});

it("spans a single box around its center", () => {
expect(
getBoundsOfCenteredBoxes([
{ position: { x: 100, y: 40 }, width: 180, height: 50 },
]),
).toEqual({ x: 10, y: 15, width: 180, height: 50 });
});

it("spans multiple boxes", () => {
expect(
getBoundsOfCenteredBoxes([
{ position: { x: 0, y: 0 }, width: 100, height: 20 },
{ position: { x: 200, y: 100 }, width: 40, height: 40 },
]),
).toEqual({ x: -50, y: -10, width: 270, height: 130 });
});

it("treats boxes with unknown size as points", () => {
expect(
getBoundsOfCenteredBoxes([
{ position: { x: -5, y: 5 } },
{ position: { x: 5, y: -5 } },
]),
).toEqual({ x: -5, y: -5, width: 10, height: 10 });
});
});

describe("getMinZoomForBounds", () => {
const viewport = { width: 1000, height: 500 };

it("scales the fit zoom by the padding factor on the limiting axis", () => {
const bounds = { x: 0, y: 0, width: 4000, height: 1000 };
// Width is limiting: 1000 / 4000 = 0.25, then * ZOOM_PADDING.
expect(getMinZoomForBounds(bounds, viewport)).toBeCloseTo(
0.25 * ZOOM_PADDING,
);
});

it("defaults to 0.5 when there are no bounds", () => {
expect(getMinZoomForBounds(null, viewport)).toBe(0.5);
});

it("defaults to 0.5 when the bounds have no area", () => {
expect(
getMinZoomForBounds({ x: 0, y: 0, width: 0, height: 0 }, viewport),
).toBe(0.5);
});

it("caps the result so small nets still allow zooming out", () => {
const bounds = { x: 0, y: 0, width: 180, height: 50 };
expect(getMinZoomForBounds(bounds, viewport)).toBe(0.75);
});
});
71 changes: 71 additions & 0 deletions libs/@hashintel/petrinaut-core/src/layout/geometry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
export type Size = { width: number; height: number };

export type Rect = { x: number; y: number; width: number; height: number };

type CenteredBox = {
position: { x: number; y: number };
width?: number;
height?: number;
};

/**
* Bounding box of boxes positioned by their center point (the SDCPN
* convention for node positions). Boxes with unknown size count as points.
* Returns null when there are no boxes.
*/
export const getBoundsOfCenteredBoxes = (
boxes: readonly CenteredBox[],
): Rect | null => {
if (boxes.length === 0) {
return null;
}

let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;

for (const box of boxes) {
const halfWidth = (box.width ?? 0) / 2;
const halfHeight = (box.height ?? 0) / 2;
minX = Math.min(minX, box.position.x - halfWidth);
minY = Math.min(minY, box.position.y - halfHeight);
maxX = Math.max(maxX, box.position.x + halfWidth);
maxY = Math.max(maxY, box.position.y + halfHeight);
}

return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
};

/**
* Padding factor shared by the canvas fit and zoom limits: an initial fit
* leaves this much viewport around the net, and at minimum zoom the net
* occupies this fraction of the viewport's limiting axis.
*/
export const ZOOM_PADDING = 0.4;

/** Zoom floor while the net is empty or has no area to fit. */
const EMPTY_BOUNDS_MIN_ZOOM = 0.5;

/** Even a tiny net must allow zooming out a reasonable amount. */
const MIN_ZOOM_CEILING = 0.75;

/**
* The lowest zoom the user may reach for the given net bounds: the zoom at
* which the net occupies {@link ZOOM_PADDING} of the viewport's limiting
* axis, capped at {@link MIN_ZOOM_CEILING}.
*/
export const getMinZoomForBounds = (
bounds: Rect | null,
viewport: Size,
): number => {
const zoomShowingWholeNet =
bounds && bounds.width > 0 && bounds.height > 0
? Math.min(
viewport.width / bounds.width,
viewport.height / bounds.height,
) * ZOOM_PADDING
: EMPTY_BOUNDS_MIN_ZOOM;

return Math.min(zoomShowingWholeNet, MIN_ZOOM_CEILING);
};
18 changes: 16 additions & 2 deletions libs/@hashintel/petrinaut-core/src/layout/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
/**
* @layerRoot core.layout
* @role Computes node positions for a net, so auto-layout does not require the canvas
* @role Computes node positions, dimensions and canvas geometry for a net, so neither auto-layout nor viewport math requires the canvas
*/

export {
calculateGraphLayout,
type LayoutDimensions,
type NodePosition,
} from "./calculate-graph-layout";
export { layoutNodeDimensions } from "./dimensions";
export {
classicNodeDimensions,
compactNodeDimensions,
getComponentInstanceHeight,
layoutNodeDimensions,
type NodeDimensions,
type RenderNodeDimensions,
} from "./dimensions";
export {
getBoundsOfCenteredBoxes,
getMinZoomForBounds,
type Rect,
type Size,
ZOOM_PADDING,
} from "./geometry";
Loading
Loading