Skip to content

Commit cab3ecb

Browse files
authored
Add vector offsets to layout bounds (#6)
Refine layout bounds and default text style
1 parent e9ff695 commit cab3ecb

File tree

2 files changed

+179
-111
lines changed

2 files changed

+179
-111
lines changed

src/output.spec.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import fc from "fast-check";
12
import { describe, expect, it } from "vitest";
2-
import { layoutToSvg } from "./output";
3+
import { layoutToSvg, unionBoundingBox2d } from "./output";
34
import type { LayoutResult, NodeRecord } from "./solver";
45

56
describe("layoutToSvg", () => {
@@ -64,6 +65,7 @@ describe("layoutToSvg", () => {
6465
expect(svg).toContain('stroke-width="2"');
6566
expect(svg).toContain('<text id="T"');
6667
expect(svg).toContain(">hi<");
68+
expect(svg).toContain('font-family="sans-serif"');
6769
});
6870

6971
it("renders arrows", () => {
@@ -93,3 +95,36 @@ describe("layoutToSvg", () => {
9395
expect(svg).toContain('stroke-width="3"');
9496
});
9597
});
98+
99+
describe("unionBoundingBox2d", () => {
100+
const boxArb = fc
101+
.tuple(
102+
fc.integer({ min: -50, max: 50 }),
103+
fc.integer({ min: -50, max: 50 }),
104+
fc.integer({ min: -50, max: 50 }),
105+
fc.integer({ min: -50, max: 50 }),
106+
)
107+
.map(([x1, y1, x2, y2]) => ({
108+
start: { x: Math.min(x1, x2), y: Math.min(y1, y2) },
109+
end: { x: Math.max(x1, x2), y: Math.max(y1, y2) },
110+
}));
111+
112+
it("encapsulates both boxes", () => {
113+
fc.assert(
114+
fc.property(boxArb, boxArb, (a, b) => {
115+
const combined = unionBoundingBox2d(a, b);
116+
const expected = {
117+
start: {
118+
x: Math.min(a.start.x, b.start.x),
119+
y: Math.min(a.start.y, b.start.y),
120+
},
121+
end: {
122+
x: Math.max(a.end.x, b.end.x),
123+
y: Math.max(a.end.y, b.end.y),
124+
},
125+
};
126+
expect(combined).toStrictEqual(expected);
127+
}),
128+
);
129+
});
130+
});

src/output.ts

Lines changed: 143 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,97 @@
11
import type { LayoutResult, NodeRecord } from "./solver";
22

3+
export type Vec2 = { x: number; y: number };
4+
5+
export const vec = (x: number, y: number): Vec2 => ({ x, y });
6+
7+
export const addVec = (a: Vec2, b: Vec2): Vec2 => ({
8+
x: a.x + b.x,
9+
y: a.y + b.y,
10+
});
11+
12+
export const subVec = (a: Vec2, b: Vec2): Vec2 => ({
13+
x: a.x - b.x,
14+
y: a.y - b.y,
15+
});
16+
17+
export const scaleVec = (v: Vec2, s: number): Vec2 => ({
18+
x: v.x * s,
19+
y: v.y * s,
20+
});
21+
22+
export const lengthVec = (v: Vec2): number => Math.hypot(v.x, v.y);
23+
24+
export const perpVec = (v: Vec2): Vec2 => ({ x: -v.y, y: v.x });
25+
26+
export type BoundingBox2d = { start: Vec2; end: Vec2 };
27+
28+
export const boundingBoxFromPoints = (...pts: Vec2[]): BoundingBox2d => {
29+
const xs = pts.map((p) => p.x);
30+
const ys = pts.map((p) => p.y);
31+
return {
32+
start: { x: Math.min(...xs), y: Math.min(...ys) },
33+
end: { x: Math.max(...xs), y: Math.max(...ys) },
34+
};
35+
};
36+
37+
export const boundingBoxFromRect = (rect: {
38+
x: number;
39+
y: number;
40+
width: number;
41+
height: number;
42+
}): BoundingBox2d =>
43+
boundingBoxFromPoints(
44+
vec(rect.x, rect.y),
45+
vec(rect.x + rect.width, rect.y + rect.height),
46+
);
47+
48+
export function unionBoundingBox2d(
49+
a: BoundingBox2d,
50+
b: BoundingBox2d,
51+
): BoundingBox2d {
52+
return {
53+
start: {
54+
x: Math.min(a.start.x, b.start.x),
55+
y: Math.min(a.start.y, b.start.y),
56+
},
57+
end: {
58+
x: Math.max(a.end.x, b.end.x),
59+
y: Math.max(a.end.y, b.end.y),
60+
},
61+
};
62+
}
63+
64+
export function layoutBounds(
65+
layout: LayoutResult,
66+
nodes?: NodeRecord[],
67+
): BoundingBox2d {
68+
const boxes = Object.values(layout)
69+
.map(boundingBoxFromRect)
70+
.reduce<BoundingBox2d | undefined>(
71+
(acc, bb) => (acc ? unionBoundingBox2d(acc, bb) : bb),
72+
undefined,
73+
);
74+
75+
const withStroke = Object.entries(layout).reduce<BoundingBox2d | undefined>(
76+
(acc, [id, box]) => {
77+
const n = nodes?.find((m) => m.id === id);
78+
const sw = n?.strokeWidth ?? (n?.type === "arrow" ? 3 : 0);
79+
if (!sw) return acc;
80+
const bb = boundingBoxFromRect({
81+
x: box.x - sw / 2,
82+
y: box.y - sw / 2,
83+
width: box.width + sw,
84+
height: box.height + sw,
85+
});
86+
return acc ? unionBoundingBox2d(acc, bb) : bb;
87+
},
88+
boxes,
89+
);
90+
91+
const fallback = boundingBoxFromPoints(vec(0, 0), vec(0, 0));
92+
return withStroke ?? fallback;
93+
}
94+
395
export function xml(
496
tag: string,
597
args: Record<string, string | number | undefined>,
@@ -31,106 +123,96 @@ function rect(
31123
id: string,
32124
box: LayoutResult[string],
33125
n: NodeRecord | undefined,
34-
dx: number,
35-
dy: number,
126+
offset: Vec2,
36127
): string {
37128
const sw = n?.strokeWidth ?? 0;
38-
return `${xml("rect", {
129+
return xml("rect", {
39130
id,
40-
x: box.x + dx - sw / 2,
41-
y: box.y + dy - sw / 2,
131+
x: box.x + offset.x - sw / 2,
132+
y: box.y + offset.y - sw / 2,
42133
width: box.width + sw,
43134
height: box.height + sw,
44135
...attrs(n),
45-
})}\n`;
136+
});
46137
}
47138

48139
function circle(
49140
id: string,
50141
box: LayoutResult[string],
51142
n: NodeRecord,
52-
dx: number,
53-
dy: number,
143+
offset: Vec2,
54144
): string {
55145
const r = (n.r ?? box.width / 2) as number;
56-
const cx = box.x + dx + r;
57-
const cy = box.y + dy + r;
58-
return `${xml("circle", {
146+
const cx = box.x + offset.x + r;
147+
const cy = box.y + offset.y + r;
148+
return xml("circle", {
59149
id,
60150
cx,
61151
cy,
62152
r,
63153
...attrs(n),
64-
})}\n`;
154+
});
65155
}
66156

