@cardenelabs/dragon 0.36.4 → 0.37.0

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.
@@ -0,0 +1,387 @@
1
+ /**
2
+ * 状態遷移図の置き場所 (#3163)。
3
+ *
4
+ * 状態は段で初めて辿り着く順に `LOOK.state.columns` つずつ並べ、段ごとに読む向きを替えて折り返す (#3171)。
5
+ * 1 段目は左から右へ、2 段目は右から左へ、3 段目は再び左から右へ読み、どの段の状態も 1 段目の同じ列に揃える。
6
+ * 段の最後の状態の真下に次の段の最初の状態が来るので、段をまたぐ線は真下への 1 本になる。
7
+ * 始まりの印は最初の状態の上に、終わりの印は終わりへ移る状態の下に置く。
8
+ *
9
+ * どの段も辿り着かない状態は点線の札にして、1 段目の上の脇道の囲みへ寄せる。札は絵柄を左に、名前を右に並べる (#3171)。
10
+ * 囲みは脇道へ入る状態のうち最も左の状態の列から図の右端までを占め、始まりの印が囲みの左に収まる時は、
11
+ * 始まりの印を囲みと同じ高さに置く。最初の段より前に書いた線のうち、どの段にも出ない線は通らない道として点線で引き、
12
+ * 脇道の状態から出る線と終わりの印も囲みの中に置く。脇道へ入る線は、1 段目の上端と囲みの間を横に通り、点線の札の下端へ並べて入る。
13
+ */
14
+ import { FIGURE_TEXT } from "../figure-text";
15
+ import { LOOK, TYPE } from "../look/values";
16
+ import type { Carry, Diagram, StatePath } from "../schema";
17
+ import { type NotationLang, STATE_ENDS } from "../words";
18
+ import { hasWide, textWidth } from "./measure";
19
+ import {
20
+ badgeOnPath,
21
+ boxColors,
22
+ boxWidth,
23
+ cornerBadge,
24
+ edgeColor,
25
+ edgesOf,
26
+ type EdgeEvent,
27
+ labelStyle,
28
+ type Layout,
29
+ layoutStrip,
30
+ type Point,
31
+ placeLabel,
32
+ type Rect,
33
+ type SceneBox,
34
+ type SceneEdge,
35
+ type SceneEnd,
36
+ type SceneLabel,
37
+ type ScenePath,
38
+ type SceneSide,
39
+ shiftContent,
40
+ stripNeed,
41
+ } from "./scene";
42
+
43
+ const isEnd = (id: string): boolean => (STATE_ENDS as readonly string[]).includes(id);
44
+
45
+ /** 段で初めて辿り着く状態の順。線の送る側と受ける側を、段の順に見て初めて出た順に並べる */
46
+ export function visitOrder(diagram: Diagram): string[] {
47
+ const order: string[] = [];
48
+ const add = (id: string): void => {
49
+ if (!isEnd(id) && !order.includes(id)) order.push(id);
50
+ };
51
+ for (const { edge } of edgesOf(diagram)) {
52
+ add(edge.from);
53
+ add(edge.to);
54
+ }
55
+ return order;
56
+ }
57
+
58
+ /** 運ぶ物の幅の半分 */
59
+ function carrierHalf(carry: Carry): number {
60
+ const C = LOOK.carrier;
61
+ if (carry.kind === "tag") return (textWidth(carry.text, hasWide(carry.text) ? TYPE.carrierJa : TYPE.carrier) + C.tagPadX * 2) / 2;
62
+ if (carry.kind === "coin") return C.coinR;
63
+ return C.envW / 2;
64
+ }
65
+
66
+ /** 線の両端が同じ 2 つの線か */
67
+ const same = (a: { from: string; to: string }, b: { from: string; to: string }): boolean => a.from === b.from && a.to === b.to;
68
+
69
+ export function layoutState(diagram: Diagram, lang: NotationLang): Layout {
70
+ const { box: B, state: T, margin, gap } = LOOK;
71
+ const inset = LOOK.edge.inset;
72
+ const words = FIGURE_TEXT[lang];
73
+ const colors = boxColors(diagram);
74
+ const steps = edgesOf(diagram);
75
+ const walked = visitOrder(diagram);
76
+ const ghosts = diagram.boxes.map((b) => b.id).filter((id) => !walked.includes(id));
77
+ const notTaken: StatePath[] = (diagram.state?.paths ?? []).filter((p) => !steps.some(({ edge }) => same(edge, p)));
78
+ const lines: { from: string; to: string; label?: string }[] = [...steps.map(({ edge }) => edge), ...notTaken];
79
+ const G = T.ghost;
80
+ // 点線の札は絵柄の右に名前と下の字を並べるので、その幅を測る
81
+ const ghostWidth = (id: string): number => {
82
+ const source = diagram.boxes.find((b) => b.id === id);
83
+ return G.pad * 2 + B.icon + G.gap + Math.max(textWidth(source?.name ?? id, TYPE.boxName), textWidth(source?.sub ?? "", TYPE.boxSub));
84
+ };
85
+ const widths = new Map(diagram.boxes.map((b) => [b.id, ghosts.includes(b.id) ? ghostWidth(b.id) : boxWidth(diagram, b.id, true)]));
86
+ const columnCount = Math.max(1, Math.min(T.columns, walked.length));
87
+ const rowCount = Math.max(1, Math.ceil(walked.length / T.columns));
88
+ // 奇数番目の段は右から左へ並べ、段の最後の状態の真下から次の段を始める
89
+ const slot = new Map(
90
+ walked.map((id, k) => {
91
+ const row = Math.floor(k / T.columns);
92
+ const at = k % T.columns;
93
+ return [id, { row, col: row % 2 === 0 ? at : columnCount - 1 - at }];
94
+ }),
95
+ );
96
+
97
+ // 列の幅と列の間。隣の列を横に結ぶ名前のある線は、名前が収まるまで間を広げる
98
+ const colW = Array.from({ length: columnCount }, (_, c) => Math.max(B.minW, ...walked.filter((id) => slot.get(id)?.col === c).map((id) => widths.get(id) ?? B.minW)));
99
+ const between = Array.from({ length: Math.max(0, columnCount - 1) }, () => T.columnGap as number);
100
+ for (const line of lines) {
101
+ const a = slot.get(line.from);
102
+ const b = slot.get(line.to);
103
+ if (!a || !b || line.label === undefined || a.row !== b.row || Math.abs(a.col - b.col) !== 1) continue;
104
+ const i = Math.min(a.col, b.col);
105
+ between[i] = Math.max(between[i] ?? 0, textWidth(line.label, labelStyle(line.label)) + LOOK.label.padX * 2 + T.labelRoom * 2);
106
+ }
107
+ const colLeft: number[] = [];
108
+ for (let c = 0; c < columnCount; c++) colLeft.push(c === 0 ? margin : (colLeft[c - 1] as number) + (colW[c - 1] as number) + (between[c - 1] as number));
109
+ const center = (c: number): number => (colLeft[c] as number) + (colW[c] as number) / 2;
110
+
111
+ // 脇道の囲みの高さ。囲みの名前と 2 行の添え書きと、点線の札が並ぶ
112
+ const isGhost = (id: string): boolean => ghosts.includes(id);
113
+ const enteringFrom = new Set(notTaken.filter((p) => isGhost(p.to) && !isGhost(p.from) && !isEnd(p.from)).map((p) => p.from));
114
+ const notes = [words.sideNote, ...(enteringFrom.size > 0 ? [words.sideFrom(enteringFrom.size)] : [])];
115
+ const titleW = Math.max(textWidth(words.side, TYPE.regionName), ...notes.map((note) => textWidth(note, TYPE.regionNote)));
116
+ const sideH = T.side.pad * 2 + Math.max(G.h, TYPE.regionName.size + LOOK.region.line * notes.length + T.side.pad / 4);
117
+ // 1 段目の上には、脇道の囲みか、始まりの印から最初の状態へ下りる間を空ける。どちらも無ければ空けない
118
+ const starts = lines.some((line) => line.from === "start");
119
+ const top0 = margin + (ghosts.length > 0 ? sideH + T.side.gap : starts ? T.start + T.drop : 0);
120
+ const rowY = (row: number): number => top0 + row * (B.h + T.rowGap);
121
+
122
+ const boxOf = (id: string, cx: number, y: number, ghost: boolean): SceneBox => {
123
+ const source = diagram.boxes.find((b) => b.id === id);
124
+ const w = widths.get(id) ?? B.minW;
125
+ const h = ghost ? G.h : B.h;
126
+ return {
127
+ id,
128
+ name: source?.name ?? id,
129
+ ...(source?.sub !== undefined ? { sub: source.sub } : {}),
130
+ ...(source?.icon !== undefined ? { icon: source.icon } : {}),
131
+ color: colors.get(id) ?? "slate",
132
+ x: cx - w / 2,
133
+ y,
134
+ w,
135
+ h,
136
+ cx,
137
+ cy: y + h / 2,
138
+ ...(ghost ? { ghost: true } : {}),
139
+ };
140
+ };
141
+ const boxes: SceneBox[] = walked.map((id) => {
142
+ const s = slot.get(id) as { row: number; col: number };
143
+ return boxOf(id, center(s.col), rowY(s.row), false);
144
+ });
145
+ const byId = new Map(boxes.map((b) => [b.id, b]));
146
+ const below = (box: SceneBox): boolean => boxes.some((other) => other !== box && Math.abs(other.cx - box.cx) < 0.5 && other.y > box.y);
147
+
148
+ const ends: SceneEnd[] = [];
149
+ const first = boxes[0];
150
+ const contentRight = Math.max(...boxes.map((b) => b.x + b.w));
151
+
152
+ // 脇道の囲み。囲みの左端は脇道へ入る状態のうち最も左の箱に揃え、右端は図の右端まで伸ばす。点線の札は入ってくる状態の真ん中に置く
153
+ let side: SceneSide | undefined;
154
+ const lane = margin + sideH + T.side.lane;
155
+ if (ghosts.length > 0) {
156
+ const sourcesOf = (id: string): SceneBox[] => notTaken.filter((p) => p.to === id).flatMap((p) => byId.get(p.from) ?? []);
157
+ const all = ghosts.flatMap(sourcesOf);
158
+ const x = Math.max(margin, Math.min(...(all.length > 0 ? all.map((b) => b.x) : [margin + T.side.pad])) - T.side.pad);
159
+ let cursor = x + T.side.pad + titleW + gap * 2;
160
+ const wanted = ghosts.map((id) => {
161
+ const from = sourcesOf(id);
162
+ return { id, cx: from.length > 0 ? from.reduce((sum, b) => sum + b.cx, 0) / from.length : cursor };
163
+ });
164
+ wanted.sort((a, b) => a.cx - b.cx);
165
+ let right = x;
166
+ for (const { id, cx } of wanted) {
167
+ const w = widths.get(id) ?? B.minW;
168
+ const left = Math.max(cx - w / 2, cursor);
169
+ const ghost = boxOf(id, left + w / 2, margin + (sideH - G.h) / 2, true);
170
+ boxes.push(ghost);
171
+ byId.set(id, ghost);
172
+ const finishes = notTaken.some((p) => p.from === id && p.to === "finish");
173
+ if (finishes) ends.push({ kind: "finish", x: ghost.x + ghost.w + T.beside, y: ghost.cy, r: T.end.r, ghost: true, text: "", box: id });
174
+ right = finishes ? ghost.x + ghost.w + T.beside + T.end.r : ghost.x + ghost.w;
175
+ cursor = right + gap * 2;
176
+ }
177
+ const rightmost = Math.max(right + T.side.pad, contentRight);
178
+ side = { x, y: margin, w: rightmost - x, h: sideH, title: words.side, notes };
179
+ }
180
+
181
+ // 始まりの印は最初の状態の上。脇道の囲みの左に字まで収まる時は、囲みと同じ高さに置く
182
+ if (first && starts) {
183
+ const reach = first.cx + T.start + T.text + textWidth(words.start, TYPE.regionNote) + gap;
184
+ const y = side && reach < side.x ? side.y + T.side.pad + T.start : first.y - T.drop;
185
+ ends.unshift({ kind: "start", x: first.cx, y, r: T.start, ghost: false, text: words.start });
186
+ }
187
+
188
+ // 終わりの印は終わりへ移る状態の下。下に状態がある時は右に置く
189
+ for (const box of boxes.filter((b) => !b.ghost && lines.some((line) => line.from === b.id && line.to === "finish"))) {
190
+ ends.push(
191
+ below(box)
192
+ ? { kind: "finish", x: box.x + box.w + T.beside, y: box.cy, r: T.end.r, ghost: false, text: words.end, box: box.id }
193
+ : { kind: "finish", x: box.cx, y: box.y + box.h + T.below, r: T.end.r, ghost: false, text: words.end, box: box.id },
194
+ );
195
+ }
196
+
197
+ /** 状態どうしを結ぶ線。同じ段の隣は横にまっすぐ、段をまたぐ線は段の間の真ん中を通る */
198
+ const between2 = (a: SceneBox, b: SceneBox): Point[] => {
199
+ const sa = slot.get(a.id);
200
+ const sb = slot.get(b.id);
201
+ if (!sa || !sb) return [[a.cx, a.y + a.h], [b.cx, b.y - inset]];
202
+ if (sa.row === sb.row && Math.abs(sa.col - sb.col) === 1) {
203
+ return b.cx > a.cx
204
+ ? [[a.x + a.w, a.cy], [b.x - inset, b.cy]]
205
+ : [[a.x, a.cy], [b.x + b.w + inset, b.cy]];
206
+ }
207
+ if (sa.row === sb.row) {
208
+ const y = a.y + a.h + T.rowGap / 4;
209
+ return [[a.cx, a.y + a.h], [a.cx, y], [b.cx, y], [b.cx, b.y + b.h + inset]];
210
+ }
211
+ if (sb.row > sa.row) {
212
+ if (Math.abs(a.cx - b.cx) < 0.5) return [[a.cx, a.y + a.h], [b.cx, b.y - inset]];
213
+ const y = a.y + a.h + T.rowGap / 2;
214
+ return [[a.cx, a.y + a.h], [a.cx, y], [b.cx, y], [b.cx, b.y - inset]];
215
+ }
216
+ if (Math.abs(a.cx - b.cx) < 0.5) return [[a.cx, a.y], [b.cx, b.y + b.h + inset]];
217
+ const y = b.y + b.h + T.rowGap / 2;
218
+ return [[a.cx, a.y], [a.cx, y], [b.cx, y], [b.cx, b.y + b.h + inset]];
219
+ };
220
+
221
+ /** 点線の箱へ入る所。同じ箱へ入る線を、入ってくる状態の左から順に箱の下端へ並べる */
222
+ const entryOf = (from: string, to: SceneBox): number => {
223
+ const sources = notTaken.filter((p) => p.to === to.id).flatMap((p) => byId.get(p.from) ?? []).sort((a, b) => a.cx - b.cx);
224
+ const k = sources.findIndex((b) => b.id === from);
225
+ if (sources.length <= 1) return Math.min(Math.max(sources[0]?.cx ?? to.cx, to.x + T.side.entry), to.x + to.w - T.side.entry);
226
+ return to.x + T.side.entry + ((to.w - T.side.entry * 2) * Math.max(0, k)) / (sources.length - 1);
227
+ };
228
+ /** 状態が線を出す所の横の位置。最初の状態は上端の真ん中に始まりからの線が入るので、脇道へは少し右から出る */
229
+ const exitX = (box: SceneBox): number => (starts && box === first ? box.cx + T.side.entry : box.cx);
230
+
231
+ const finishOf = (id: string): SceneEnd | undefined => ends.find((e) => e.kind === "finish" && e.box === id);
232
+ /** 同じ状態へ戻る線。箱の下端の右から出て、下へ膨らむ弧を描いて左から箱へ戻る。運ぶ物が弧の上を走れるよう、点で区切る */
233
+ const loopOf = (box: SceneBox): Point[] => {
234
+ const { w, h, samples } = T.loop;
235
+ const bottom = box.y + box.h;
236
+ const p0: Point = [box.cx + w / 2, bottom];
237
+ const c1: Point = [box.cx + (w * 3) / 4, bottom + (h * 4) / 3];
238
+ const c2: Point = [box.cx - (w * 3) / 4, bottom + (h * 4) / 3];
239
+ const p3: Point = [box.cx - w / 2, bottom + inset];
240
+ return Array.from({ length: samples + 1 }, (_, k): Point => {
241
+ const t = k / samples;
242
+ const u = 1 - t;
243
+ const at = (i: 0 | 1): number => u * u * u * p0[i] + 3 * u * u * t * c1[i] + 3 * u * t * t * c2[i] + t * t * t * p3[i];
244
+ return [at(0), at(1)];
245
+ });
246
+ };
247
+ const route = (from: string, to: string): Point[] => {
248
+ if (from === "start") {
249
+ const start = ends.find((e) => e.kind === "start");
250
+ const target = byId.get(to);
251
+ if (!start || !target) return [];
252
+ return [[start.x, start.y + start.r], [target.cx, target.y - inset]];
253
+ }
254
+ const a = byId.get(from);
255
+ if (!a) return [];
256
+ if (to === "finish") {
257
+ const end = finishOf(from);
258
+ if (!end) return [];
259
+ return Math.abs(end.x - a.cx) < 0.5 ? [[a.cx, a.y + a.h], [a.cx, end.y - end.r - inset]] : [[a.x + a.w, a.cy], [end.x - end.r - inset, a.cy]];
260
+ }
261
+ const b = byId.get(to);
262
+ if (!b) return [];
263
+ if (from === to) return loopOf(a);
264
+ if (b.ghost && !a.ghost) {
265
+ const sx = exitX(a);
266
+ const ex = entryOf(from, b);
267
+ return Math.abs(sx - ex) < 0.5 ? [[sx, a.y], [ex, b.y + b.h + inset]] : [[sx, a.y], [sx, lane], [ex, lane], [ex, b.y + b.h + inset]];
268
+ }
269
+ if (a.ghost && b.ghost) return [[a.x + a.w, a.cy], [b.x - inset, b.cy]];
270
+ if (a.ghost) return [[a.cx, a.y + a.h], [a.cx, lane], [b.cx, lane], [b.cx, b.y - inset]];
271
+ return between2(a, b);
272
+ };
273
+
274
+ /**
275
+ * 運ぶ物が着いた後に留まる所。状態へ着いた札はその状態の印に乗り (同じ状態へ戻る輪も、回った後に印へ戻る)、
276
+ * 終わりへ着いた札は終わりの印と字に掛からないよう、下に置いた印なら左、右に置いた印なら下に留まる
277
+ */
278
+ const settleOf = (edge: EdgeEvent): Point | undefined => {
279
+ const target = byId.get(edge.to);
280
+ if (target) return [target.cx, target.cy + B.iconY];
281
+ const end = edge.to === "finish" ? finishOf(edge.from) : undefined;
282
+ if (!end) return undefined;
283
+ const from = byId.get(edge.from);
284
+ return from && Math.abs(end.x - from.cx) < 0.5 ? [end.x - end.r - gap - carrierHalf(edge.carry), end.y] : [end.x, end.y + end.r + gap + LOOK.carrier.tagH / 2];
285
+ };
286
+
287
+ const sceneEdges: SceneEdge[] = steps.map(({ step, event, edge }) => {
288
+ const settle = settleOf(edge);
289
+ return {
290
+ step,
291
+ event,
292
+ from: edge.from,
293
+ to: edge.to,
294
+ points: route(edge.from, edge.to),
295
+ color: colorOf(edge, colors),
296
+ carry: edge.carry,
297
+ ...(settle ? { settle } : {}),
298
+ };
299
+ });
300
+ // 同じ 2 つの状態を行きと帰りで結ぶまっすぐな線は、少しずらして重ならないようにする
301
+ for (const e of sceneEdges) {
302
+ if (e.points.length !== 2 || !sceneEdges.some((o) => o.from === e.to && o.to === e.from && o.points.length === 2)) continue;
303
+ const [p, q] = e.points as [Point, Point];
304
+ const flat = Math.abs(q[1] - p[1]) < Math.abs(q[0] - p[0]);
305
+ const d = (e.from < e.to ? 1 : -1) * LOOK.edge.parallel;
306
+ e.points = e.points.map(([x, y]) => (flat ? [x, y + d] : [x + d, y]));
307
+ }
308
+
309
+ const paths: ScenePath[] = notTaken.map((p) => ({ from: p.from, to: p.to, points: route(p.from, p.to) }));
310
+
311
+ // 線の名前。同じ状態へ戻る輪は輪の下に置き、終わりへ移る線は印の字と反対の左に置く
312
+ const labels: SceneLabel[] = [];
313
+ const soft: Rect[] = side ? [{ x: side.x, y: side.y, w: T.side.pad + titleW, h: side.h }] : [];
314
+ for (const e of sceneEdges) {
315
+ const source = diagram.steps[e.step]?.events[e.event];
316
+ if (source?.type !== "edge" || source.label === undefined) continue;
317
+ const style = labelStyle(source.label);
318
+ if (e.from === e.to) {
319
+ const w = textWidth(source.label, style) + LOOK.label.padX * 2;
320
+ const bottom = Math.max(...e.points.map(([, y]) => y));
321
+ labels.push({ step: e.step, event: e.event, text: source.label, family: style.family, color: e.color, x: (e.points[0]?.[0] ?? 0) - T.loop.w / 2 - w / 2, y: bottom + LOOK.label.soft, w, h: LOOK.label.h });
322
+ continue;
323
+ }
324
+ const side2 = source.labelSide ?? (e.to === "finish" ? "left" : undefined);
325
+ labels.push(
326
+ placeLabel(
327
+ { step: e.step, event: e.event, text: source.label, points: e.points, color: e.color, ...(side2 ? { side: side2 } : {}) },
328
+ { hard: boxes, soft: [...soft, ...labels.filter((l) => l.step === e.step)] },
329
+ ),
330
+ );
331
+ }
332
+ // 脇道へ入る線の名前は、横に通る所の下の、状態から上へ出る所の右に置く
333
+ notTaken.forEach((p, i) => {
334
+ const path = paths[i] as ScenePath;
335
+ if (p.label === undefined || path.points.length === 0) return;
336
+ const style = labelStyle(p.label);
337
+ const into = byId.get(p.to)?.ghost === true && byId.get(p.from)?.ghost !== true;
338
+ const rect: Rect = into
339
+ ? { x: (path.points[0] as Point)[0] + LOOK.label.soft, y: lane + LOOK.label.soft, w: textWidth(p.label, style) + LOOK.label.padX * 2, h: LOOK.label.h }
340
+ : placeLabel({ step: -1, event: i, text: p.label, points: path.points, color: "slate" }, { hard: boxes, soft });
341
+ path.label = { x: rect.x, y: rect.y, w: rect.w, h: rect.h, text: p.label, family: style.family };
342
+ });
343
+
344
+ const layout: Layout = { kind: "state", width: 0, height: 0, regions: [], boxes, lifelines: [], edges: sceneEdges, labels, actions: [], badges: [], paths, ends, ...(side ? { side } : {}) };
345
+ // 名前が左へ出たら、全体を右へ寄せる
346
+ const lefts = [...boxes.map((b) => b.x), ...labels.map((l) => l.x), ...paths.flatMap((p) => (p.label ? [p.label.x] : []))];
347
+ const shift = Math.max(0, margin - Math.min(...lefts));
348
+ if (shift > 0) shiftContent(layout, shift, 0);
349
+ const endRight = (e: SceneEnd): number => e.x + (e.text === "" ? e.r : T.text + textWidth(e.text, TYPE.regionNote));
350
+ const rights = [
351
+ ...boxes.map((b) => b.x + b.w),
352
+ ...labels.map((l) => l.x + l.w),
353
+ ...ends.map(endRight),
354
+ ...sceneEdges.flatMap((e) => e.points.map((p) => p[0])),
355
+ ...paths.flatMap((p) => [...p.points.map((q) => q[0]), ...(p.label ? [p.label.x + p.label.w] : [])]),
356
+ ...(side ? [side.x + side.w] : []),
357
+ ];
358
+ const bottoms = [...boxes.map((b) => b.y + b.h), ...labels.map((l) => l.y + l.h), ...ends.map((e) => e.y + e.r), ...sceneEdges.flatMap((e) => e.points.map((p) => p[1]))];
359
+ // 帯の最後に、段が辿り着く順に状態を点で並べる
360
+ const trail = { title: words.trail, states: walked.map((id) => ({ box: id, color: colors.get(id) ?? "slate" })) };
361
+ const width = Math.max(Math.max(...rights) + margin, stripNeed(diagram, trail) + margin * 2);
362
+ const bottom = Math.max(...bottoms, rowY(rowCount - 1) + B.h);
363
+ const strip = layoutStrip(diagram, margin, bottom + gap, width - margin * 2, trail);
364
+ layout.width = width;
365
+ layout.height = (strip ? strip.y + strip.h : bottom) + margin;
366
+ if (strip) layout.strip = strip;
367
+ layout.badges = diagram.steps.map((step, s) => {
368
+ const edge = sceneEdges.find((e) => e.step === s && e.points.length > 0);
369
+ if (!edge) return cornerBadge(step.events, s, layout);
370
+ // 同じ状態へ戻る弧の番号は、弧に重ねず弧の右に置く
371
+ if (edge.from === edge.to) {
372
+ const right = Math.max(...edge.points.map(([px]) => px));
373
+ const top = (edge.points[0] as Point)[1];
374
+ const deep = Math.max(...edge.points.map(([, py]) => py));
375
+ return { step: s, x: right + LOOK.badge.r + LOOK.badge.inset, y: top + (deep - top) * T.loop.badgeAt, color: edge.color };
376
+ }
377
+ const [x, y] = badgeOnPath(edge.points);
378
+ return { step: s, x, y, color: edge.color };
379
+ });
380
+ return layout;
381
+ }
382
+
383
+ /** 線の色。移った先の状態の色で、終わりへ移る線は終わる状態の色。硬貨を運ぶ線は構成図と同じく硬貨の色 (#3171) */
384
+ function colorOf(edge: EdgeEvent, colors: Map<string, ReturnType<typeof edgeColor>>): ReturnType<typeof edgeColor> {
385
+ if (edge.carry.kind === "coin") return edgeColor(edge, colors);
386
+ return colors.get(edge.to === "finish" ? edge.from : edge.to) ?? "slate";
387
+ }
@@ -88,13 +88,46 @@ export const LOOK = {
88
88
  region: { pad: 18, gap: 10, radius: 14, inset: 14, icon: 24, iconRadius: 7, iconScale: 0.68, nameY: 58, line: 16, minH: 108 },
89
89
  architecture: { columns: 4, columnGap: 56, maxColumnGap: 160, rowGap: 44, labelRoom: 16 },
90
90
  sequence: { columnGap: 40, labelRoom: 16, firstRow: 22, rowGap: 14, stepGap: 8, minPitch: 40, tail: 18, selfW: 34, selfH: 18 },
91
- edge: { inset: 3, arrow: 6, arrowHalf: 3.5, parallel: 5, ghostDash: 5, flowDash: 9 },
91
+ /**
92
+ * 状態遷移図 (#3163)。状態は `columns` つずつ並べて次の段へ折り返し、段の間 (`rowGap`) の真ん中を、段をまたぐ線が通る。
93
+ * `start` と `end` は始まりと終わりの印の半径と、印の中の丸の半径。`drop` は最初の状態の上端から始まりの印の中心まで、
94
+ * `below` は状態の下端から終わりの印の中心まで、`beside` は状態の右端から右に置く終わりの印の中心まで、`text` は印の中心から字の左端まで。
95
+ * `loop` は同じ状態へ戻る輪の幅と深さ。`side` は脇道の囲みの内側の余白、1 段目の上端との間、脇道へ入る線が横に通る所の囲みの下端からの離れ、
96
+ * 点線の線が箱へ入る所を箱の端から離す幅。
97
+ * `ghost` は脇道の点線の札で、絵柄を左に、名前と下の字を右に並べる (#3171)。`h` は札の高さ、`pad` は左右の余白、`gap` は絵柄と字の間、
98
+ * `nameY` と `subY` は札の真ん中から名前と下の字の基準線まで。
99
+ * `done` は出た状態の右上の済んだ印の半径と、箱の右上の角からの離れ。
100
+ * `loop` の `samples` は同じ状態へ戻る弧を区切る点の数、`badgeAt` は弧の段の番号を弧の上端から深さのどこに置くかの割合。
101
+ * `pastLabel` は段が終わった後に薄く残す線の名前の濃さ (#3171)
102
+ */
103
+ state: {
104
+ columns: 4,
105
+ columnGap: 64,
106
+ rowGap: 108,
107
+ labelRoom: 16,
108
+ start: 7,
109
+ end: { r: 9, inner: 5 },
110
+ drop: 56,
111
+ below: 64,
112
+ beside: 38,
113
+ text: 16,
114
+ loop: { w: 24, h: 34, samples: 12, badgeAt: 0.6 },
115
+ side: { pad: 16, gap: 76, lane: 30, entry: 28 },
116
+ ghost: { h: 52, pad: 12, gap: 10, nameY: -2, subY: 14 },
117
+ done: { r: 6.5, inset: 13 },
118
+ pastLabel: 0.85,
119
+ },
120
+ /** `bend` は線の曲がりを丸める半径 (#3171) */
121
+ edge: { inset: 3, arrow: 6, arrowHalf: 3.5, parallel: 5, ghostDash: 5, flowDash: 9, bend: 12 },
92
122
  label: { h: 20, padX: 9, radius: 10, offset: 22, side: 14, search: 4, searchMax: 400, soft: 6 },
93
123
  action: { h: 22, padX: 10, radius: 11 },
94
124
  badge: { r: 9.5, ring: 9, inset: 4, along: 14, short: 44 },
95
125
  carrier: { envW: 24, envH: 17, coinR: 6.5, coinIn: 3.8, tagH: 18, tagPadX: 8, samples: 16 },
96
- strip: { h: 72, line: 44, pad: 12, icon: 24, titleX: 44, fieldGap: 22, sepInset: 14, tagGap: 6, tagH: 15, tagPadX: 7, nameY: 22, valueY: 43 },
97
- stroke: { hair: 1, edge: 1.7, edgeOn: 2.25, arrow: 1.5, flow: 1.3, halo: 7, on: 1.75, glyph: 2, lifeline: 1.2, envelope: 1.5, badgeRing: 1.5 },
126
+ /**
127
+ * `trail` は状態遷移図の帯の最後の、通った状態の点 (#3171)。`r` は点の半径、`step` は点の中心どうしの間、`now` は変わった値の欄の名前の横の点の半径と、名前からの離れ
128
+ */
129
+ strip: { h: 72, line: 44, pad: 12, icon: 24, titleX: 44, fieldGap: 22, sepInset: 14, tagGap: 6, tagH: 15, tagPadX: 7, nameY: 22, valueY: 43, trail: { r: 5.4, step: 16 }, now: { r: 3, gap: 7 } },
130
+ stroke: { hair: 1, edge: 1.7, edgeOn: 2.25, arrow: 1.5, flow: 1.3, halo: 7, on: 1.75, glyph: 2, lifeline: 1.2, envelope: 1.5, badgeRing: 1.5, path: 1.6, end: 1.6, done: 1.2, trail: 1.2 },
98
131
  shadow: { near: 0.8, far: 4, nearY: 1, farY: 3 },
99
132
  mini: { w: 168, pad: 8, title: 14, inset: 12, radius: 10 },
100
133
  /**
@@ -108,9 +141,9 @@ export const LOOK = {
108
141
  */
109
142
  follow: { aspect: 0.75, minH: 320, fitMinH: 160, ease: 0.6, narrow: 440, badgeRoom: 8, headRoom: 10, head: { pad: 8, h: 40, nameY: 17, subY: 31 } },
110
143
  /** 色を混ぜる割合 (%) */
111
- mix: { icon: 14, regionIcon: 16, regionIconEdge: 34, cap: 12, capEdge: 42, badgePast: 14, badgePastEdge: 70, mini: 62, miniBand: 16, miniBandEdge: 36, view: 6 },
144
+ mix: { icon: 14, regionIcon: 16, regionIconEdge: 34, cap: 12, capEdge: 42, badgePast: 14, badgePastEdge: 70, mini: 62, miniBand: 16, miniBandEdge: 36, view: 6, side: 4, sideEdge: 36, ghostEdge: 60, path: 55, past: 72 },
112
145
  /** 字の並びの基準線を、置きたい高さの真ん中からどれだけ下げるか */
113
- baseline: { label: 3.8, badge: 3.5, carrier: 3.7, stripTitle: 4.5, tag: 3.6, action: 3.8 },
146
+ baseline: { label: 3.8, badge: 3.5, carrier: 3.7, stripTitle: 4.5, tag: 3.6, action: 3.8, mark: 4 },
114
147
  /** 浮いて出る物が下から上がる長さと、弾んで出る物の始まりの大きさ */
115
148
  rise: 5,
116
149
  popFrom: 0.4,
@@ -154,6 +187,8 @@ export const MOTION = {
154
187
  untilFade: 0.2,
155
188
  /** 段の止め絵を撮る時刻を、段の終わりからどれだけ前にするか */
156
189
  still: 0.45,
190
+ /** 状態遷移図で、札が状態を出てから済んだ印が出るまで */
191
+ done: 0.3,
157
192
  } as const;
158
193
 
159
194
  /** 見せ方を決める字の大きさ。全体を枠に収めた時の箱の名前がこれを下回ると寄って追う。 */
package/src/messages.ts CHANGED
@@ -59,6 +59,12 @@ export interface Messages {
59
59
  badCarry: string;
60
60
  badSide: string;
61
61
  carryTwice: string;
62
+ /** 状態遷移図の箱の名札に、始まりか終わりの印の名前を書いた */
63
+ reservedId: (id: string) => string;
64
+ intoStart: string;
65
+ outOfFinish: string;
66
+ /** 状態遷移図の最初の step より前の線に、属性を書いた */
67
+ pathNoAttrs: string;
62
68
  }
63
69
 
64
70
  const list = (words: readonly string[]): string => words.join(" / ");
@@ -126,6 +132,10 @@ const ja = (): Messages => {
126
132
  badCarry: `carry は ${list(CARRIES)} のどれか。札は tag: <値> で書く`,
127
133
  badSide: `side は ${list(SIDES)} のどれか`,
128
134
  carryTwice: "運ぶ物は 1 本の線に 1 つまで。carry と tag は一緒に書かない",
135
+ reservedId: (id) => `${id} は状態遷移図の始まりと終わりの印の名前で、箱の名札に使えない`,
136
+ intoStart: "start へ入る線は書けない。start は始まりの印で、線は start から出る",
137
+ outOfFinish: "finish から出る線は書けない。finish は終わりの印で、線は finish へ入る",
138
+ pathNoAttrs: "最初の step より前の線は今回は通らない道なので、属性 (carry と tag と side) を書けない",
129
139
  };
130
140
  };
