Skip to content

Commit 4bb1d23

Browse files
authored
fix(class): keep every multiplicity and role at its own end (#593)
Sol's library build lost two of five samples on data accuracy, and three on the class-drawn check, after #588: Member to Reservation runs straight up in one run, the "reservations" role found no spot clearly its own line's at Reservation, and walked down that run until it stood just above Member. A multiplicity or role now only takes a spot beside its own end: its middle within 110 units of the end's tip (the class-drawn check's reach) and the line's other tip half again as far off as its own, and 8 units further. That also keeps a self-loop's labels out from between its two runs. With no spot both beside its end and clearly its own line's, it stays beside its end, and the placements run again keeping room for labels; one that reads at most 5% longer than the placement it replaces may now take over. The class fuzz gains the end invariant: main misplaced 17 end labels across the 240-model full run and 74 across eight more seeds, none now. On those eight seeds, 8 labels whose room would cost crossings now stand at their end but nearer a neighboring line (81 findings on main, 12 now). Across the 100 saved class builds from the evals, three scenes change, each only in that one end's labels, and each now passes the class-drawn check. The renderer test checks every class fixture, and a new fixture and story carry Sol's build. Fixes #589
1 parent d99eb3c commit 4bb1d23

5 files changed

Lines changed: 293 additions & 20 deletions

File tree

‎packages/diagram/core/src/class-fixtures.ts‎