67157
function textNode(
68158
id: string,
69159
box: LayoutResult[string],
70160
n: NodeRecord,
71-
dx: number,
72-
dy: number,
161+
offset: Vec2,
73162
): string {
74163
const t = n.text ?? "";
75-
return `${xml(
164+
return xml(
76165
"text",
77166
{
78167
id,
79-
x: box.x + dx,
80-
y: box.y + dy,
168+
x: box.x + offset.x,
169+
y: box.y + offset.y,
81170
"dominant-baseline": "hanging",
171+
"font-family": "sans-serif",
82172
...attrs(n),
83173
},
84174
t,
85-
)}\n`;
175+
);
86176
}
87177

88178
function arrow(
89179
id: string,
90180
n: NodeRecord,
91181
layout: LayoutResult,
92-
dx: number,
93-
dy: number,
182+
shift: Vec2,
94183
): string | undefined {
95184
const a = n.from ? layout[n.from] : undefined;
96185
const b = n.to ? layout[n.to] : undefined;
97186
if (!a || !b) return;
98187
const margin = 5;
99-
const x1 = a.x + dx + a.width / 2;
100-
const y1 = a.y + dy + a.height + margin;
101-
const x2 = b.x + dx + b.width / 2;
102-
const y2 = b.y + dy - margin;
103-
const dxv = x2 - x1;
104-
const dyv = y2 - y1;
105-
const len = Math.hypot(dxv, dyv);
188+
const start = vec(
189+
a.x + shift.x + a.width / 2,
190+
a.y + shift.y + a.height + margin,
191+
);
192+
const tip = vec(b.x + shift.x + b.width / 2, b.y + shift.y - margin);
193+
const dir = subVec(tip, start);
194+
const len = lengthVec(dir);
106195
const head = 6;
107196
const ratio = len > 0 ? (len - head) / len : 0;
108-
const sx2 = x1 + dxv * ratio;
109-
const sy2 = y1 + dyv * ratio;
110-
const ux = len === 0 ? 0 : dxv / len;
111-
const uy = len === 0 ? 0 : dyv / len;
112-
const perpX = -uy;
113-
const perpY = ux;
197+
const shaftEnd = addVec(start, scaleVec(dir, ratio));
198+
const unit = len === 0 ? vec(0, 0) : scaleVec(dir, 1 / len);
199+
const perp = perpVec(unit);
114200
const w = head * 0.6;
115-
const bx = sx2;
116-
const by = sy2;
117-
const leftX = bx + perpX * w * 0.5;
118-
const leftY = by + perpY * w * 0.5;
119-
const rightX = bx - perpX * w * 0.5;
120-
const rightY = by - perpY * w * 0.5;
201+
const left = addVec(shaftEnd, scaleVec(perp, w * 0.5));
202+
const right = addVec(shaftEnd, scaleVec(perp, -w * 0.5));
121203
const line = xml("line", {
122204
id,
123-
x1,
124-
y1,
125-
x2: sx2,
126-
y2: sy2,
205+
x1: start.x,
206+
y1: start.y,
207+
x2: shaftEnd.x,
208+
y2: shaftEnd.y,
127209
...attrs(n),
128210
});
129211
const poly = xml("polygon", {
130-
points: `${x2},${y2} ${leftX},${leftY} ${rightX},${rightY}`,
212+
points: `${tip.x},${tip.y} ${left.x},${left.y} ${right.x},${right.y}`,
131213
...attrs(n),
132214
});
133-
return `${line}\n${poly}\n`;
215+
return `${line}\n${poly}`;
134216
}
135217