131
141
 
@@ -192,6 +202,10 @@ const en = (): Messages => {
192
202
  badCarry: `carry is one of ${list(CARRIES)}. Write a tag as tag: <value>`,
193
203
  badSide: `side is one of ${list(SIDES)}`,
194
204
  carryTwice: "A line carries one thing. Do not write carry and tag together",
205
+ reservedId: (id) => `${id} names the start or end mark of a state diagram and cannot be a box id`,
206
+ intoStart: "A line cannot go into start. start is the start mark, and lines leave it",
207
+ outOfFinish: "A line cannot leave finish. finish is the end mark, and lines go into it",
208
+ pathNoAttrs: "A line before the first step is a path not taken, so it takes no attributes (carry, tag, side)",
195
209
  };
196
210
  };
197
211
 
package/src/parser.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * 行の種類は行の頭の英語の語で決まり、線の行だけは箱の名札から始まる。最初の `step` より前に
5
5
  * 図の種類と題と区画と箱と帯を宣言し、`step` ごとに、その段で起きることを起きる順に並べる。
6
6
  *
7
- * sequence <題> [view: fixed か follow] 書かなければ字の大きさで選ぶ
7
+ * sequence <題> [view: fixed か follow] 書かなければ字の大きさで選ぶ。図の種類は sequence と architecture と state
8
8
  * group <名札> as <名前> [note: <添え書き>, color: <色>, icon: <印>]
