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
73 changes: 72 additions & 1 deletion packages/diagram/core/src/class-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ const build = (input: Record<string, unknown>): ClassDiagram =>
* Class diagrams that stress the layout: a model of twenty classes, a
* hierarchy wider than any row should run, one five levels deep with an
* interface several rows above its implementer, a class related to itself
* twice, and two classes in one row with a third between them.
* twice, two classes in one row with a third between them, and two named
* dependencies running opposite ways between a pair of interfaces.
*/
export const classStressFixtures = {
library: build({
Expand Down Expand Up @@ -293,4 +294,74 @@ export const classStressFixtures = {
{ id: "sale", kind: "realization", from: "Sale", to: "PricingStrategy" },
],
}),
// Sol's observer-pattern-class-v2 build (rv583): "attach / detach" stood between its own
// line and "update parameter"'s, 6 units from its own and 4 from the other.
opposingDependencies: build({
id: "weather-observer",
title: "Weather station observer",
classes: [
{
id: "Subject",
kind: "interface",
methods: [
"+ attach(o: Observer): void",
"+ detach(o: Observer): void",
"+ notifyObservers(): void",
],
},
{ id: "Observer", kind: "interface", methods: ["+ update(subject: Subject): void"] },
{
id: "WeatherStation",
attributes: ["- temperature: double", "- humidity: double", "- observers: List<Observer>"],
methods: [
"+ attach(o: Observer): void",
"+ detach(o: Observer): void",
"+ notifyObservers(): void",
"+ setMeasurements(t: double, h: double): void",
"+ getTemperature(): double",
],
},
{
id: "PhoneDisplay",
attributes: ["- lastReading: double"],
methods: ["+ update(subject: Subject): void"],
},
{ id: "WindowDisplay", methods: ["+ update(subject: Subject): void"] },
],
relationships: [
{ id: "station-subject", kind: "realization", from: "WeatherStation", to: "Subject" },
{ id: "phone-observer", kind: "realization", from: "PhoneDisplay", to: "Observer" },
{ id: "window-observer", kind: "realization", from: "WindowDisplay", to: "Observer" },
{
id: "observers",
kind: "association",
from: "WeatherStation",
to: "Observer",
toMultiplicity: "0..*",
toRole: "observers",
navigable: true,
},
{
id: "attaches",
kind: "dependency",
from: "Subject",
to: "Observer",
label: "attach / detach",
},
{
id: "updates",
kind: "dependency",
from: "Observer",
to: "Subject",
label: "update parameter",
},
{
id: "reads",
kind: "dependency",
from: "PhoneDisplay",
to: "WeatherStation",
label: "casts to; getTemperature()",
},
],
}),
} as const satisfies Record<string, ClassDiagram>;
33 changes: 32 additions & 1 deletion packages/diagram/excalidraw/src/lib/class-fuzz.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
type AxisAlignedSegment,
candidateRouteQuality,
type ClassDiagram,
type ClassRelationshipKind,
Expand Down Expand Up @@ -198,6 +199,12 @@ function routingCounts(scene: RenderedDiagramScene) {
const PORT_CLEARANCE = 24;
/** Parallel runs of two lines closer than this read as one line. */
const PARALLEL_CLEARANCE = 10;
/**
* A label reads as its own line's only when every other line stands half again
* as far off, and at least this much further.
*/
const ATTRIBUTION_RATIO = 1.5;
const ATTRIBUTION_MARGIN = 8;

/** Everything the export validator, the canvas validator or the layout guards would flag. */
function defects(diagram: ClassDiagram): string[] {
Expand Down Expand Up @@ -319,6 +326,29 @@ function defects(diagram: ClassDiagram): string[] {
)
.map((other) => `${label.id} on ${other.id}`),
]);
// Every free label reads as its own line's: every other line stands clearly further off.
const routes = new Map(arrows.map((arrow) => [arrow.edgeId, segmentsFromPoints(arrow.points)]));
const gap = (label: (typeof labels)[number], run: AxisAlignedSegment) => {
const [x0, x1, y0, y1] =
run.orientation === "horizontal"
? [run.min, run.max, run.staticCoordinate, run.staticCoordinate]
: [run.staticCoordinate, run.staticCoordinate, run.min, run.max];
return Math.hypot(
Math.max(0, label.left - x1, x0 - label.right),
Math.max(0, label.top - y1, y0 - label.bottom),
);
};
const misattributed = labels.flatMap((label) => {
const owner = label.id.split(":")[1]!;
const own = Math.min(...routes.get(owner)!.map((run) => gap(label, run)));
const [other, nearest] = [...routes]
.filter(([id]) => id !== owner)
.map(([id, runs]) => [id, Math.min(...runs.map((run) => gap(label, run)))] as const)
.reduce((best, next) => (next[1] < best[1] ? next : best), ["", Infinity] as const);
return nearest < Math.max(ATTRIBUTION_RATIO * own, own + ATTRIBUTION_MARGIN)
? [`${label.id} ${own.toFixed(0)} px from its line, ${nearest.toFixed(0)} px from ${other}`]
: [];
});
// No two lines run side by side closer than a reader can tell apart.
const runs = arrows.flatMap((arrow) =>
segmentsFromPoints(arrow.points).map((segment) => ({ arrow: arrow.id, segment })),
Expand Down Expand Up @@ -347,6 +377,7 @@ function defects(diagram: ClassDiagram): string[] {
...packed,
...unwrapped,
...crowded,
...misattributed,
...hugging,
];
}
Expand All @@ -356,7 +387,7 @@ function defects(diagram: ClassDiagram): string[] {
* draws, in order. None may draw more crossings, nor more crossings and
* findings together, and together they may draw no more of either.
*/
const CLASS_ROUTING = "0/0 0/0 0/0 2/1 0/0 0/0 0/2 0/0 0/0 1/0 0/0 0/1 0/0 0/0 0/0 0/0";
const CLASS_ROUTING = "0/0 0/0 0/0 0/2 0/0 0/0 0/2 0/0 0/0 1/0 0/0 0/1 0/0 0/0 0/0 0/0";

/** Models checked in CI; the full fuzz runs locally with CLASS_FUZZ=full (and any CLASS_FUZZ_SEED). */
const CI_MODELS = 16;
Expand Down
Loading
Loading