@brunosouzas/design-system 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -3
- package/dist/src/pieces/diagram.d.ts +128 -0
- package/dist/src/pieces/diagram.js +198 -0
- package/dist/src/pieces/ink.d.ts +48 -0
- package/dist/src/pieces/ink.js +38 -0
- package/dist/src/pieces/library.d.ts +22 -0
- package/dist/src/pieces/library.js +3 -0
- package/dist/src/pieces/metro.d.ts +7 -9
- package/dist/src/pieces/metro.js +26 -39
- package/dist/src/pieces/node.d.ts +1 -1
- package/dist/src/pieces/node.js +1 -0
- package/dist/src/pieces/params.js +5 -2
- package/dist/src/pieces/render.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ The visual identity of [brunosouzas.com](https://brunosouzas.com): design tokens
|
|
|
25
25
|
|
|
26
26
|
## Status
|
|
27
27
|
|
|
28
|
-
Early. The first change of the [roadmap](docs/roadmap.md) is implemented: the token source and its CSS, TypeScript and JSON exports, the motion components, and the circuit-breaker state machine as the first catalogue scene (16:9 and 4:5, Portuguese and English), with commands to render its stills and videos. The first variety scene, `publish-subscribe`, is implemented with independent message copies, Publish acceptance, two ACKs and a local invoice NACK. Its owner stills/video approval is pending; implementation is not visual approval. The illustration pieces (roadmap item 3), the metro and diagram pieces (item 6)
|
|
28
|
+
Early. The first change of the [roadmap](docs/roadmap.md) is implemented: the token source and its CSS, TypeScript and JSON exports, the motion components, and the circuit-breaker state machine as the first catalogue scene (16:9 and 4:5, Portuguese and English), with commands to render its stills and videos. The first variety scene, `publish-subscribe`, is implemented with independent message copies, Publish acceptance, two ACKs and a local invoice NACK. Its owner stills/video approval is pending; implementation is not visual approval. The illustration pieces (roadmap item 3), the metro and diagram pieces (item 6) stackledger's map markers, zones and icons (AB#167, 0.3.0) and the body diagram pieces (AB#141, 0.3.2) are implemented: the static drawing library that covers, banners, diagrams and scenes share, with a command that builds a blog cover from a JSON spec. The published covers are not ported to it yet (item 5).
|
|
29
29
|
|
|
30
30
|
## What it is
|
|
31
31
|
|
|
@@ -131,11 +131,11 @@ The components take every colour and size from the tokens, and what is visible a
|
|
|
131
131
|
- `toSvg(node)` writes a tree as SVG text; `toReact(node)` turns the same tree into React elements. Both describe the same elements, attributes and text, which the tests check.
|
|
132
132
|
- `pose(node, { x, y, tilt, squash, hop, scale, shake, opacity })` animates any piece around its base point; a scene recomputes the numbers every frame.
|
|
133
133
|
- Every colour parameter is a token path (`"color.healthy"`, `"illustration.accent"`), never a hex value, except the three data fills of the map pieces below. Each piece checks its parameters before drawing and names the first wrong one. Positions and points take any finite number, because a map has no edge; sizes stay within 10000. A drawing that would hold a non-finite number throws `<piece>: the drawing overflows`.
|
|
134
|
-
- The
|
|
134
|
+
- The 51 pieces, with their anchors, parameters and defaults, are listed in [design §4](openspec/changes/archive/2026-10-23-illustration-pieces/design.md) (the 26 cover pieces), in [design §3](openspec/changes/archive/2026-10-23-metro-and-diagram-pieces/design.md) of `metro-and-diagram-pieces` (the nine map and diagram pieces) and in [design §3, §5 and §6](openspec/changes/archive/2026-10-23-stackledger-map-and-link-pieces/design.md) of `stackledger-map-and-link-pieces` (the six markers, zones and icons) and in [design §3 to §5](openspec/changes/archive/2026-10-23-body-diagram-pieces/design.md) of `body-diagram-pieces` (the ten body diagram pieces); the rules are in [`openspec/specs/illustration-pieces/`](openspec/specs/illustration-pieces/spec.md). `npm run pieces:sheet -- --out <file.svg>` draws them all.
|
|
135
135
|
|
|
136
136
|
### Maps and diagrams
|
|
137
137
|
|
|
138
|
-
Thirteen pieces draw a metro map and its page diagrams, for [stackledger](https://github.com/brunosouzas/stackledger) and any other map in this style: `lineBadge`, `stationMarker` (stop, interchange as a capsule, deprecated), `pipe`, `countBadge`, `nodeBlock`, `pillNode`, `hexNode` (normal, focus, placeholder), `messageArrow` (curved or
|
|
138
|
+
Thirteen pieces draw a metro map and its page diagrams, for [stackledger](https://github.com/brunosouzas/stackledger) and any other map in this style: `lineBadge`, `stationMarker` (stop, interchange as a capsule, deprecated), `pipe`, `countBadge`, `nodeBlock`, `pillNode`, `hexNode` (normal, focus, placeholder), `messageArrow` (curved, straight or through two `controls` points, with a head and an optional label; `kind` `reply`, `failure` or `failFast` dashes it and paints it as ADR 0003 says), `ledgerMark` (stackledger's mark; `surface: "dark"` lines it with a light rim), `renewalRing` (orange bands round a station), `suggestedHalo` (a dashed muted circle), `licenceMark` (a 10-unit disc, green when held, white to buy) and `zone` (the outline the caller gives, widened by a round 32-unit stroke at the surface's zone opacity, with an optional title). They paint what the caller lays out: positions, hulls, text measurement and truncation stay with the map engine.
|
|
139
139
|
|
|
140
140
|
- A `placeholder` node may have a `subtitle` (drawn like a normal node's, all muted), and `lineBadge` may have no `text` (the pill alone, 30 wide by default).
|
|
141
141
|
- The licence's disc is small, so it has its own room rule: `size` at least three outlines (9 at ink scale 1).
|
|
@@ -153,6 +153,15 @@ const stop = stationMarker({ cx: 120, cy: 40, surface: "dark" });
|
|
|
153
153
|
const badge = lineBadge({ x: 250, y: 40, text: "SH", fill: "#7B3FE4", surface: "dark" });
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
+
### Body diagrams
|
|
157
|
+
|
|
158
|
+
Ten pieces draw what the blog's body diagrams need beyond the map pieces: `diagramBox` (a box or pill with a fill and centred lines), `decisionNode` (diamond), `storeNode` (cylinder), `queueNode` (box with two rails), `exchangeNode` (hexagon), `taggedContainer` (a frame with its name on a tab), `lifeline` (dashed, or sparse dots once nothing calls it), `phaseBand` (a pill across a sequence in its state's colour), `altFrame` (alt/else frame with a tag and condition per part) and `edgeLabel` (words next to an arrow, optionally on a backdrop). They take `surface` and, the stickers, `inkScale`, like the map pieces, and paint what the caller lays out.
|
|
159
|
+
|
|
160
|
+
- The shapes take one to three centred lines (`text`), an optional `subtitle` and `textSize`. Without `fill` the face is the surface's panel; with a fill, every word is ink.
|
|
161
|
+
- Backticks mark code: `` "adopts a `correlationId`" `` draws `correlationId` in the monospace stack, as a `tspan`. An odd number of backticks is drawn as is.
|
|
162
|
+
- Widths the piece estimates (`tagWidth`, `conditionWidth`, a label's `width`) can be passed by a caller that measures its text.
|
|
163
|
+
- A body diagram is 800 wide. Lay it out in 560 units and show it 800 wide: the pieces then come out at the sizes of the published diagrams (text about 18, outline about 4); only the hard shadow is shallower (5 instead of 8).
|
|
164
|
+
|
|
156
165
|
### A cover
|
|
157
166
|
|
|
158
167
|
A cover is a JSON spec kept with its article, outside this repository: drafts are private, and only the pieces are public. `npm run cover -- <spec.json> --out <file.svg>` validates it, reports every problem with its JSON path, and writes an 800x420 SVG; the same spec always gives the same bytes. With `"format": "banner"` the same command writes a site banner instead (see below).
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { type Ink, type SurfaceName } from "./ink.ts";
|
|
2
|
+
export type ShapeParams = Ink & {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
width: number;
|
|
6
|
+
height?: number;
|
|
7
|
+
text: string[];
|
|
8
|
+
subtitle?: string;
|
|
9
|
+
fill?: string;
|
|
10
|
+
textSize?: number;
|
|
11
|
+
};
|
|
12
|
+
export type DiagramBoxParams = ShapeParams & {
|
|
13
|
+
radius?: number;
|
|
14
|
+
shape?: "box" | "pill";
|
|
15
|
+
};
|
|
16
|
+
/** A box or a pill with a fill and centred lines: a body diagram's step, system or note. */
|
|
17
|
+
export declare const diagramBox: import("./piece.ts").Piece<DiagramBoxParams>;
|
|
18
|
+
/** A decision: a diamond in its bounds. */
|
|
19
|
+
export declare const decisionNode: import("./piece.ts").Piece<Ink & {
|
|
20
|
+
x: number;
|
|
21
|
+
y: number;
|
|
22
|
+
width: number;
|
|
23
|
+
height?: number;
|
|
24
|
+
text: string[];
|
|
25
|
+
subtitle?: string;
|
|
26
|
+
fill?: string;
|
|
27
|
+
textSize?: number;
|
|
28
|
+
} & {
|
|
29
|
+
height: number;
|
|
30
|
+
}>;
|
|
31
|
+
export type StoreNodeParams = ShapeParams & {
|
|
32
|
+
height: number;
|
|
33
|
+
ry?: number;
|
|
34
|
+
};
|
|
35
|
+
/** A store: a cylinder in its bounds, with the front of its lid. */
|
|
36
|
+
export declare const storeNode: import("./piece.ts").Piece<StoreNodeParams>;
|
|
37
|
+
/** A queue: a box with a rail 10 in from each side. */
|
|
38
|
+
export declare const queueNode: import("./piece.ts").Piece<ShapeParams>;
|
|
39
|
+
/** An exchange: a hexagon with its points 0.26 × height in, as the motion shape (src/brand/flow-shapes.ts). */
|
|
40
|
+
export declare const exchangeNode: import("./piece.ts").Piece<ShapeParams>;
|
|
41
|
+
export type TaggedContainerParams = Ink & {
|
|
42
|
+
x: number;
|
|
43
|
+
y: number;
|
|
44
|
+
width: number;
|
|
45
|
+
height: number;
|
|
46
|
+
tag: string;
|
|
47
|
+
tagWidth?: number;
|
|
48
|
+
tagFill?: string;
|
|
49
|
+
fill?: string;
|
|
50
|
+
};
|
|
51
|
+
/** A container (a platform, a broker) with its name on a tab centred on the top edge. */
|
|
52
|
+
export declare const taggedContainer: import("./piece.ts").Piece<TaggedContainerParams>;
|
|
53
|
+
export type LifelineParams = {
|
|
54
|
+
x: number;
|
|
55
|
+
y1: number;
|
|
56
|
+
y2: number;
|
|
57
|
+
state?: "active" | "inactive";
|
|
58
|
+
width?: number;
|
|
59
|
+
surface?: SurfaceName;
|
|
60
|
+
};
|
|
61
|
+
/** A participant's lifeline: dashed while active, sparse dots once nothing calls it. */
|
|
62
|
+
export declare const lifeline: import("./piece.ts").Piece<LifelineParams>;
|
|
63
|
+
export type PhaseBandParams = Ink & {
|
|
64
|
+
x: number;
|
|
65
|
+
y: number;
|
|
66
|
+
width: number;
|
|
67
|
+
height?: number;
|
|
68
|
+
text: string;
|
|
69
|
+
fill: string;
|
|
70
|
+
textSize?: number;
|
|
71
|
+
};
|
|
72
|
+
/** A phase of a sequence: a pill across the diagram in its state's colour. */
|
|
73
|
+
export declare const phaseBand: import("./piece.ts").Piece<PhaseBandParams>;
|
|
74
|
+
type Head = {
|
|
75
|
+
tag: string;
|
|
76
|
+
condition?: string;
|
|
77
|
+
tagWidth?: number;
|
|
78
|
+
conditionWidth?: number;
|
|
79
|
+
};
|
|
80
|
+
export type AltFrameParams = Head & {
|
|
81
|
+
x: number;
|
|
82
|
+
y: number;
|
|
83
|
+
width: number;
|
|
84
|
+
height: number;
|
|
85
|
+
sections?: (Head & {
|
|
86
|
+
y: number;
|
|
87
|
+
})[];
|
|
88
|
+
surface?: SurfaceName;
|
|
89
|
+
};
|
|
90
|
+
/** An alt/else frame: the frame, a dashed divider per section, and a tag (with its condition) per part. */
|
|
91
|
+
export declare const altFrame: import("./piece.ts").Piece<AltFrameParams>;
|
|
92
|
+
export type EdgeLabelParams = {
|
|
93
|
+
x: number;
|
|
94
|
+
y: number;
|
|
95
|
+
lines: string[];
|
|
96
|
+
anchor?: "start" | "middle" | "end";
|
|
97
|
+
backdrop?: boolean;
|
|
98
|
+
width?: number;
|
|
99
|
+
textSize?: number;
|
|
100
|
+
surface?: SurfaceName;
|
|
101
|
+
};
|
|
102
|
+
/** Words next to an arrow: lines down from the baseline (x, y), optionally on a backdrop that hides what is behind. */
|
|
103
|
+
export declare const edgeLabel: import("./piece.ts").Piece<EdgeLabelParams>;
|
|
104
|
+
/** The body diagram pieces, in catalogue order (design §3). */
|
|
105
|
+
export declare const DIAGRAM_PIECES: {
|
|
106
|
+
readonly diagramBox: import("./piece.ts").Piece<DiagramBoxParams>;
|
|
107
|
+
readonly decisionNode: import("./piece.ts").Piece<Ink & {
|
|
108
|
+
x: number;
|
|
109
|
+
y: number;
|
|
110
|
+
width: number;
|
|
111
|
+
height?: number;
|
|
112
|
+
text: string[];
|
|
113
|
+
subtitle?: string;
|
|
114
|
+
fill?: string;
|
|
115
|
+
textSize?: number;
|
|
116
|
+
} & {
|
|
117
|
+
height: number;
|
|
118
|
+
}>;
|
|
119
|
+
readonly storeNode: import("./piece.ts").Piece<StoreNodeParams>;
|
|
120
|
+
readonly queueNode: import("./piece.ts").Piece<ShapeParams>;
|
|
121
|
+
readonly exchangeNode: import("./piece.ts").Piece<ShapeParams>;
|
|
122
|
+
readonly taggedContainer: import("./piece.ts").Piece<TaggedContainerParams>;
|
|
123
|
+
readonly lifeline: import("./piece.ts").Piece<LifelineParams>;
|
|
124
|
+
readonly phaseBand: import("./piece.ts").Piece<PhaseBandParams>;
|
|
125
|
+
readonly altFrame: import("./piece.ts").Piece<AltFrameParams>;
|
|
126
|
+
readonly edgeLabel: import("./piece.ts").Piece<EdgeLabelParams>;
|
|
127
|
+
};
|
|
128
|
+
export {};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Body diagram pieces (spec illustration-pieces, change body-diagram-pieces,
|
|
2
|
+
// design §3 and §4): the shapes, lines and labels of the blog's body diagrams
|
|
3
|
+
// that the metro and diagram pieces lack. Like those, they paint what the caller
|
|
4
|
+
// lays out; positions, text measurement and truncation stay with the caller.
|
|
5
|
+
//
|
|
6
|
+
// What can go wrong: every parameter is checked by its schema first; the limits
|
|
7
|
+
// between parameters are checked in the order of design §3 and the first one is
|
|
8
|
+
// thrown as a RangeError naming the parameter.
|
|
9
|
+
import { tokens } from "../../tokens/generated/tokens.js";
|
|
10
|
+
import { inkOf, inkSchema, limits, roomFor, sticker, surfaceParam } from "./ink.js";
|
|
11
|
+
import { drawn, el, r2 } from "./node.js";
|
|
12
|
+
import { col, colour, label, MAX_SIZE, pos, size } from "./params.js";
|
|
13
|
+
import { definePiece } from "./piece.js";
|
|
14
|
+
const ink = tokens.color.ink;
|
|
15
|
+
const font = tokens.font.sans;
|
|
16
|
+
const bold = tokens.font.weightBold;
|
|
17
|
+
const M = tokens.map;
|
|
18
|
+
const lines = (max) => ({ kind: "array", required: true, minItems: 1, maxItems: max, of: label() });
|
|
19
|
+
const textSize = { kind: "number", min: 6, max: 48 };
|
|
20
|
+
// ---------- words ----------
|
|
21
|
+
/** Characters of a word for a width estimate: backticks do not count. */
|
|
22
|
+
const characters = (value) => Array.from(value.replace(/`/g, "")).length;
|
|
23
|
+
/** Backticks mark code (design §4): with an even number of them, each part between backticks becomes a tspan in
|
|
24
|
+
* the monospace stack; empty parts are dropped. With an odd number, the string is drawn as is. */
|
|
25
|
+
const spans = (value) => {
|
|
26
|
+
const parts = value.split("`");
|
|
27
|
+
if (parts.length % 2 === 0)
|
|
28
|
+
return [value];
|
|
29
|
+
return parts.flatMap((part, i) => (part === "" ? [] : i % 2 === 1 ? [el("tspan", { "font-family": tokens.font.mono }, part)] : [part]));
|
|
30
|
+
};
|
|
31
|
+
// Every text sets its own anchor, size and weight, so a surrounding group (a cover's label style) does not leak in.
|
|
32
|
+
const words = (x, y, value, attrs) => el("text", { x: r2(x), y: r2(y), "font-family": font, ...attrs }, ...spans(value));
|
|
33
|
+
/** No fill: the surface's panel with its text; a fill: that token with ink words (ADR 0003). */
|
|
34
|
+
const coloursOf = (fill, params) => {
|
|
35
|
+
const { s } = inkOf(params);
|
|
36
|
+
return fill === undefined ? { face: s.panel, text: s.text, sub: s.muted } : { face: colour(fill, "fill"), text: ink, sub: ink };
|
|
37
|
+
};
|
|
38
|
+
/** Centred lines (bold) and an optional subtitle (11, regular), centred on (cx, cy). */
|
|
39
|
+
const centred = (cx, cy, text, subtitle, c, size_ = M.labelSize) => {
|
|
40
|
+
const step = size_ * 1.25;
|
|
41
|
+
const block = (text.length - 1) * step + (subtitle === undefined ? 0 : 15);
|
|
42
|
+
const first = cy + (4.5 * size_) / 13 - block / 2;
|
|
43
|
+
return [
|
|
44
|
+
...text.map((line, i) => words(cx, first + i * step, line, { fill: c.text, "font-size": size_, "font-weight": bold, "text-anchor": "middle" })),
|
|
45
|
+
...(subtitle === undefined ? [] : [words(cx, first + (text.length - 1) * step + 15, subtitle, { fill: c.sub, "font-size": 11, "font-weight": 400, "text-anchor": "middle" })]),
|
|
46
|
+
];
|
|
47
|
+
};
|
|
48
|
+
/** Room on the smaller of width and height, naming the smaller one. */
|
|
49
|
+
const roomOn = (width, height, params) => width < height ? roomFor("width", width, params) : roomFor("height", height, params);
|
|
50
|
+
const polygon = (points) => (dx, dy, attrs) => el("polygon", { points: points.map(([px, py]) => `${r2(px + dx)},${r2(py + dy)}`).join(" "), ...attrs });
|
|
51
|
+
const rect = (x, y, w, h, rx) => (dx, dy, attrs) => el("rect", { x: r2(x + dx), y: r2(y + dy), width: r2(w), height: r2(h), rx: r2(rx), ...attrs });
|
|
52
|
+
const shapeSchema = (heightRequired) => ({
|
|
53
|
+
x: pos(),
|
|
54
|
+
y: pos(),
|
|
55
|
+
width: size(true),
|
|
56
|
+
height: size(heightRequired),
|
|
57
|
+
text: lines(3),
|
|
58
|
+
subtitle: label(false),
|
|
59
|
+
fill: col(),
|
|
60
|
+
textSize,
|
|
61
|
+
...inkSchema,
|
|
62
|
+
});
|
|
63
|
+
/** A box or a pill with a fill and centred lines: a body diagram's step, system or note. */
|
|
64
|
+
export const diagramBox = definePiece("diagramBox", { ...shapeSchema(false), radius: { kind: "number", min: 0, max: MAX_SIZE }, shape: { kind: "enum", values: ["box", "pill"] } }, (params) => {
|
|
65
|
+
const { x, y, width, height = M.nodeHeight, text, subtitle, fill, shape = "box" } = params;
|
|
66
|
+
const radius = shape === "pill" ? height / 2 : (params.radius ?? Math.min(M.nodeRadius, height / 2));
|
|
67
|
+
const c = coloursOf(fill, params);
|
|
68
|
+
return drawn(sticker(rect(x, y, width, height, radius), c.face, params), centred(x + width / 2, y + height / 2, text, subtitle, c, params.textSize));
|
|
69
|
+
}, (params) => {
|
|
70
|
+
const height = params.height ?? M.nodeHeight;
|
|
71
|
+
return limits(roomOn(params.width, height, params), params.shape === "pill" && params.radius !== undefined && { path: "radius", message: "cannot be combined with shape pill" }, params.radius !== undefined && params.radius > height / 2 && { path: "radius", message: `must be at most half the height (${r2(height / 2)}), found ${r2(params.radius)}` });
|
|
72
|
+
});
|
|
73
|
+
/** A decision: a diamond in its bounds. */
|
|
74
|
+
export const decisionNode = definePiece("decisionNode", shapeSchema(true), (params) => {
|
|
75
|
+
const { x, y, width: w, height: h, text, subtitle, fill } = params;
|
|
76
|
+
const c = coloursOf(fill, params);
|
|
77
|
+
const shape = polygon([[x + w / 2, y], [x + w, y + h / 2], [x + w / 2, y + h], [x, y + h / 2]]);
|
|
78
|
+
return drawn(sticker(shape, c.face, params), centred(x + w / 2, y + h / 2, text, subtitle, c, params.textSize));
|
|
79
|
+
}, (params) => limits(roomOn(params.width, params.height, params)));
|
|
80
|
+
/** A store: a cylinder in its bounds, with the front of its lid. */
|
|
81
|
+
export const storeNode = definePiece("storeNode", { ...shapeSchema(true), ry: size() }, (params) => {
|
|
82
|
+
const { x, y, width: w, height: h, text, subtitle, fill, ry = 8 } = params;
|
|
83
|
+
const c = coloursOf(fill, params);
|
|
84
|
+
const { outline } = inkOf(params);
|
|
85
|
+
const rx = r2(w / 2);
|
|
86
|
+
const body = (dx, dy, attrs) => el("path", { d: `M${r2(x + dx)},${r2(y + ry + dy)} A${rx},${ry} 0 0 1 ${r2(x + w + dx)},${r2(y + ry + dy)} V${r2(y + h - ry + dy)} A${rx},${ry} 0 0 1 ${r2(x + dx)},${r2(y + h - ry + dy)} Z`, ...attrs });
|
|
87
|
+
const lid = el("path", { d: `M${r2(x)},${r2(y + ry)} A${rx},${ry} 0 0 0 ${r2(x + w)},${r2(y + ry)}`, fill: "none", stroke: ink, "stroke-width": r2(0.75 * outline) });
|
|
88
|
+
return drawn([...sticker(body, c.face, params), lid], centred(x + w / 2, y + (h + ry) / 2, text, subtitle, c, params.textSize));
|
|
89
|
+
}, (params) => {
|
|
90
|
+
const ry = params.ry ?? 8;
|
|
91
|
+
return limits(roomOn(params.width, params.height, params), ry > params.height / 4 && { path: "ry", message: `must be at most a quarter of the height (${r2(params.height / 4)}), found ${r2(ry)}` });
|
|
92
|
+
});
|
|
93
|
+
const RAIL = 10;
|
|
94
|
+
/** A queue: a box with a rail 10 in from each side. */
|
|
95
|
+
export const queueNode = definePiece("queueNode", shapeSchema(false), (params) => {
|
|
96
|
+
const { x, y, width: w, height: h = M.nodeHeight, text, subtitle, fill } = params;
|
|
97
|
+
const c = coloursOf(fill, params);
|
|
98
|
+
const { outline } = inkOf(params);
|
|
99
|
+
const rails = el("path", { d: `M${r2(x + RAIL)},${r2(y)} V${r2(y + h)} M${r2(x + w - RAIL)},${r2(y)} V${r2(y + h)}`, fill: "none", stroke: ink, "stroke-width": r2(0.75 * outline) });
|
|
100
|
+
return drawn([...sticker(rect(x, y, w, h, tokens.radius.small), c.face, params), rails], centred(x + w / 2, y + h / 2, text, subtitle, c, params.textSize));
|
|
101
|
+
}, (params) => {
|
|
102
|
+
const height = params.height ?? M.nodeHeight;
|
|
103
|
+
const least = Math.max(height, 2 * RAIL + 4 * inkOf(params).outline);
|
|
104
|
+
return limits(roomFor("height", height, params), params.width < least && { path: "width", message: `must be at least the height and leave room for the rails (${r2(least)}), found ${r2(params.width)}` });
|
|
105
|
+
});
|
|
106
|
+
/** An exchange: a hexagon with its points 0.26 × height in, as the motion shape (src/brand/flow-shapes.ts). */
|
|
107
|
+
export const exchangeNode = definePiece("exchangeNode", shapeSchema(false), (params) => {
|
|
108
|
+
const { x, y, width: w, height: h = M.nodeHeight, text, subtitle, fill } = params;
|
|
109
|
+
const c = coloursOf(fill, params);
|
|
110
|
+
const k = 0.26 * h;
|
|
111
|
+
const shape = polygon([[x + k, y], [x + w - k, y], [x + w, y + h / 2], [x + w - k, y + h], [x + k, y + h], [x, y + h / 2]]);
|
|
112
|
+
return drawn(sticker(shape, c.face, params), centred(x + w / 2, y + h / 2, text, subtitle, c, params.textSize));
|
|
113
|
+
}, (params) => {
|
|
114
|
+
const height = params.height ?? M.nodeHeight;
|
|
115
|
+
return limits(roomFor("height", height, params), params.width < height && { path: "width", message: `must be at least the height (${r2(height)}), found ${r2(params.width)}` });
|
|
116
|
+
});
|
|
117
|
+
// ---------- container ----------
|
|
118
|
+
const TAB = 24;
|
|
119
|
+
const tabWidth = (params) => params.tagWidth ?? 7.2 * characters(params.tag) + 24;
|
|
120
|
+
/** A container (a platform, a broker) with its name on a tab centred on the top edge. */
|
|
121
|
+
export const taggedContainer = definePiece("taggedContainer", { x: pos(), y: pos(), width: size(true), height: size(true), tag: label(), tagWidth: size(), tagFill: col(), fill: col(), ...inkSchema }, (params) => {
|
|
122
|
+
const { x, y, width, height, tag, tagFill = "color.transitional", fill } = params;
|
|
123
|
+
const tw = tabWidth(params);
|
|
124
|
+
const c = coloursOf(fill, params);
|
|
125
|
+
return drawn([...sticker(rect(x, y, width, height, M.nodeRadius), c.face, params), ...sticker(rect(x + 14, y - TAB / 2, tw, TAB, TAB / 2), colour(tagFill, "tagFill"), params)], [words(x + 14 + tw / 2, y + 4.5, tag, { fill: ink, "font-size": M.badgeTextSize, "font-weight": bold, "text-anchor": "middle" })]);
|
|
126
|
+
}, (params) => {
|
|
127
|
+
const tw = tabWidth(params);
|
|
128
|
+
const need = 4 * inkOf(params).outline;
|
|
129
|
+
return limits(roomOn(params.width, params.height, params), tw < TAB && { path: "tagWidth", message: `must be at least the tab's height (${TAB}), found ${r2(tw)}` }, TAB < need && { path: "inkScale", message: `leaves no room for the tab's outline: the tab is ${TAB} high and needs ${r2(need)}` }, params.width < tw + 28 && { path: "width", message: `leaves no room for the tag: needs at least ${r2(tw + 28)}` });
|
|
130
|
+
});
|
|
131
|
+
/** A participant's lifeline: dashed while active, sparse dots once nothing calls it. */
|
|
132
|
+
export const lifeline = definePiece("lifeline", { x: pos(), y1: pos(), y2: pos(), state: { kind: "enum", values: ["active", "inactive"] }, width: { kind: "number", min: 0, exclusiveMin: true, max: 12 }, surface: surfaceParam }, ({ x, y1, y2, state = "active", width = 1.75, surface = "light" }) => {
|
|
133
|
+
const active = state === "active";
|
|
134
|
+
// Width as given, like pipe's: a valid hairline must not round to zero.
|
|
135
|
+
return drawn([
|
|
136
|
+
el("line", { x1: r2(x), y1: r2(y1), x2: r2(x), y2: r2(y2), stroke: tokens.surface[surface].muted, "stroke-width": width, "stroke-dasharray": active ? "5 5" : "1 6", "stroke-linecap": active ? "butt" : "round" }),
|
|
137
|
+
]);
|
|
138
|
+
}, ({ y1, y2 }) => limits(y1 === y2 && { path: "y2", message: "must differ from y1" }));
|
|
139
|
+
/** A phase of a sequence: a pill across the diagram in its state's colour. */
|
|
140
|
+
export const phaseBand = definePiece("phaseBand", { x: pos(), y: pos(), width: size(true), height: size(), text: label(), fill: { kind: "color", required: true }, textSize, ...inkSchema }, (params) => {
|
|
141
|
+
const { x, y, width, height = 26, text, fill, textSize: size_ = M.labelSize } = params;
|
|
142
|
+
return drawn(sticker(rect(x, y, width, height, height / 2), colour(fill, "fill"), params), [
|
|
143
|
+
words(x + width / 2, y + height / 2 + (4.5 * size_) / 13, text, { fill: ink, "font-size": size_, "font-weight": bold, "text-anchor": "middle" }),
|
|
144
|
+
]);
|
|
145
|
+
}, (params) => {
|
|
146
|
+
const height = params.height ?? 26;
|
|
147
|
+
return limits(roomFor("height", height, params), params.width < height && { path: "width", message: `must be at least the height (${r2(height)}), found ${r2(params.width)}` });
|
|
148
|
+
});
|
|
149
|
+
/** An alt/else frame: the frame, a dashed divider per section, and a tag (with its condition) per part. */
|
|
150
|
+
export const altFrame = definePiece("altFrame", {
|
|
151
|
+
x: pos(),
|
|
152
|
+
y: pos(),
|
|
153
|
+
width: size(true),
|
|
154
|
+
height: size(true),
|
|
155
|
+
tag: label(),
|
|
156
|
+
condition: label(false),
|
|
157
|
+
tagWidth: size(),
|
|
158
|
+
conditionWidth: size(),
|
|
159
|
+
sections: {
|
|
160
|
+
kind: "array",
|
|
161
|
+
maxItems: 10,
|
|
162
|
+
of: { kind: "object", fields: { y: pos(), tag: label(), condition: label(false), tagWidth: size(), conditionWidth: size() } },
|
|
163
|
+
},
|
|
164
|
+
surface: surfaceParam,
|
|
165
|
+
}, ({ x, y, width, height, sections = [], surface = "light", ...first }) => {
|
|
166
|
+
const s = tokens.surface[surface];
|
|
167
|
+
const head = (top, { tag, condition, tagWidth, conditionWidth }) => {
|
|
168
|
+
const tw = tagWidth ?? 6.4 * characters(tag) + 14;
|
|
169
|
+
const body = [el("rect", { x: r2(x + 7), y: r2(top + 6), width: r2(tw), height: 17, rx: 4, fill: s.text, stroke: "none" })];
|
|
170
|
+
const text = [words(x + 7 + tw / 2, top + 18.5, tag, { fill: s.panel, "font-size": 10, "font-weight": tokens.font.weightHeavy, "text-anchor": "middle" })];
|
|
171
|
+
if (condition !== undefined) {
|
|
172
|
+
body.push(el("rect", { x: r2(x + 7 + tw + 2), y: r2(top + 7.5), width: r2(conditionWidth ?? 6.2 * characters(condition) + 8), height: 14, rx: 3, fill: s.background, stroke: "none" }));
|
|
173
|
+
text.push(words(x + 7 + tw + 6, top + 18.5, condition, { fill: s.text, "font-size": 11, "font-weight": bold, "text-anchor": "start" }));
|
|
174
|
+
}
|
|
175
|
+
return { body, text };
|
|
176
|
+
};
|
|
177
|
+
const heads = [head(y, first), ...sections.map((section) => head(section.y, section))];
|
|
178
|
+
return drawn([
|
|
179
|
+
el("rect", { x: r2(x), y: r2(y), width: r2(width), height: r2(height), rx: 7, fill: "none", stroke: s.text, "stroke-width": 1.75 }),
|
|
180
|
+
...sections.map((section) => el("line", { x1: r2(x), y1: r2(section.y), x2: r2(x + width), y2: r2(section.y), stroke: s.text, "stroke-width": 1.4, "stroke-dasharray": "4 4" })),
|
|
181
|
+
...heads.flatMap((part) => part.body),
|
|
182
|
+
], heads.flatMap((part) => part.text));
|
|
183
|
+
}, ({ y, height, sections = [] }) => limits(...sections.map((section, i) => (section.y <= y || section.y >= y + height) && { path: `sections[${i}].y`, message: "must lie inside the frame" })));
|
|
184
|
+
/** Words next to an arrow: lines down from the baseline (x, y), optionally on a backdrop that hides what is behind. */
|
|
185
|
+
export const edgeLabel = definePiece("edgeLabel", { x: pos(), y: pos(), lines: lines(4), anchor: { kind: "enum", values: ["start", "middle", "end"] }, backdrop: { kind: "boolean" }, width: size(), textSize, surface: surfaceParam }, ({ x, y, lines: rows, anchor = "middle", backdrop = false, width, textSize: size_ = 11, surface = "light" }) => {
|
|
186
|
+
const s = tokens.surface[surface];
|
|
187
|
+
const k = size_ / 11;
|
|
188
|
+
const body = backdrop
|
|
189
|
+
? rows.map((row, i) => {
|
|
190
|
+
const w = width ?? (6.2 * characters(row) + 8) * k;
|
|
191
|
+
const left = anchor === "start" ? x - 4 * k : anchor === "end" ? x - w + 4 * k : x - w / 2;
|
|
192
|
+
return el("rect", { x: r2(left), y: r2(y + (i * 13 - 10) * k), width: r2(w), height: r2(14 * k), rx: 3, fill: s.background, stroke: "none" });
|
|
193
|
+
})
|
|
194
|
+
: [];
|
|
195
|
+
return drawn(body, rows.map((row, i) => words(x, y + i * 13 * k, row, { fill: s.text, "font-size": size_, "font-weight": bold, "text-anchor": anchor })));
|
|
196
|
+
});
|
|
197
|
+
/** The body diagram pieces, in catalogue order (design §3). */
|
|
198
|
+
export const DIAGRAM_PIECES = { diagramBox, decisionNode, storeNode, queueNode, exchangeNode, taggedContainer, lifeline, phaseBand, altFrame, edgeLabel };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type Attrs, type SvgNode } from "./node.ts";
|
|
2
|
+
import type { ParamSpec, Problem, Schema } from "./params.ts";
|
|
3
|
+
export type Point = [number, number];
|
|
4
|
+
export declare const SURFACES: readonly ["light", "dark"];
|
|
5
|
+
export type SurfaceName = (typeof SURFACES)[number];
|
|
6
|
+
export type Ink = {
|
|
7
|
+
surface?: SurfaceName;
|
|
8
|
+
inkScale?: number;
|
|
9
|
+
};
|
|
10
|
+
export declare const surfaceParam: ParamSpec;
|
|
11
|
+
export declare const inkScaleParam: ParamSpec;
|
|
12
|
+
export declare const inkSchema: Schema;
|
|
13
|
+
/** Collects the limits between parameters (piece.ts, `relations`). */
|
|
14
|
+
export declare const limits: (...checks: (Problem | false)[]) => Problem[];
|
|
15
|
+
/** The surface's colours and the ink measures at a scale. */
|
|
16
|
+
export declare const inkOf: ({ surface, inkScale }: Ink) => {
|
|
17
|
+
s: {
|
|
18
|
+
readonly background: "#14215A";
|
|
19
|
+
readonly grid: "#1C2D74";
|
|
20
|
+
readonly muted: "#8F9BD0";
|
|
21
|
+
readonly panel: "#1C2D74";
|
|
22
|
+
readonly rim: "#EEF2FF";
|
|
23
|
+
readonly rimWidth: 1;
|
|
24
|
+
readonly shadow: "#4C5CB8";
|
|
25
|
+
readonly text: "#EEF2FF";
|
|
26
|
+
readonly zoneOpacity: 0.22;
|
|
27
|
+
} | {
|
|
28
|
+
readonly background: "#EEF2FF";
|
|
29
|
+
readonly grid: "#E0E7FB";
|
|
30
|
+
readonly muted: "#5B6694";
|
|
31
|
+
readonly panel: "#FFFFFF";
|
|
32
|
+
readonly rim: "#EEF2FF";
|
|
33
|
+
readonly rimWidth: 0;
|
|
34
|
+
readonly shadow: "#0A1033";
|
|
35
|
+
readonly text: "#0A1033";
|
|
36
|
+
readonly zoneOpacity: 0.12;
|
|
37
|
+
};
|
|
38
|
+
outline: number;
|
|
39
|
+
rim: number;
|
|
40
|
+
offset: number;
|
|
41
|
+
};
|
|
42
|
+
/** Outlined shapes need room for their ink: smallest dimension ≥ 4 × outline. */
|
|
43
|
+
export declare const roomFor: (name: string, smallest: number, inkParams: Ink) => Problem | false;
|
|
44
|
+
export declare const atLeast: (name: string, value: number, least: number, what: string) => Problem | false;
|
|
45
|
+
export type Shape = (dx: number, dy: number, attrs: Attrs) => SvgNode;
|
|
46
|
+
/** The ink sticker (metro-and-diagram-pieces design §3): shadow copy, rim on dark, then the face. */
|
|
47
|
+
export declare const sticker: (shape: Shape, face: string, inkParams: Ink) => SvgNode[];
|
|
48
|
+
export declare const words: (x: number, y: number, value: string, attrs: Attrs) => SvgNode;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Ink helpers shared by the metro, diagram and body diagram pieces (moved from
|
|
2
|
+
// metro.ts unchanged; change body-diagram-pieces, design §8). Internal: the
|
|
3
|
+
// package does not export this module.
|
|
4
|
+
import { tokens } from "../../tokens/generated/tokens.js";
|
|
5
|
+
import { el, r2 } from "./node.js";
|
|
6
|
+
export const SURFACES = ["light", "dark"];
|
|
7
|
+
const ink = tokens.color.ink;
|
|
8
|
+
const font = tokens.font.sans;
|
|
9
|
+
const M = tokens.map;
|
|
10
|
+
export const surfaceParam = { kind: "enum", values: SURFACES };
|
|
11
|
+
export const inkScaleParam = { kind: "number", min: 0.25, max: 8 };
|
|
12
|
+
export const inkSchema = { surface: surfaceParam, inkScale: inkScaleParam };
|
|
13
|
+
/** Collects the limits between parameters (piece.ts, `relations`). */
|
|
14
|
+
export const limits = (...checks) => checks.filter((check) => check !== false);
|
|
15
|
+
/** The surface's colours and the ink measures at a scale. */
|
|
16
|
+
export const inkOf = ({ surface = "light", inkScale = 1 }) => {
|
|
17
|
+
const s = tokens.surface[surface];
|
|
18
|
+
return { s, outline: M.outline * inkScale, rim: s.rimWidth * inkScale, offset: M.shadow * inkScale };
|
|
19
|
+
};
|
|
20
|
+
/** Outlined shapes need room for their ink: smallest dimension ≥ 4 × outline. */
|
|
21
|
+
export const roomFor = (name, smallest, inkParams) => {
|
|
22
|
+
const need = 4 * inkOf(inkParams).outline;
|
|
23
|
+
return smallest < need && { path: name, message: `leaves no room for the outline: needs at least ${r2(need)} at this ink scale` };
|
|
24
|
+
};
|
|
25
|
+
export const atLeast = (name, value, least, what) => value < least && { path: name, message: `must be at least ${what} (${r2(least)}), found ${r2(value)}` };
|
|
26
|
+
/** The ink sticker (metro-and-diagram-pieces design §3): shadow copy, rim on dark, then the face. */
|
|
27
|
+
export const sticker = (shape, face, inkParams) => {
|
|
28
|
+
const { s, outline, rim, offset } = inkOf(inkParams);
|
|
29
|
+
const wide = r2(outline + 2 * rim);
|
|
30
|
+
return [
|
|
31
|
+
// Joins are set here, so a sticker draws the same inside any group.
|
|
32
|
+
shape(offset, offset, { fill: s.shadow, stroke: s.shadow, "stroke-width": wide, "stroke-linejoin": "round" }),
|
|
33
|
+
...(s.rimWidth > 0 ? [shape(0, 0, { fill: s.rim, stroke: s.rim, "stroke-width": wide, "stroke-linejoin": "round" })] : []),
|
|
34
|
+
shape(0, 0, { fill: face, stroke: ink, "stroke-width": r2(outline), "stroke-linejoin": "round" }),
|
|
35
|
+
];
|
|
36
|
+
};
|
|
37
|
+
// Every text sets its own anchor, size and weight, so a surrounding group (a cover's label style) does not leak in.
|
|
38
|
+
export const words = (x, y, value, attrs) => el("text", { x: r2(x), y: r2(y), "font-family": font, ...attrs }, value);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export * from "./metro.ts";
|
|
2
|
+
export * from "./diagram.ts";
|
|
2
3
|
export * from "./ui-icons.ts";
|
|
3
4
|
export * from "./link-icons.ts";
|
|
4
5
|
type Point = [number, number];
|
|
@@ -275,6 +276,27 @@ export declare const PIECES: {
|
|
|
275
276
|
readonly suggestedHalo: import("./piece.ts").Piece<import("./metro.ts").SuggestedHaloParams>;
|
|
276
277
|
readonly licenceMark: import("./piece.ts").Piece<import("./metro.ts").LicenceMarkParams>;
|
|
277
278
|
readonly zone: import("./piece.ts").Piece<import("./metro.ts").ZoneParams>;
|
|
279
|
+
readonly diagramBox: import("./piece.ts").Piece<import("./diagram.ts").DiagramBoxParams>;
|
|
280
|
+
readonly decisionNode: import("./piece.ts").Piece<import("./ink.ts").Ink & {
|
|
281
|
+
x: number;
|
|
282
|
+
y: number;
|
|
283
|
+
width: number;
|
|
284
|
+
height?: number;
|
|
285
|
+
text: string[];
|
|
286
|
+
subtitle?: string;
|
|
287
|
+
fill?: string;
|
|
288
|
+
textSize?: number;
|
|
289
|
+
} & {
|
|
290
|
+
height: number;
|
|
291
|
+
}>;
|
|
292
|
+
readonly storeNode: import("./piece.ts").Piece<import("./diagram.ts").StoreNodeParams>;
|
|
293
|
+
readonly queueNode: import("./piece.ts").Piece<import("./diagram.ts").ShapeParams>;
|
|
294
|
+
readonly exchangeNode: import("./piece.ts").Piece<import("./diagram.ts").ShapeParams>;
|
|
295
|
+
readonly taggedContainer: import("./piece.ts").Piece<import("./diagram.ts").TaggedContainerParams>;
|
|
296
|
+
readonly lifeline: import("./piece.ts").Piece<import("./diagram.ts").LifelineParams>;
|
|
297
|
+
readonly phaseBand: import("./piece.ts").Piece<import("./diagram.ts").PhaseBandParams>;
|
|
298
|
+
readonly altFrame: import("./piece.ts").Piece<import("./diagram.ts").AltFrameParams>;
|
|
299
|
+
readonly edgeLabel: import("./piece.ts").Piece<import("./diagram.ts").EdgeLabelParams>;
|
|
278
300
|
readonly pill: import("./piece.ts").Piece<PillParams>;
|
|
279
301
|
readonly rings: import("./piece.ts").Piece<RingsParams>;
|
|
280
302
|
readonly sheet: import("./piece.ts").Piece<SheetParams>;
|
|
@@ -12,9 +12,11 @@ import { drawn, el, g, r2, shadowed } from "./node.js";
|
|
|
12
12
|
import { col, colour, count, label, MAX_SIZE, PATH_DATA, pos, size } from "./params.js";
|
|
13
13
|
import { definePiece } from "./piece.js";
|
|
14
14
|
import { METRO_PIECES } from "./metro.js";
|
|
15
|
+
import { DIAGRAM_PIECES } from "./diagram.js";
|
|
15
16
|
import { uiIcon } from "./ui-icons.js";
|
|
16
17
|
import { linkIcon } from "./link-icons.js";
|
|
17
18
|
export * from "./metro.js";
|
|
19
|
+
export * from "./diagram.js";
|
|
18
20
|
export * from "./ui-icons.js";
|
|
19
21
|
export * from "./link-icons.js";
|
|
20
22
|
const ink = tokens.color.ink;
|
|
@@ -381,5 +383,6 @@ export const PIECES = {
|
|
|
381
383
|
...METRO_PIECES,
|
|
382
384
|
uiIcon,
|
|
383
385
|
linkIcon,
|
|
386
|
+
...DIAGRAM_PIECES,
|
|
384
387
|
};
|
|
385
388
|
export const PIECE_NAMES = Object.keys(PIECES);
|
|
@@ -1,10 +1,5 @@
|
|
|
1
|
-
type
|
|
2
|
-
export
|
|
3
|
-
export type SurfaceName = (typeof SURFACES)[number];
|
|
4
|
-
export type Ink = {
|
|
5
|
-
surface?: SurfaceName;
|
|
6
|
-
inkScale?: number;
|
|
7
|
-
};
|
|
1
|
+
import { type Ink, type Point, type SurfaceName } from "./ink.ts";
|
|
2
|
+
export { SURFACES, type Ink, type SurfaceName } from "./ink.ts";
|
|
8
3
|
export type LineBadgeParams = Ink & {
|
|
9
4
|
x: number;
|
|
10
5
|
y: number;
|
|
@@ -60,16 +55,20 @@ export declare const nodeBlock: import("./piece.ts").Piece<NodeBlockParams>;
|
|
|
60
55
|
export declare const pillNode: import("./piece.ts").Piece<NodeParams>;
|
|
61
56
|
/** A diagram hexagon (an agent). */
|
|
62
57
|
export declare const hexNode: import("./piece.ts").Piece<NodeParams>;
|
|
58
|
+
export type ArrowKind = "message" | "reply" | "failure" | "failFast";
|
|
63
59
|
export type MessageArrowParams = {
|
|
64
60
|
from: Point;
|
|
65
61
|
to: Point;
|
|
66
62
|
curve?: "bezier" | "straight";
|
|
63
|
+
controls?: [Point, Point];
|
|
67
64
|
head?: boolean;
|
|
68
65
|
label?: string;
|
|
69
66
|
width?: number;
|
|
67
|
+
kind?: ArrowKind;
|
|
70
68
|
surface?: SurfaceName;
|
|
71
69
|
};
|
|
72
|
-
/** An edge with a head at `to`: curved (leaves and enters horizontally)
|
|
70
|
+
/** An edge with a head at `to`: curved (leaves and enters horizontally), straight, or through the caller's two
|
|
71
|
+
* control points, with an optional label. `kind` paints a reply, a failure or a fail-fast path (card 141). */
|
|
73
72
|
export declare const messageArrow: import("./piece.ts").Piece<MessageArrowParams>;
|
|
74
73
|
export type LedgerMarkParams = {
|
|
75
74
|
x: number;
|
|
@@ -129,4 +128,3 @@ export declare const METRO_PIECES: {
|
|
|
129
128
|
readonly licenceMark: import("./piece.ts").Piece<LicenceMarkParams>;
|
|
130
129
|
readonly zone: import("./piece.ts").Piece<ZoneParams>;
|
|
131
130
|
};
|
|
132
|
-
export {};
|
package/dist/src/pieces/metro.js
CHANGED
|
@@ -9,46 +9,18 @@
|
|
|
9
9
|
// cross-parameter limits (room for the outline, width against height, arrow
|
|
10
10
|
// length) are checked here and thrown as a RangeError naming the parameter.
|
|
11
11
|
import { tokens } from "../../tokens/generated/tokens.js";
|
|
12
|
+
import { atLeast, inkOf, inkSchema, limits, roomFor, sticker, surfaceParam, words } from "./ink.js";
|
|
12
13
|
import { drawn, el, g, r2 } from "./node.js";
|
|
13
14
|
import { col, colour, label, MAX_SIZE, PATH_DATA, paint, pos, size } from "./params.js";
|
|
14
15
|
import { definePiece } from "./piece.js";
|
|
15
|
-
export
|
|
16
|
+
export { SURFACES } from "./ink.js";
|
|
16
17
|
const ink = tokens.color.ink;
|
|
17
18
|
const white = tokens.color.white;
|
|
18
19
|
const font = tokens.font.sans;
|
|
19
20
|
const bold = tokens.font.weightBold;
|
|
20
21
|
const M = tokens.map;
|
|
21
|
-
const surfaceParam = { kind: "enum", values: SURFACES };
|
|
22
|
-
const inkScaleParam = { kind: "number", min: 0.25, max: 8 };
|
|
23
22
|
const dataColour = (required = true) => ({ kind: "dataColor", required });
|
|
24
23
|
const point = (required = true) => ({ kind: "point", required });
|
|
25
|
-
const inkSchema = { surface: surfaceParam, inkScale: inkScaleParam };
|
|
26
|
-
/** Collects the limits between parameters (piece.ts, `relations`). */
|
|
27
|
-
const limits = (...checks) => checks.filter((check) => check !== false);
|
|
28
|
-
/** The surface's colours and the ink measures at a scale. */
|
|
29
|
-
const inkOf = ({ surface = "light", inkScale = 1 }) => {
|
|
30
|
-
const s = tokens.surface[surface];
|
|
31
|
-
return { s, outline: M.outline * inkScale, rim: s.rimWidth * inkScale, offset: M.shadow * inkScale };
|
|
32
|
-
};
|
|
33
|
-
/** Outlined shapes need room for their ink: smallest dimension ≥ 4 × outline. */
|
|
34
|
-
const roomFor = (name, smallest, inkParams) => {
|
|
35
|
-
const need = 4 * inkOf(inkParams).outline;
|
|
36
|
-
return smallest < need && { path: name, message: `leaves no room for the outline: needs at least ${r2(need)} at this ink scale` };
|
|
37
|
-
};
|
|
38
|
-
const atLeast = (name, value, least, what) => value < least && { path: name, message: `must be at least ${what} (${r2(least)}), found ${r2(value)}` };
|
|
39
|
-
/** The ink sticker (design §3): shadow copy, rim on dark, then the face. */
|
|
40
|
-
const sticker = (shape, face, inkParams) => {
|
|
41
|
-
const { s, outline, rim, offset } = inkOf(inkParams);
|
|
42
|
-
const wide = r2(outline + 2 * rim);
|
|
43
|
-
return [
|
|
44
|
-
// Joins are set here, so a sticker draws the same inside any group.
|
|
45
|
-
shape(offset, offset, { fill: s.shadow, stroke: s.shadow, "stroke-width": wide, "stroke-linejoin": "round" }),
|
|
46
|
-
...(s.rimWidth > 0 ? [shape(0, 0, { fill: s.rim, stroke: s.rim, "stroke-width": wide, "stroke-linejoin": "round" })] : []),
|
|
47
|
-
shape(0, 0, { fill: face, stroke: ink, "stroke-width": r2(outline), "stroke-linejoin": "round" }),
|
|
48
|
-
];
|
|
49
|
-
};
|
|
50
|
-
// Every text sets its own anchor, size and weight, so a surrounding group (a cover's label style) does not leak in.
|
|
51
|
-
const words = (x, y, value, attrs) => el("text", { x: r2(x), y: r2(y), "font-family": font, ...attrs }, value);
|
|
52
24
|
const pill = (x, y, w, h) => (dx, dy, attrs) => el("rect", { x: r2(x + dx), y: r2(y + dy), width: r2(w), height: r2(h), rx: r2(h / 2), ...attrs });
|
|
53
25
|
// Without a text, the default width is the one-character width (card 167).
|
|
54
26
|
const badgeWidth = ({ width, text }) => width ?? Math.max(30, 7.6 * (text === undefined ? 1 : Array.from(text).length) + 18);
|
|
@@ -167,32 +139,47 @@ export const nodeBlock = definePiece("nodeBlock", { ...nodeSchema, radius: { kin
|
|
|
167
139
|
export const pillNode = definePiece("pillNode", nodeSchema, (p) => drawNode("pill", p), nodeLimits("pill"));
|
|
168
140
|
/** A diagram hexagon (an agent). */
|
|
169
141
|
export const hexNode = definePiece("hexNode", nodeSchema, (p) => drawNode("hex", p), nodeLimits("hex"));
|
|
170
|
-
/** An edge with a head at `to`: curved (leaves and enters horizontally)
|
|
142
|
+
/** An edge with a head at `to`: curved (leaves and enters horizontally), straight, or through the caller's two
|
|
143
|
+
* control points, with an optional label. `kind` paints a reply, a failure or a fail-fast path (card 141). */
|
|
171
144
|
export const messageArrow = definePiece("messageArrow", {
|
|
172
145
|
from: point(),
|
|
173
146
|
to: point(),
|
|
174
147
|
curve: { kind: "enum", values: ["bezier", "straight"] },
|
|
148
|
+
controls: { kind: "array", minItems: 2, maxItems: 2, of: point() },
|
|
175
149
|
head: { kind: "boolean" },
|
|
176
150
|
label: label(false),
|
|
177
151
|
width: { kind: "number", min: 1, max: 12 },
|
|
152
|
+
kind: { kind: "enum", values: ["message", "reply", "failure", "failFast"] },
|
|
178
153
|
surface: surfaceParam,
|
|
179
|
-
}, ({ from, to, curve = "bezier", head = true, label: text, width = M.edgeWidth, surface = "light" }) => {
|
|
154
|
+
}, ({ from, to, curve = "bezier", controls, head = true, label: text, width = M.edgeWidth, kind = "message", surface = "light" }) => {
|
|
180
155
|
const s = tokens.surface[surface];
|
|
156
|
+
const paint_ = kind === "failure" ? tokens.color.failure : kind === "failFast" ? tokens.color.failFast : s.text;
|
|
181
157
|
const length = 5 * width;
|
|
182
158
|
const distance = Math.hypot(to[0] - from[0], to[1] - from[1]);
|
|
183
159
|
const dx = to[0] - from[0];
|
|
184
160
|
// A curve whose ends are almost above each other has no horizontal entry: draw it straight.
|
|
185
|
-
const bent = curve === "bezier" && Math.abs(dx) > 2 * length;
|
|
186
|
-
|
|
161
|
+
const bent = controls === undefined && curve === "bezier" && Math.abs(dx) > 2 * length;
|
|
162
|
+
// With controls, the head points from the last of c2, c1, from that differs from `to` (design §5).
|
|
163
|
+
const towards = (q) => {
|
|
164
|
+
const l = Math.hypot(to[0] - q[0], to[1] - q[1]);
|
|
165
|
+
return [(to[0] - q[0]) / l, (to[1] - q[1]) / l];
|
|
166
|
+
};
|
|
167
|
+
const tail = controls && [controls[1], controls[0], from].find((q) => q[0] !== to[0] || q[1] !== to[1]);
|
|
168
|
+
const direction = tail !== undefined ? towards(tail) : bent ? [Math.sign(dx), 0] : [dx / distance, (to[1] - from[1]) / distance];
|
|
187
169
|
const end = head ? [to[0] - direction[0] * length, to[1] - direction[1] * length] : to;
|
|
188
170
|
const mx = (from[0] + to[0]) / 2;
|
|
189
171
|
const at = (p) => `${r2(p[0])},${r2(p[1])}`;
|
|
190
|
-
const d =
|
|
191
|
-
|
|
172
|
+
const d = controls
|
|
173
|
+
? `M${at(from)} C${at(controls[0])} ${at(controls[1])} ${at(end)}`
|
|
174
|
+
: bent
|
|
175
|
+
? `M${at(from)} C${r2(mx)},${r2(from[1])} ${r2(mx)},${r2(to[1])} ${at(end)}`
|
|
176
|
+
: `M${at(from)} L${at(end)}`;
|
|
177
|
+
const dash = kind === "message" ? {} : { "stroke-dasharray": `${r2(2.4 * width)} ${r2(3.2 * width)}` };
|
|
178
|
+
const body = [el("path", { d, fill: "none", stroke: paint_, "stroke-width": r2(width), "stroke-linecap": "round", ...dash })];
|
|
192
179
|
if (head) {
|
|
193
180
|
const normal = [-direction[1] * 2 * width, direction[0] * 2 * width];
|
|
194
181
|
const points = [to, [end[0] + normal[0], end[1] + normal[1]], [end[0] - normal[0], end[1] - normal[1]]];
|
|
195
|
-
body.push(el("polygon", { points: points.map(at).join(" "), fill:
|
|
182
|
+
body.push(el("polygon", { points: points.map(at).join(" "), fill: paint_, stroke: paint_, "stroke-width": 1, "stroke-linejoin": "round" }));
|
|
196
183
|
}
|
|
197
184
|
const words_ = [];
|
|
198
185
|
if (text !== undefined) {
|
|
@@ -202,9 +189,9 @@ export const messageArrow = definePiece("messageArrow", {
|
|
|
202
189
|
words_.push(words(middle[0], middle[1] + 4, text, { fill: s.text, "font-size": 11, "font-weight": 400, "text-anchor": "middle" }));
|
|
203
190
|
}
|
|
204
191
|
return drawn(body, words_);
|
|
205
|
-
}, ({ from, to, head = true, width = M.edgeWidth }) => {
|
|
192
|
+
}, ({ from, to, head = true, width = M.edgeWidth, controls, curve }) => {
|
|
206
193
|
const distance = Math.hypot(to[0] - from[0], to[1] - from[1]);
|
|
207
|
-
return limits(distance === 0 && { path: "to", message: "must differ from from" }, distance > 0 && head && distance < 10 * width && { path: "to", message: `is too close to from for the head: needs at least ${r2(10 * width)}, found ${r2(distance)}` });
|
|
194
|
+
return limits(distance === 0 && { path: "to", message: "must differ from from" }, distance > 0 && head && distance < 10 * width && { path: "to", message: `is too close to from for the head: needs at least ${r2(10 * width)}, found ${r2(distance)}` }, controls !== undefined && curve !== undefined && { path: "controls", message: "cannot be combined with curve" });
|
|
208
195
|
});
|
|
209
196
|
/** stackledger's mark (favicon, header): an ink square with a bent line and two stations, 32 units scaled to `size`.
|
|
210
197
|
* On a surface with a rim (dark), a light rim of 2 × rimWidth lines the square inside its 32 box (card 167). */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const SVG_TAGS: readonly ["svg", "g", "title", "desc", "defs", "clipPath", "rect", "circle", "ellipse", "line", "polyline", "polygon", "path", "text"];
|
|
1
|
+
export declare const SVG_TAGS: readonly ["svg", "g", "title", "desc", "defs", "clipPath", "rect", "circle", "ellipse", "line", "polyline", "polygon", "path", "text", "tspan"];
|
|
2
2
|
export type SvgTag = (typeof SVG_TAGS)[number];
|
|
3
3
|
export type AttrValue = string | number;
|
|
4
4
|
export type Attrs = Readonly<Record<string, AttrValue>>;
|
package/dist/src/pieces/node.js
CHANGED
|
@@ -89,7 +89,8 @@ const checkValue = (spec, value, path, out) => {
|
|
|
89
89
|
return;
|
|
90
90
|
case "point":
|
|
91
91
|
// Any finite number: a map position has no maximum (spec illustration-pieces, "Positions of any size").
|
|
92
|
-
|
|
92
|
+
// Both indices read directly: every() skips the holes of a sparse array.
|
|
93
|
+
if (!Array.isArray(value) || value.length !== 2 || ![value[0], value[1]].every((n) => typeof n === "number" && Number.isFinite(n))) {
|
|
93
94
|
return report(`expected a point [x, y] of finite numbers, found ${describe(value)}`);
|
|
94
95
|
}
|
|
95
96
|
return;
|
|
@@ -100,7 +101,9 @@ const checkValue = (spec, value, path, out) => {
|
|
|
100
101
|
return report(`expected at least ${spec.minItems} item(s)`);
|
|
101
102
|
if (value.length > (spec.maxItems ?? MAX_ITEMS))
|
|
102
103
|
return report(`expected at most ${spec.maxItems ?? MAX_ITEMS} item(s)`);
|
|
103
|
-
|
|
104
|
+
// An index loop, not forEach: a hole in a sparse array is checked as a missing item, not skipped.
|
|
105
|
+
for (let index = 0; index < value.length; index++)
|
|
106
|
+
checkValue(spec.of, value[index], `${path}[${index}]`, out);
|
|
104
107
|
return;
|
|
105
108
|
case "object":
|
|
106
109
|
if (!isPlainObject(value))
|
|
@@ -10,7 +10,8 @@ export const toSvg = (node, depth = 0) => {
|
|
|
10
10
|
if (node.children.length === 0)
|
|
11
11
|
return `${open}/>`;
|
|
12
12
|
// Text content, alone or mixed with elements, is written inline: added whitespace would change it.
|
|
13
|
-
|
|
13
|
+
// A text node is always written inline, so one holding only a tspan keeps its words intact (body-diagram-pieces §4).
|
|
14
|
+
if (node.tag === "text" || node.children.some((child) => typeof child === "string")) {
|
|
14
15
|
return `${open}>${node.children.map((child) => toSvg(child)).join("")}</${node.tag}>`;
|
|
15
16
|
}
|
|
16
17
|
if (node.children.every((child) => typeof child === "string")) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brunosouzas/design-system",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Design tokens and illustration pieces of brunosouzas.com: tokens as CSS, JSON and TypeScript, pieces that draw SVG (and React), and a cover command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|