9
9
  * box <名札> as <名前> [note: <下の小さな字>, color: <色>, icon: <印>]
10
10
  * end 同じ名札の group はもう一度開ける
@@ -22,14 +22,18 @@
22
22
  * 空白を含む名前と文は行の終わりか `[` の前に置き、`[` を含む名前と `,` か `]` を含む属性の値だけを
23
23
  * `"` で囲む。囲みの中では `\"` が `"`、`\\` が `\` になる。字下げは `say` の続きの行を見分ける時だけ意味を持つ。
24
24
  *
25
+ * 状態遷移図 (`state`) では、線の送る側に始まりの印 `start`、受ける側に終わりの印 `finish` を書ける。
26
+ * 最初の `step` より前に書いた線は、移れるが段で通るとは限らない道 (`state.paths`) になり、属性を持たない (#3163)。
27
+ * 状態遷移図の `start` と `finish` は箱の名札に使えない。時系列図と構成図では宣言して使う普通の名札のまま。
28
+ *
25
29
  * 行の頭の語と同じ綴りの名札 (`end` や `step`) も書ける。名札の次に `->` が来る行は線と読む。
26
30
  * 誤りは最初の 1 つで止め、行と列を示す。誤りの文の言葉は呼ぶ側が渡す。
27
31
  */
28
32
  import { type Messages, messagesFor, type Shape } from "./messages";
29
- import { type Box, type Carry, type Color, COLORS, type Diagram, type DiagramEvent, type LabelSide, type Region, type Step, type ViewMode } from "./schema";
33
+ import { type Box, type Carry, type Color, COLORS, type Diagram, type DiagramEvent, type LabelSide, type Region, type StatePath, type Step, type ViewMode } from "./schema";
30
34
  import { columnOf, isBlankOrComment, type SourceLine, tokenize } from "./tokenize";
31
35
  import { FORMAT_VERSION } from "./version";
32
- import { AS, ATTRS, CARRIES, HEADS, KINDS, type NotationLang, OLD_HEADS, SIDES, VIEWS } from "./words";
36
+ import { AS, ATTRS, CARRIES, HEADS, KINDS, type NotationLang, OLD_HEADS, SIDES, STATE_ENDS, VIEWS } from "./words";
33
37
 
34
38
  export interface LineError {
35
39
  line: number;
@@ -255,8 +259,10 @@ function parseLines(lines: SourceLine[], m: Messages): Diagram {
255
259
  if (shown && !isOneOf(VIEWS, shown.value)) throw fail(head, shown.at, m.badView);
256
260
  const view = shown?.value as ViewMode | undefined;
257
261
 
262
+ const isState = kind === "state";
258
263
  const regions: Region[] = [];
259
264
  const boxes: Box[] = [];
265
+ const paths: StatePath[] = [];
260
266
  const ids = new Set<string>();
261
267
  let strip: { title: string; fields: string[] } | undefined;
262
268
  const steps: Step[] = [];
@@ -272,6 +278,16 @@ function parseLines(lines: SourceLine[], m: Messages): Diagram {
272
278
  if (!boxes.some((box) => box.id === id)) throw fail(source, at, m.undeclaredBox(id));
273
279
  return id;
274
280
  };
281
+ /** 線の端。状態遷移図では送る側に `start`、受ける側に `finish` を書ける */
282
+ const endAt = (source: SourceLine, id: string, at: number, side: "from" | "to"): string => {
283
+ if (!isState || !isOneOf(STATE_ENDS, id)) return boxAt(source, id, at);
284
+ if ((id === "start") !== (side === "from")) throw fail(source, at, id === "start" ? m.intoStart : m.outOfFinish);
285
+ return id;
286
+ };
287
+ /** 状態遷移図の箱と区画の名札に、始まりと終わりの印の名前は使えない */
288
+ const notReserved = (source: SourceLine, id: string, at: number): void => {
289
+ if (isState && isOneOf(STATE_ENDS, id)) throw fail(source, at, m.reservedId(id));
290
+ };
275
291
  /** 段の中に書く行の、書き先の段。最初の `step` より前なら誤り */
276
292
  const inStep = (source: SourceLine): Building => {
277
293
  if (!building) throw fail(source, 0, m.stepLineBeforeStep);
@@ -300,7 +316,9 @@ function parseLines(lines: SourceLine[], m: Messages): Diagram {
300
316
  // 名札の次に `->` が来る行は、名札が行の頭の語と同じ綴りでも線と読む
301
317
  const edge = EDGE.exec(text);
302
318
  if (edge) {
303
- inStep(source).events.push(readEdge(source, edge));
319
+ // 状態遷移図の最初の step より前の線は、移れる道の地図
320
+ if (isState && !building) paths.push(readPath(source, edge));
321
+ else inStep(source).events.push(readEdge(source, edge));
304
322
  continue;
305
323
  }
306
324
  const word = text.split(/\s/u)[0] ?? "";
@@ -375,6 +393,7 @@ function parseLines(lines: SourceLine[], m: Messages): Diagram {
375
393
  ...(strip ? { strip } : {}),
376
394
  steps,
377
395
  ...(kind === "sequence" ? { sequence: { lifelines: boxes.map((box) => box.id) } } : {}),
396
+ ...(paths.length > 0 ? { state: { paths } } : {}),
378
397
  };
379
398
 
380
399
  /** `group` の行。同じ名札をもう一度開いた時は、名前も属性も書かない */
@@ -385,6 +404,7 @@ function parseLines(lines: SourceLine[], m: Messages): Diagram {
385
404
  if (regions.some((region) => region.id === declared.id)) {
386
405
  if (declared.name !== undefined || declared.attrs.size > 0) throw fail(source, 0, m.regionReopened(declared.id));
387
406
  } else {
407
+ notReserved(source, declared.id, declared.idAt);
388
408
  declare(source, declared.id, declared.idAt);
389
409
  regions.push({ id: declared.id, name: declared.name ?? declared.id, ...lookOf(source, declared.attrs, m) });
390
410
  }
@@ -395,6 +415,7 @@ function parseLines(lines: SourceLine[], m: Messages): Diagram {
395
415
  function readBox(source: SourceLine): void {
396
416
  beforeSteps(source);
397
417
  const declared = readDeclared(source, "box", ATTRS.box, m);
418
+ notReserved(source, declared.id, declared.idAt);
398
419
  declare(source, declared.id, declared.idAt);
399
420
  const { note, ...look } = lookOf(source, declared.attrs, m);
400
421
  boxes.push({
@@ -462,19 +483,29 @@ function parseLines(lines: SourceLine[], m: Messages): Diagram {
462
483
  return { box: boxAt(source, found[1] ?? "", idAt), text: found[2] ?? "" };
463
484
  }
464
485
 
486
+ /** 線の行の両端と、`:` の後ろの線の名前。`at` は名前の後ろの位置 */
487
+ function readLine(source: SourceLine, edge: RegExpExecArray): { from: string; to: string; label?: string; at: number } {
488
+ const text = source.text;
489
+ const from = endAt(source, edge[1] ?? "", edge.indices?.[1]?.[0] ?? 0, "from");
490
+ const to = endAt(source, edge[2] ?? "", edge.indices?.[2]?.[0] ?? 0, "to");
491
+ const at = skipSpaces(text, edge[0].length);
492
+ if (text[at] !== ":") return { from, to, at };
493
+ const named = readName(source, at + 1, m);
494
+ if (named.value === "") throw fail(source, at, m.emptyLabel);
495
+ return { from, to, label: named.value, at: named.rest };
496
+ }
497
+
498
+ /** 状態遷移図の最初の `step` より前の `<名札> -> <名札>: <線の名前>` の行。属性は書けない */
499
+ function readPath(source: SourceLine, edge: RegExpExecArray): StatePath {
500
+ const { from, to, label, at } = readLine(source, edge);
501
+ if (at < source.text.length) throw fail(source, at, source.text[at] === "[" ? m.pathNoAttrs : m.edgeShape);
502
+ return { from, to, ...(label !== undefined ? { label } : {}) };
503
+ }
504
+
465
505
  /** `<名札> -> <名札>: <線の名前> [<属性>]` の行 */
466
506
  function readEdge(source: SourceLine, edge: RegExpExecArray): DiagramEvent {
467
507
  const text = source.text;
468
- const from = boxAt(source, edge[1] ?? "", edge.indices?.[1]?.[0] ?? 0);
469
- const to = boxAt(source, edge[2] ?? "", edge.indices?.[2]?.[0] ?? 0);
470
- let at = skipSpaces(text, edge[0].length);
471
- let label: string | undefined;
472
- if (text[at] === ":") {
473
- const named = readName(source, at + 1, m);
474
- if (named.value === "") throw fail(source, at, m.emptyLabel);
475
- label = named.value;
476
- at = named.rest;
477
- }
508
+ const { from, to, label, at } = readLine(source, edge);
478
509
  if (at < text.length && text[at] !== "[") throw fail(source, at, m.edgeShape);
479
510
  const attrs = at < text.length ? readAttrs(source, at, ATTRS.edge, m) : new Map<string, Attr>();
480
511
  const tag = attrs.get("tag");
package/src/printer.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * 並べ方は決まった形に整える。JSON の欄の並び順は見ず、欄の名前で読むので、
5
5
  * 同じ内容の JSON からは毎回同じ文字列になる。書き手の行の並べ方とコメントは保たない。
6
6
  * 段の中で起きることは JSON の並び順 (起きる順) のまま書く。
7
+ * 状態遷移図の移れる道 (`state.paths`) は、帯の宣言の後、最初の `step` より前に JSON の並びのまま書く (#3163)。
7
8
  *
8
9
  * `"` で囲むのは要る時だけにする。`[` を含む名前と、`,` か `]` を含む属性の値と、`"` で始まる字を囲む。
9
10
  * 箱は JSON の並びのまま書き、同じ区画の箱が続く所を 1 つの `group … end` にする。
@@ -44,11 +45,14 @@ const declareBox = (b: Box): string =>
44
45
  ["icon", b.icon],
45
46
  ])}`;