Lines changed: 157 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ const build = (input: Record<string, unknown>): ClassDiagram =>
1212
* Class diagrams that stress the layout: a model of twenty classes, a
1313
* hierarchy wider than any row should run, one five levels deep with an
1414
* interface several rows above its implementer, a class related to itself
15-
* twice, two classes in one row with a third between them, and two named
16-
* dependencies running opposite ways between a pair of interfaces.
15+
* twice, two classes in one row with a third between them, two named
16+
* dependencies running opposite ways between a pair of interfaces, and a
17+
* library whose roles must each stay at their own end.
1718
*/
1819
export const classStressFixtures = {
1920
library: build({
@@ -364,4 +365,158 @@ export const classStressFixtures = {
364365
},
365366
],
366367
}),
368+
// Sol's library-system-class-v2 build (r30): the "reservations" role at the Reservation end
369+
// of Member - Reservation, a line of one run, stood beside Member instead.
370+
memberReservations: build({
371+
id: "library-system",
372+
title: "Library system",
373+
classes: [
374+
{
375+
id: "Library",
376+
attributes: ["- name: String", "- branches: List<Branch>", "- catalog: Catalog"],
377+
methods: ["+ search(query: String): List<Item>"],
378+
},
379+
{ id: "Branch", attributes: ["- address: String"] },
380+
{
381+
id: "Catalog",
382+
attributes: ["- items: List<Item>"],
383+
methods: ["+ add(item: Item): void", "+ find(isbn: String): Book"],
384+
},
385+
{
386+
id: "Item",
387+
kind: "abstract",
388+
attributes: ["# id: UUID", "# title: String"],
389+
methods: ["+ loanPeriod(): int {abstract}"],
390+
},
391+
{
392+
id: "Book",
393+
attributes: ["- isbn: String", "- pages: int", "- authors: List<Author>"],
394+
methods: ["+ loanPeriod(): int"],
395+
},
396+
{ id: "DVD", attributes: ["- minutes: int"], methods: ["+ loanPeriod(): int"] },
397+
{ id: "Magazine", attributes: ["- issue: int"], methods: ["+ loanPeriod(): int"] },
398+
{ id: "Author", attributes: ["- name: String", "- books: List<Book>"] },
399+
{
400+
id: "Member",
401+
attributes: [
402+
"- memberId: String",
403+
"- name: String",
404+
"- loans: List<Loan>",
405+
"- reservations: List<Reservation>",
406+
],
407+
methods: ["+ borrow(item: Item): Loan", "+ reserve(item: Item): Reservation"],
408+
},
409+
{ id: "StudentMember", attributes: ["- school: String"] },
410+
{ id: "StaffMember", attributes: ["- department: String"] },
411+
{
412+
id: "Loan",
413+
attributes: ["- due: Date", "- returned: Date", "- item: Item", "- fine: Fine"],
414+
methods: ["+ isOverdue(): boolean"],
415+
},
416+
{ id: "Reservation", attributes: ["- placed: Date", "- item: Item"] },
417+
{ id: "Fine", attributes: ["- amount: Money"], methods: ["+ pay(): void"] },
418+
{
419+
id: "Librarian",
420+
attributes: ["- staffId: String", "- branch: Branch"],
421+
methods: ["+ checkOut(loan: Loan): void"],
422+
},
423+
],
424+
relationships: [
425+
{
426+
id: "relationship-1",
427+
kind: "composition",
428+
from: "Library",
429+
to: "Branch",
430+
fromMultiplicity: "1",
431+
toMultiplicity: "1..*",
432+
toRole: "branches",
433+
},
434+
{
435+
id: "relationship-2",
436+
kind: "composition",
437+
from: "Library",
438+
to: "Catalog",
439+
fromMultiplicity: "1",
440+
toMultiplicity: "1",
441+
toRole: "catalog",
442+
},
443+
{
444+
id: "relationship-3",
445+
kind: "composition",
446+
from: "Loan",
447+
to: "Fine",
448+
fromMultiplicity: "1",
449+
toRole: "fine",
450+
},
451+
{
452+
id: "relationship-4",
453+
kind: "aggregation",
454+
from: "Catalog",
455+
to: "Item",
456+
toMultiplicity: "0..*",
457+
toRole: "items",
458+
},
459+
{ id: "relationship-5", kind: "inheritance", from: "Book", to: "Item" },
460+
{ id: "relationship-6", kind: "inheritance", from: "DVD", to: "Item" },
461+
{ id: "relationship-7", kind: "inheritance", from: "Magazine", to: "Item" },
462+
{
463+
id: "relationship-8",
464+
kind: "association",
465+
from: "Book",
466+
to: "Author",
467+
fromMultiplicity: "0..*",
468+
toMultiplicity: "1..*",
469+
fromRole: "books",
470+
toRole: "authors",
471+
},
472+
{ id: "relationship-9", kind: "inheritance", from: "StudentMember", to: "Member" },
473+
{ id: "relationship-10", kind: "inheritance", from: "StaffMember", to: "Member" },
474+
{
475+
id: "relationship-11",
476+
kind: "association",
477+
from: "Member",
478+
to: "Loan",
479+
toMultiplicity: "0..*",
480+
toRole: "loans",
481+
navigable: true,
482+
},
483+
{
484+
id: "relationship-12",
485+
kind: "association",
486+
from: "Member",
487+
to: "Reservation",
488+
toMultiplicity: "0..*",
489+
toRole: "reservations",
490+
navigable: true,
491+
},
492+
{
493+
id: "relationship-13",
494+
kind: "association",
495+
from: "Loan",
496+
to: "Item",
497+
toRole: "item",
498+
navigable: true,
499+
},
500+
{
501+
id: "relationship-14",
502+
kind: "association",
503+
from: "Reservation",
504+
to: "Item",
505+
toRole: "item",
506+
navigable: true,
507+
},
508+
{
509+
id: "relationship-15",
510+
kind: "association",
511+
from: "Librarian",
512+
to: "Branch",
513+
toRole: "branch",
514+
navigable: true,
515+
},
516+
{ id: "relationship-16", kind: "dependency", from: "Librarian", to: "Loan" },
517+
{ id: "relationship-17", kind: "dependency", from: "Library", to: "Item" },
518+
{ id: "relationship-18", kind: "dependency", from: "Catalog", to: "Book" },
519+
{ id: "relationship-19", kind: "dependency", from: "Member", to: "Item" },
520+
],
521+
}),
367522
} as const satisfies Record<string, ClassDiagram>;