136218
export function layoutToSvg(
@@ -139,72 +221,23 @@ export function layoutToSvg(
139221
): string {
140222
const byId = new Map<string, NodeRecord>();
141223
if (nodes) for (const n of nodes) byId.set(n.id, n);
142-
const boxes = Object.values(layout);
143-
let minX = Math.min(...boxes.map((b) => b.x));
144-
let minY = Math.min(...boxes.map((b) => b.y));
145-
let maxX = Math.max(...boxes.map((b) => b.x + b.width));
146-
let maxY = Math.max(...boxes.map((b) => b.y + b.height));
147-
for (const [id, box] of Object.entries(layout)) {
148-
const n = nodes?.find((m) => m.id === id);
149-
const sw = n?.strokeWidth ?? (n?.type === "arrow" ? 3 : 0);
150-
if (sw) {
151-
minX = Math.min(minX, box.x - sw / 2);
152-
minY = Math.min(minY, box.y - sw / 2);
153-
maxX = Math.max(maxX, box.x + box.width + sw / 2);
154-
maxY = Math.max(maxY, box.y + box.height + sw / 2);
155-
}
156-
}
157-
// account for arrow endpoints which may lie outside node boxes
158-
for (const n of nodes ?? []) {
159-
if (n.type === "arrow" && n.from && n.to) {
160-
const a = layout[n.from];
161-
const b = layout[n.to];
162-
if (a && b) {
163-
const margin = 5;
164-
const x1 = a.x + a.width / 2;
165-
const y1 = a.y + a.height + margin;
166-
const x2 = b.x + b.width / 2;
167-
const y2 = b.y - margin;
168-
const dxv = x2 - x1;
169-
const dyv = y2 - y1;
170-
const len = Math.hypot(dxv, dyv);
171-
const head = 6;
172-
const ratio = len > 0 ? (len - head) / len : 0;
173-
const sx2 = x1 + dxv * ratio;
174-
const sy2 = y1 + dyv * ratio;
175-
const ux = len === 0 ? 0 : dxv / len;
176-
const uy = len === 0 ? 0 : dyv / len;
177-
const perpX = -uy;
178-
const perpY = ux;
179-
const w = head * 0.6;
180-
const leftX = sx2 + perpX * w * 0.5;
181-
const leftY = sy2 + perpY * w * 0.5;
182-
const rightX = sx2 - perpX * w * 0.5;
183-
const rightY = sy2 - perpY * w * 0.5;
184-
minX = Math.min(minX, x1, x2, leftX, rightX);
185-
minY = Math.min(minY, y1, y2, leftY, rightY);
186-
maxX = Math.max(maxX, x1, x2, leftX, rightX);
187-
maxY = Math.max(maxY, y1, y2, leftY, rightY);
188-
}
189-
}
190-
}
191-
const dx = minX < 0 ? -minX : 0;
192-
const dy = minY < 0 ? -minY : 0;
193-
const body = Object.entries(layout)
194-
.map(([id, box]) => {
195-
const n = byId.get(id);
196-
if (!n?.type) return "";
197-
if (n.type === "circle") return circle(id, box, n, dx, dy);
198-
if (n.type === "text") return textNode(id, box, n, dx, dy);
199-
if (n.type === "arrow") return arrow(id, n, layout, dx, dy) ?? "";
200-
return rect(id, box, n, dx, dy);
201-
})
202-
.join("");
203-
const w = maxX - minX;
204-
const h = maxY - minY;
224+
const bounds = layoutBounds(layout, nodes);
225+
const min = bounds.start;
226+
const max = bounds.end;
227+
const offset = vec(min.x < 0 ? -min.x : 0, min.y < 0 ? -min.y : 0);
228+
const body = Object.entries(layout).map(([id, box]) => {
229+
const n = byId.get(id);
230+
if (!n?.type) return "";
231+
if (n.type === "circle") return circle(id, box, n, offset);
232+
if (n.type === "text") return textNode(id, box, n, offset);
233+
if (n.type === "arrow") return arrow(id, n, layout, offset) ?? "";
234+
return rect(id, box, n, offset);
235+
});
236+
const width = max.x - min.x;
237+
const height = max.y - min.y;
205238
return xml(
206239
"svg",
207-
{ xmlns: "http://www.w3.org/2000/svg", width: w, height: h },
208-
`\n${body}`,
240+
{ xmlns: "http://www.w3.org/2000/svg", width, height },
241+
body,
209242
);
210243
}

0 commit comments

Comments
 (0)