46
47
 
48
+ /** 線の行の両端と名前 */
49
+ const line = (from: string, to: string, label: string | undefined): string => `${from} -> ${to}${label === undefined ? "" : `: ${name(label)}`}`;
50
+
47
51
  function event(e: DiagramEvent): string {
48
52
  switch (e.type) {
49
53
  case "edge": {
50
54
  const carry: [string, string][] = e.carry.kind === "tag" ? [["tag", e.carry.text]] : e.carry.kind === "envelope" ? [] : [["carry", e.carry.kind]];
51
- return `${e.from} -> ${e.to}${e.label === undefined ? "" : `: ${name(e.label)}`}${attrs([...carry, ["side", e.labelSide]])}`;
55
+ return `${line(e.from, e.to, e.label)}${attrs([...carry, ["side", e.labelSide]])}`;
52
56
  }
53
57
  case "action":
54
58
  return `act ${e.box}: ${e.label}`;
@@ -95,6 +99,7 @@ function declarations(diagram: Diagram): string[] {
95
99
  export function printNotation(diagram: Diagram): string {
96
100
  const blocks = [`${diagram.kind} ${name(diagram.title)}${attrs([["view", diagram.view]])}`, ...declarations(diagram)];
97
101
  if (diagram.strip) blocks.push(`strip ${key(diagram.strip.title)}: ${diagram.strip.fields.map(field).join(", ")}`);
102
+ if (diagram.state) blocks.push(diagram.state.paths.map((p) => line(p.from, p.to, p.label)).join("\n"));
98
103
  for (const step of diagram.steps) {
99
104
  const body = step.body.split("\n");
100
105
  const say = body.length === 1 ? [` say ${step.body}`] : [" say", ...body.map((line) => (line === "" ? "" : ` ${line}`))];