‎packages/diagram/excalidraw/src/lib/class-fuzz.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,8 @@ const PARALLEL_CLEARANCE = 10;
205205
*/
206206
const ATTRIBUTION_RATIO = 1.5;
207207
const ATTRIBUTION_MARGIN = 8;
208+
/** A multiplicity or role sits beside its end when its middle stands this near the end's tip. */
209+
const END_LABEL_REACH = 110;
208210

209211
/** Everything the export validator, the canvas validator or the layout guards would flag. */
210212
function defects(diagram: ClassDiagram): string[] {
@@ -349,6 +351,29 @@ function defects(diagram: ClassDiagram): string[] {
349351
? [`${label.id} ${own.toFixed(0)} px from its line, ${nearest.toFixed(0)} px from ${other}`]
350352
: [];
351353
});
354+
// Every multiplicity and role reads as its own end's: beside that end's tip, and the
355+
// line's other tip clearly further off.
356+
const tips = new Map(arrows.map((arrow) => [arrow.edgeId, arrow.points]));
357+
const reach = (label: (typeof labels)[number], tip: { x: number; y: number }) =>
358+
Math.hypot(
359+
Math.max(0, label.left - tip.x, tip.x - label.right),
360+
Math.max(0, label.top - tip.y, tip.y - label.bottom),
361+
);
362+
const misplaced = labels.flatMap((label) => {
363+
const [, owner, end] = label.id.split(":");
364+
if (end !== "from" && end !== "to") return [];
365+
const points = tips.get(owner!)!;
366+
const [tip, far] = end === "from" ? [points[0], points.at(-1)!] : [points.at(-1)!, points[0]];
367+
const [own, other] = [reach(label, tip), reach(label, far)];
368+
const center = Math.hypot(
369+
(label.left + label.right) / 2 - tip.x,
370+
(label.top + label.bottom) / 2 - tip.y,
371+
);
372+
return center > END_LABEL_REACH ||
373+
other < Math.max(ATTRIBUTION_RATIO * own, own + ATTRIBUTION_MARGIN)
374+
? [`${label.id} ${own.toFixed(0)} px from its end, ${other.toFixed(0)} px from the other`]
375+
: [];
376+
});
352377
// No two lines run side by side closer than a reader can tell apart.
353378
const runs = arrows.flatMap((arrow) =>
354379
segmentsFromPoints(arrow.points).map((segment) => ({ arrow: arrow.id, segment })),
@@ -378,6 +403,7 @@ function defects(diagram: ClassDiagram): string[] {
378403
...unwrapped,
379404
...crowded,
380405
...misattributed,
406+
...misplaced,
381407
...hugging,
382408
];
383409
}

‎packages/diagram/renderer/src/class-diagram.ts‎

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ const LABEL_CLEARANCE = 3;
6161
*/
6262
const ATTRIBUTION_RATIO = 1.5;
6363
const ATTRIBUTION_MARGIN = 8;
64+
/**
65+
* A multiplicity or role reads as its own end's only when its middle stands
66+
* this near the end's tip and, by the rule above, the line's other tip
67+
* stands clearly further off.
68+
*/
69+
const END_LABEL_REACH = 110;
6470
/** Past this, a layout's export reads poorly; a candidate pays for any excess. */
6571
const READABLE_ASPECT = 2.5;
6672
const NAME_FILL = "#efe6ec";
@@ -73,6 +79,11 @@ const EXPORT_PADDING = 20;
7379
* placement within it that routes no worse takes its place.
7480
*/
7581
const LONG_EXPORT = 2.4;
82+
/**
83+
* A placement keeping room for labels that reads longer than that may still
84+
* take over, when it reads at most this much longer than the one it replaces.
85+
*/
86+
const LABEL_ROOM_STRETCH = 1.05;
7687

7788
/** Each kind's line: dashed or solid, and the heads at its `from` and `to` ends. */
7889
const LINE_STYLES: Readonly<
@@ -451,7 +462,7 @@ function bestScene(
451462
}
452463
// While a label stands clearly its own line's nowhere, the placements run again
453464
// keeping room for labels; one takes its place when it leaves fewer such labels,
454-
// draws no more crossings, detours or hugging runs, reads no longer, and draws
465+
// draws no more crossings, detours or hugging runs, reads hardly longer, and draws
455466
// no more crossings and findings together than one more for each label it clears:
456467
// a label read as another line's misleads a reader more than a jog does.
457468
const labelled = (routed: Routed) => ({
@@ -475,7 +486,7 @@ function bestScene(
475486
const was = chosen.counts;
476487
if (
477488
routed.labels.unclear < chosen.labels.unclear &&
478-
(!long(routed) || exportAspect(scene) <= exportAspect(chosen.scene)) &&
489+
(!long(routed) || exportAspect(scene) <= LABEL_ROOM_STRETCH * exportAspect(chosen.scene)) &&
479490
counts.crossings <= was.crossings &&
480491
counts.crossings + counts.findings <=
481492
was.crossings + was.findings + chosen.labels.unclear - routed.labels.unclear &&
@@ -635,6 +646,13 @@ function* labelSpots(
635646
}
636647
}
637648

649+
function rectPointDistance(rect: Rect, point: { readonly x: number; readonly y: number }): number {
650+
return Math.hypot(
651+
Math.max(0, rect.left - point.x, point.x - rect.right),
652+
Math.max(0, rect.top - point.y, point.y - rect.bottom),
653+
);
654+
}
655+
638656
/** A route from its start up to halfway along it. */
639657
function halfway(points: readonly { x: number; y: number }[]): { x: number; y: number }[] {
640658
const lengths = points
@@ -659,7 +677,9 @@ function halfway(points: readonly { x: number; y: number }[]): { x: number; y: n
659677
* Place each multiplicity and role beside its end's first run, the two on
660678
* opposite sides of the line, and each relationship's name beside its
661679
* longest run, every one clear of the boxes, the lines and the others, and
662-
* clearly nearer its own line than any other, so none reads as another's.
680+
* clearly nearer its own line than any other, so none reads as another's;
681+
* a multiplicity or role also stays beside its own end's tip, clearly nearer
682+
* it than the line's other tip, so none reads as the other end's.
663683
*/
664684
function placeLabels(
665685
diagram: ClassDiagram,
@@ -683,19 +703,23 @@ function placeLabels(
683703
placed.filter((other) => rectsOverlap(rect, other, LABEL_CLEARANCE)).length;
684704
const free = (rect: Rect) => collisions(rect) === 0;
685705
const anywhere = () => free;
686-
// Free, and the text as drawn within its room stands clearly nearer its own line.
687-
const attributed = (owner: string, text: string) => (rect: Rect) => {
688-
if (!free(rect)) return false;
706+
// The text as drawn within its room.
707+
const drawnRect = (text: string, rect: Rect): Rect => {
689708
const inset = {
690709
x: (labelWidth(text) - estimateTextWidth(text, LABEL_FONT_SIZE)) / 2,
691710
y: (text.split("\n").length * (LABEL_HEIGHT - LABEL_FONT_SIZE * 1.35)) / 2,
692711
};
693-
const drawn = {
712+
return {
694713
left: rect.left + inset.x,
695714
top: rect.top + inset.y,
696715
right: rect.right - inset.x,
697716
bottom: rect.bottom - inset.y,
698717
};
718+
};
719+
// Free, and the text as drawn within its room stands clearly nearer its own line.
720+
const attributed = (owner: string, text: string) => (rect: Rect) => {
721+
if (!free(rect)) return false;
722+
const drawn = drawnRect(text, rect);
699723
let [own, other] = [Infinity, Infinity];
700724
for (const run of owned) {
701725
const distance = rectSegmentDistance(drawn, run.segment);
@@ -741,6 +765,7 @@ function placeLabels(
741765
const labels = endLabels(relationship, end);
742766
if (labels.length === 0) continue;
743767
const route = end === "from" ? arrow.points : arrow.points.toReversed();
768+
const [tip, far] = [route[0]!, route.at(-1)!];
744769
const owner = end === "from" ? arrow.sourceNodeId : arrow.targetNodeId;
745770
const keys = [
746771
relationship[end === "from" ? "fromMultiplicity" : "toMultiplicity"] && "multiplicity",
@@ -763,10 +788,29 @@ function placeLabels(
763788
placed.splice(placed.length - rects.length, rects.length);
764789
return rects.length === labels.length ? rects : undefined;
765790
};
766-
// On the end's first run, clearly its own line's: two labels on opposite
767-
// sides, whichever way round fits, else together on one side; one label on
768-
// either side, nearest the end first.
769-
const mine = (text: string) => attributed(relationship.id, text);
791+
// The text as drawn stands beside this end's tip, the other tip clearly further off.
792+
const beside = (text: string) => (rect: Rect) => {
793+
const drawn = drawnRect(text, rect);
794+
const [own, other] = [rectPointDistance(drawn, tip), rectPointDistance(drawn, far)];
795+
return (
796+
Math.hypot(
797+
(drawn.left + drawn.right) / 2 - tip.x,
798+
(drawn.top + drawn.bottom) / 2 - tip.y,
799+
) <= END_LABEL_REACH &&
800+
other >= Math.max(ATTRIBUTION_RATIO * own, own + ATTRIBUTION_MARGIN)
801+
);
802+
};
803+
const near = (text: string) => {
804+
const here = beside(text);
805+
return (rect: Rect) => free(rect) && here(rect);
806+
};
807+
// On the end's first run, beside the end and clearly its own line's: two labels
808+
// on opposite sides, whichever way round fits, else together on one side; one
809+
// label on either side, nearest the end first.
810+
const mine = (text: string) => {
811+
const [line, here] = [attributed(relationship.id, text), beside(text)];
812+
return (rect: Rect) => line(rect) && here(rect);
813+
};
770814
const sides: ((index: number) => readonly (-1 | 1)[])[] =
771815
labels.length === 2
772816
? [
@@ -777,23 +821,27 @@ function placeLabels(
777821
]
778822
: [() => [1, -1]];
779823
// Failing that, each the nearest such spot on either side along the end's
780-
// half of the line; then opposite sides, then each the nearest free spot on
781-
// either side anywhere along the line; then, with none free, its first spot.
824+
// half of the line; then the same beside the end, if nearer another line;
825+
// then opposite sides, then each the nearest free spot on either side along
826+
// the end's half; then, with none free, its first spot.
782827
const orders: (readonly (-1 | 1)[])[] =
783828
labels.length === 2
784829
? [
785830
[1, -1],
786831
[-1, 1],
787832
]
788833
: [[1], [-1]];
789-
const clear =
790-
sides.map((side) => place(route.slice(0, 2), side, mine)).find(Boolean) ??
791-
place(halfway(route), () => [1, -1], mine);
834+
const half = halfway(route);
835+
const nearest = (accept: (text: string) => (rect: Rect) => boolean) =>
836+
sides.map((side) => place(route.slice(0, 2), side, accept)).find(Boolean) ??
837+
place(half, () => [1, -1], accept);
838+
const clear = nearest(mine);
792839
if (!clear) unclear += labels.length;
793840
const chosen =
794841
clear ??
795-
orders.map((order) => place(route, (index) => [order[index]!], anywhere)).find(Boolean) ??
796-
place(route, () => [1, -1], anywhere) ??
842+
nearest(near) ??
843+
orders.map((order) => place(half, (index) => [order[index]!], anywhere)).find(Boolean) ??
844+
place(half, () => [1, -1], anywhere) ??
797845
labels.map((label) => spots(route, labelWidth(label), LABEL_HEIGHT, [1, -1], 4)[0]!);
798846
for (const [index, rect] of chosen.entries())
799847
add(

0 commit comments

Comments
 (0)