@cardenelabs/cdl 0.16.0 → 0.17.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.
package/src/presets.ts CHANGED
@@ -2,7 +2,16 @@ import { diagram } from "./builder";
2
2
  import type { DiagramBuilder } from "./builder";
3
3
  import { LANE_PAD_MIN, NODE_SIZE } from "./layout/tokens";
4
4
  import { requiredRowsWidth } from "./kinds/row-align";
5
- import type { NodeKind, Tone, EdgeStyle, EdgeHead, JourneyEmotion, QuadrantKey, BoundNumber } from "./types";
5
+ import {
6
+ sequenceBoardMetrics,
7
+ sequenceStepId,
8
+ type SequenceBoardActor,
9
+ type SequenceBoardBand,
10
+ type SequenceBoardData,
11
+ type SequenceBoardMessage,
12
+ type SequenceBoardMessageKind,
13
+ } from "./kinds/sequence-board-metrics";
14
+ import type { NodeKind, Tone, EdgeStyle, EdgeHead, EdgeHeadFill, RowMark, JourneyEmotion, QuadrantKey, BoundNumber } from "./types";
6
15
 
7
16
  /**
8
17
  * 状態を読む書き方 (`{名前}`)。
@@ -348,6 +357,30 @@ export function flow(preset: FlowPreset): FlowBuilder {
348
357
 
349
358
  // ─── sequence (UML sequence diagram 風) ────────────────────────
350
359
 
360
+ /**
361
+ * 言づての種類 (#578)。 **形で 3 つを分ける**。
362
+ *
363
+ * | 種類 | 線 | 印 | 意味 |
364
+ * |---|---|---|---|
365
+ * | `call` | 実線 | 塗った矢 | 相手にやらせて待つ |
366
+ * | `return` | 破線 | 開いた矢 | 呼ばれた側から戻る。 新しい仕事ではないので線を切る |
367
+ * | `fire` | 実線 | 開いた矢 | 返事を待たない。 実線だが矢を閉じないことで待たないと示す |
368
+ *
369
+ * 印の形はクラス図の「継ぐ」 と「結ぶ」 で使った形そのもの。 **意味は違う** (あちらは親を
370
+ * 指す / こちらは返事を待つか) が、形の並びは共有できる。
371
+ */
372
+ export type SequenceMessageKind = "call" | "return" | "fire";
373
+
374
+ /** 言づての種類ごとの見た目 (#578)。 種類を足すと 3 つとも要る */
375
+ export const SEQUENCE_MESSAGE_LOOK: Record<
376
+ SequenceMessageKind,
377
+ { style: EdgeStyle; head: EdgeHead }
378
+ > = {
379
+ call: { style: "solid", head: "triangle" },
380
+ return: { style: "dashed", head: "open" },
381
+ fire: { style: "solid", head: "open" },
382
+ };
383
+
351
384
  export type SequenceStep = {
352
385
  /** from actor id (or label slug) */
353
386
  from: string;
@@ -359,13 +392,28 @@ export type SequenceStep = {
359
392
  sub?: string;
360
393
  tone?: Tone;
361
394
  style?: EdgeStyle;
395
+ /**
396
+ * 言づての種類 (#578)。 書くと線と印がまとめて決まる (`SEQUENCE_MESSAGE_LOOK`)。
397
+ *
398
+ * `style` / `head` を個別に書けばそちらが勝つ。
399
+ */
400
+ kind?: SequenceMessageKind;
401
+ /** 端の形 (#578)。 書かなければ `kind` から決まり、それも無ければ既定の塗った三角 */
402
+ head?: EdgeHead;
403
+ };
404
+
405
+ /** 参加者 1 人。 名前だけの文字列でも書ける (#578) */
406
+ export type SequenceActor = {
407
+ name: string;
408
+ /** 名前の下に置く日本語の呼び名 (例 "画面" / "受付" / "台帳") */
409
+ subtitle?: string;
362
410
  };
363
411
 
364
412
  export type SequencePreset = {
365
413
  id: string;
366
414
  topic: string;
367
- /** actor label の配列 (lifeline、 column 単位) */
368
- actors: string[];
415
+ /** actor label の配列 (lifeline、 column 単位) 呼び名を添えるなら物で書く (#578) */
416
+ actors: readonly (string | SequenceActor)[];
369
417
  /** actor の lane width (default 220) */
370
418
  laneWidth?: number;
371
419
  /** lane gap (default 60) */
@@ -374,6 +422,13 @@ export type SequencePreset = {
374
422
  defaultTone?: Tone;
375
423
  /** default edge style (default solid) */
376
424
  defaultStyle?: EdgeStyle;
425
+ /**
426
+ * 動いている間の帯 (#578)。 段の番号で区間を書く。
427
+ *
428
+ * 書かなければ面ごとに「最初に関わった段から最後まで」 の 1 本。 途中で手が空く面を分けたい
429
+ * 図だけ書く = どこで手が空くかは言づての並びからは決まらない
430
+ */
431
+ bands?: readonly { actor: string; from: number; to: number }[];
377
432
  };
378
433
 
379
434
  export type SequenceBuilder = {
@@ -395,84 +450,75 @@ export type SequenceBuilder = {
395
450
  * .build();
396
451
  */
397
452
  export function sequence(preset: SequencePreset): SequenceBuilder {
398
- const laneW = preset.laneWidth ?? 340;
399
453
  const tone: Tone = preset.defaultTone ?? "accent";
400
- const style: EdgeStyle = preset.defaultStyle ?? "solid";
401
454
  const b = diagram(preset.id, { topic: preset.topic });
402
455
 
403
- // actor 名 → slug id の map
404
- const actorIds = new Map<string, string>();
405
- const headerNodeIds: string[] = [];
406
- preset.actors.forEach((label, i) => {
407
- const id = slugify(label) || `actor-${i}`;
408
- actorIds.set(label, id);
409
- actorIds.set(id, id);
410
- b.lane(id, { width: laneW, label, lifeline: true });
411
- // 各 lane 先頭に actor header (kind=card) を stack=0 で配置
412
- // header/footer 幅を title 長に応じて auto-size (text-readability warning 解消)。
413
- // formula = 22px/char + 52px padding (visualValidate text-readability と完全一致)、 min 140 で従来 sample 互換維持。
414
- // visualValidate text-readability formula (titleLen * 22 + 52) と完全一致で
415
- // warning suppress、 ASCII long name (AuthenticationService 21 char) は 514 になり
416
- // 過大化との trade-off だが 「警告出るのに対応できない」 の user feedback 優先。
417
- const actorW = Math.max(140, label.length * 22 + 52);
418
- const headerId = `${id}-header`;
419
- b.node(headerId, { lane: id, stack: 0, kind: "card", title: label, w: actorW, h: 72 });
420
- headerNodeIds.push(headerId);
421
- // header と最初の step の間に dummy spacer node を挟む (stack=1、 step は stack=2 から)
422
- const spacerId = `${id}-spacer`;
423
- b.node(spacerId, { lane: id, stack: 1, kind: "card", title: "", w: 2, h: 40 });
456
+ // 名前だけの文字列と、呼び名を添えた物の両方を受ける (#578)
457
+ const 面々: SequenceBoardActor[] = preset.actors.map((a) =>
458
+ typeof a === "string" ? { name: a } : { name: a.name, ...(a.subtitle ? { subtitle: a.subtitle } : {}) },
459
+ );
460
+ const 番号 = new Map<string, number>();
461
+ 面々.forEach((a, i) => {
462
+ 番号.set(a.name, i);
463
+ 番号.set(slugify(a.name) || `actor-${i}`, i);
424
464
  });
425
465
 
426
- const phaseActivate: string[] = [...headerNodeIds];
427
- let stepIdx = 0;
428
- const stepBoxIds: Array<{ from: string; to: string; stack: number }> = [];
466
+ const 言づて: SequenceBoardMessage[] = [];
467
+ const = new Map<number, string>();
429
468
 
430
469
  const api: SequenceBuilder = {
431
- step(s) {
432
- const fromLaneId = actorIds.get(s.from) ?? s.from;
433
- const toLaneId = actorIds.get(s.to) ?? s.to;
434
- const stack = stepIdx + 2; // stack=0 header、 stack=1 は spacer、 step は 2 から
435
- // from / to lane に それぞれ invisible activation box (kind=card、 title="") を配置
436
- const fromBoxId = `s${stepIdx}-${fromLaneId}`;
437
- const toBoxId = `s${stepIdx}-${toLaneId}`;
438
- // activation marker (lifeline 上に置く小点)、 edge anchor 用。 w=h=2 で視覚上ほぼ非表示
439
- b.node(fromBoxId, { lane: fromLaneId, stack, kind: "card", title: "", w: 2, h: 2 });
440
- if (fromLaneId !== toLaneId) {
441
- b.node(toBoxId, { lane: toLaneId, stack, kind: "card", title: "", w: 2, h: 2 });
442
- }
443
- const edgeId = `e${stepIdx}-${fromLaneId}-${toLaneId}`;
444
- b.edge(fromBoxId, fromLaneId === toLaneId ? fromBoxId : toBoxId, {
445
- id: edgeId,
446
- label: s.label,
447
- ...(s.sub ? { sub: s.sub } : {}),
448
- tone: s.tone ?? tone,
449
- style: s.style ?? style,
450
- });
451
- stepBoxIds.push({ from: fromBoxId, to: toBoxId, stack });
452
- phaseActivate.push(fromBoxId);
453
- if (fromLaneId !== toLaneId) phaseActivate.push(toBoxId);
454
- phaseActivate.push(edgeId);
455
- stepIdx += 1;
470
+ step(s2) {
471
+ const from = 番号.get(s2.from) ?? 0;
472
+ const to = 番号.get(s2.to) ?? 0;
473
+ // 種類を書かない図は **線の種類から読む** (#578)。 点線で書いてあるものは返し、
474
+ // それ以外は呼び。 既存の図をそのまま新しい骨格へ移すための経路
475
+ const kind: SequenceBoardMessageKind =
476
+ s2.kind ?? ((s2.style ?? preset.defaultStyle) === "dotted-flow" ? "return" : "call");
477
+ 言づて.push({ label: s2.label, from, to, kind });
478
+ if (s2.sub) 語.set(言づて.length - 1, s2.sub);
456
479
  return api;
457
480
  },
458
481
  build() {
459
- // 下端 footer ... UML sequence 標準どおり、 各 lifeline 末尾に header と同 box を再描画
460
- const footerStack = stepIdx + 2; // 最終 step (stepIdx + 1 の stack) の次 stack
461
- preset.actors.forEach((label) => {
462
- const laneId = actorIds.get(label) ?? slugify(label);
463
- const footerId = `${laneId}-footer`;
464
- // visualValidate text-readability formula (titleLen * 22 + 52) と完全一致で
465
- // warning suppress、 ASCII long name (AuthenticationService 21 char) は 514 になり
466
- // 過大化との trade-off だが 「警告出るのに対応できない」 の user feedback 優先。
467
- const actorW = Math.max(140, label.length * 22 + 52);
468
- // `role` を付けるのは、 描画側が「どれが下端の名札か」 を位置で推測しないため (#458)
469
- b.node(footerId, { lane: laneId, stack: footerStack, kind: "card", title: label, w: actorW, h: 72, role: "lifeline-footer" });
470
- phaseActivate.push(footerId);
482
+ /*
483
+ * 動いている間の帯 (#578)
484
+ *
485
+ * 書かない図では **最初に関わった段から最後まで** 1 本にする。 途中で手が空く面を
486
+ * 分けたい図は `bands` に区間を書く = どこで手が空くかは言づての並びからは決まらない
487
+ * (返しを受けて終わるのか、後でまた呼ばれるのかが読めない)
488
+ */
489
+ const 帯: SequenceBoardBand[] = preset.bands
490
+ ? preset.bands.map((x) => ({ actor: 番号.get(x.actor) ?? 0, from: x.from, to: x.to }))
491
+ : 面々
492
+ .map((_, i) => {
493
+ const 出番 = 言づて
494
+ .map((m, idx) => (m.from === i || m.to === i ? idx : -1))
495
+ .filter((idx) => idx >= 0);
496
+ return 出番.length > 0
497
+ ? { actor: i, from: 出番[0]!, to: 出番[出番.length - 1]! }
498
+ : null;
499
+ })
500
+ .filter((x): x is SequenceBoardBand => x !== null);
501
+
502
+ const data: SequenceBoardData = { actors: 面々, messages: 言づて, bands: 帯 };
503
+ const m = sequenceBoardMetrics(data);
504
+ const laneId = "seq";
505
+ b.lane(laneId, { width: m.w + LANE_PAD_MIN * 2 });
506
+ b.node(preset.id, {
507
+ lane: laneId,
508
+ stack: 0,
509
+ kind: "sequence-board",
510
+ w: m.w,
511
+ h: m.h,
512
+ title: "",
513
+ sequenceData: data,
514
+ // 段が進むと濃さが移る。 書かない図では全部を濃く出す
515
+ sequenceStep: `{${sequenceStepId()}}`,
516
+ tone,
471
517
  });
472
518
  b.phase(
473
- "seq",
519
+ "sequence",
474
520
  { duration: 2400, title: preset.topic, body: "sequence の全 message を時系列展開。" },
475
- (p) => p.activate(...phaseActivate).badge("sequence"),
521
+ (p) => p.activate(preset.id).badge("sequence"),
476
522
  );
477
523
  return b.build();
478
524
  },
@@ -619,23 +665,90 @@ export function topology(preset: TopologyPreset): TopologyBuilder {
619
665
 
620
666
  // ─── er (ER 図 / Entity Relationship) ─────────────────────────
621
667
 
668
+ /**
669
+ * 表の列 1 つ (#578)。 行頭の印の 2 軸を、ER の意味に割り当てる。
670
+ *
671
+ * | 印 | 意味 |
672
+ * |---|---|
673
+ * | 四角を塗る | 空にできないただの列 |
674
+ * | 四角の外枠だけ | 空を許す列 |
675
+ * | 山形を塗る | 外を指す列で、相手が必ずいる |
676
+ * | 山形の外枠だけ | 外を指すが、指さないこともある |
677
+ *
678
+ * 主キーは **名前の下線**。 印の 2 軸は埋まっているので、3 つ目の軸を印に足すと 4 通りが
679
+ * 8 通りになって見分けが付かなくなる。 下線は別の段に載るので、外部キーの山形と重ねられる。
680
+ */
681
+ export type ErColumn = {
682
+ name: string;
683
+ /** 型 (例 "bigint" / "timestamptz")。 箱の右端に薄く右揃えで出る */
684
+ type: string;
685
+ /** 外を指す列か (外部キー)。 書かなければただの値 */
686
+ fk?: boolean;
687
+ /** 空を許すか。 書かなければ空にできない */
688
+ optional?: boolean;
689
+ /** 主キーの一部か。 書くと名前に下線が付き、**鍵の群**として上にまとめられる */
690
+ pk?: boolean;
691
+ };
692
+
622
693
  export type ErEntity = {
623
694
  id: string;
624
695
  /** entity 名 (例 "User") */
625
696
  title: string;
626
- /** 列定義 ( "id: PK", "email: string", "userId: FK") */
627
- rows: string[];
697
+ /** 表の名前の下に置く日本語の呼び名 (#578)。 `columns` を書いた時だけ出る */
698
+ subtitle?: string;
699
+ /**
700
+ * 列定義 (例 "id: PK", "email: string", "userId: FK")。
701
+ *
702
+ * **`columns` を書いた実体では読まない**。 印を付けるには列の意味が要り、字からは導けない
703
+ * (`"userId: FK"` の `FK` は型の欄に書かれた語であって、外を指すという宣言ではない)。
704
+ */
705
+ rows?: string[];
706
+ /** 列を印つきで書く形 (#578)。 書くと設計の意匠 (行頭の印 + 右端の薄い型) で出る */
707
+ columns?: ErColumn[];
628
708
  };
629
709
 
630
710
  export type ErRelationCardinality = "1:1" | "1:N" | "N:1" | "N:M" | "0..1" | "1..*";
631
711
 
712
+ /**
713
+ * 個数の書き方から **両端の印** を決める表 (#578)。
714
+ *
715
+ * 出どころ側と行き先側で別の印が立つ。 `1:N` なら出どころが「ちょうど 1 つ」、行き先が
716
+ * 「少なくとも 1 つ」。 端ごとに書きたい図は `tailHead` / `head` で個別に上書きする。
717
+ */
718
+ export const ER_CARDINALITY_HEAD: Record<
719
+ ErRelationCardinality,
720
+ { tail: EdgeHead; head: EdgeHead }
721
+ > = {
722
+ "1:1": { tail: "one", head: "one" },
723
+ "1:N": { tail: "one", head: "many" },
724
+ "N:1": { tail: "many", head: "one" },
725
+ "N:M": { tail: "many", head: "many" },
726
+ "0..1": { tail: "one", head: "zero-one" },
727
+ "1..*": { tail: "one", head: "many" },
728
+ };
729
+
632
730
  export type ErRelation = {
633
731
  from: string;
634
732
  to: string;
635
- cardinality: ErRelationCardinality;
733
+ /**
734
+ * 個数。 書かなければ端の印を `tailHead` / `head` で個別に書く。
735
+ *
736
+ * 書いた場合は端の印の既定になり、`label` と併せて書いた時だけ字としても出る。
737
+ */
738
+ cardinality?: ErRelationCardinality;
636
739
  /** 関係名 (例 "places", "belongs_to") */
637
740
  label?: string;
638
741
  tone?: Tone;
742
+ /** 出どころ側の端の印 (#578)。 書かなければ `cardinality` から決まる */
743
+ tailHead?: EdgeHead;
744
+ /** 行き先側の端の印 (#578)。 書かなければ `cardinality` から決まる */
745
+ head?: EdgeHead;
746
+ /**
747
+ * 線の種類 (#578)。 実線 = 親の鍵が子の鍵に入る (識別する) / 破線 = 子は自分の鍵を持つ。
748
+ *
749
+ * 書かなければ実線。
750
+ */
751
+ style?: EdgeStyle;
639
752
  };
640
753
 
641
754
  export type ErPreset = {
@@ -681,9 +794,31 @@ export function er(preset: ErPreset): ErBuilder {
681
794
  const api: ErBuilder = {
682
795
  entity(e) {
683
796
  const laneId = `lane-${e.id}`;
797
+ // 鍵の群を上にまとめ、間を空の行 1 つで開ける (#578)。 区切りの線を引く代わりに余白で
798
+ // 分けるので、`rowMarks` を持つ箱では描き手が線を引かない
799
+ const 鍵 = (e.columns ?? []).filter((c) => c.pk === true);
800
+ const 値 = (e.columns ?? []).filter((c) => c.pk !== true);
801
+ const 行: string[] = [];
802
+ const 印: (RowMark | null)[] = [];
803
+ const 足す = (c: ErColumn): void => {
804
+ 行.push(`${c.name}: ${c.type}`);
805
+ 印.push({
806
+ shape: c.fk === true ? "chevron" : "square",
807
+ filled: c.optional !== true,
808
+ ...(c.pk === true ? { underline: true } : {}),
809
+ });
810
+ };
811
+ 鍵.forEach(足す);
812
+ if (鍵.length > 0 && 値.length > 0) {
813
+ 行.push("");
814
+ 印.push(null);
815
+ }
816
+ 値.forEach(足す);
817
+ const 設計の形 = 行.length > 0;
818
+ const rows = 設計の形 ? 行 : (e.rows ?? []);
684
819
  // 帯は実体ごとに決める。 行の長さで箱が広がる実体があるため、 全実体を同じ帯幅で
685
820
  // 宣言すると宣言座標と実座標がずれる。
686
- const boxW = fitRowsWidth(entityW, e.rows);
821
+ const boxW = fitRowsWidth(entityW, rows);
687
822
  b.lane(laneId, { width: boxLaneW(boxW) });
688
823
  b.node(e.id, {
689
824
  lane: laneId,
@@ -691,8 +826,12 @@ export function er(preset: ErPreset): ErBuilder {
691
826
  kind: "storage",
692
827
  w: boxW,
693
828
  title: e.title,
694
- eyebrow: "エンティティ",
695
- rows: e.rows,
829
+ // 種類の札は設計の形では出さない (#578)。 「エンティティ」 と書いた札は全ての箱に
830
+ // 同じ字が並ぶだけで、代わりに表の呼び名を題の下へ置く
831
+ ...(設計の形 ? {} : { eyebrow: "エンティティ" }),
832
+ ...(設計の形 && e.subtitle ? { subtitle: e.subtitle } : {}),
833
+ rows,
834
+ ...(設計の形 ? { rowMarks: 印 } : {}),
696
835
  });
697
836
  entities.push(e.id);
698
837
  phaseActivate.push(e.id);
@@ -701,8 +840,12 @@ export function er(preset: ErPreset): ErBuilder {
701
840
  relation(r) {
702
841
  const currentIdx = relIdx++;
703
842
  const edgeId = `rel-${currentIdx}-${r.from}-${r.to}`;
704
- const label = r.label ?? r.cardinality;
705
- const sub = r.label ? r.cardinality : undefined;
843
+ const label = r.label ?? r.cardinality ?? "";
844
+ // 個数を字でも出すのは **端の印を書かなかった時だけ** (#578)。 印が個数を示すので、
845
+ // 両方出すと同じことを 2 回言うことになる
846
+ const 印が示す = r.tailHead !== undefined || r.head !== undefined;
847
+ const sub = r.label && r.cardinality && !印が示す ? r.cardinality : undefined;
848
+ const 既定 = r.cardinality ? ER_CARDINALITY_HEAD[r.cardinality] : undefined;
706
849
  // ER 図の relation label は entity 上に配置 (path 短く entity 隣接なので矢印との視覚関連が強い)。
707
850
  // CAR-430 で label-shift.ts の shift 探索を削除、 edges.ts routePath() init 位置が SSOT。
708
851
  // overlap 発生時は post-pass 2 の resolveEdgeLabelOverlapsWithChain が chain 伝搬で解消する。
@@ -711,7 +854,10 @@ export function er(preset: ErPreset): ErBuilder {
711
854
  label,
712
855
  ...(sub ? { sub } : {}),
713
856
  tone: r.tone ?? tone,
714
- style: "solid",
857
+ style: r.style ?? "solid",
858
+ // 端の印は両端に立つ (#578)。 箱に近い側が個数、その外側が任意か
859
+ ...(r.head ?? 既定?.head ? { head: r.head ?? 既定!.head } : {}),
860
+ ...(r.tailHead ?? 既定?.tail ? { tailHead: r.tailHead ?? 既定!.tail } : {}),
715
861
  });
716
862
  phaseActivate.push(edgeId);
717
863
  return api;
@@ -731,6 +877,29 @@ export function er(preset: ErPreset): ErBuilder {
731
877
 
732
878
  // ─── stateMachine (FSM / Workflow) ────────────────────────────
733
879
 
880
+ /**
881
+ * 状態の中身 1 行 (#578)。 **いつ動くか** の 4 つを、行頭の印の 2 軸に載せる。
882
+ *
883
+ * | いつ | 印 |
884
+ * |---|---|
885
+ * | `entry` (入る時) | 山形を塗る |
886
+ * | `exit` (出る時) | 山形の外枠だけ |
887
+ * | `do` (いる間) | 四角を塗る |
888
+ * | `internal` (中で受ける) | 四角の外枠だけ |
889
+ *
890
+ * 割り当ては「山形 = 出入りの瞬間 / 四角 = 状態の中」「塗り = 続く・入る側 / 外枠 = 瞬間・出る側」。
891
+ *
892
+ * **4 つは元々 2 軸に割れていない**。 それでも 2 軸に載せたのは、読み手がクラス図と ER 図で
893
+ * 覚えた見分け方をそのまま使える方が、正しく分類することより効くと判断したため。
894
+ */
895
+ export type FsmStateAction = {
896
+ when: "entry" | "exit" | "do" | "internal";
897
+ /** すること (例 "在庫を押さえる") */
898
+ label: string;
899
+ /** 添え (例 "7 日ごと")。 箱の右端に薄く右揃えで出る */
900
+ note?: string;
901
+ };
902
+
734
903
  export type FsmState = {
735
904
  id: string;
736
905
  title: string;
@@ -738,6 +907,47 @@ export type FsmState = {
738
907
  initial?: boolean;
739
908
  /** final state (複数可) */
740
909
  final?: boolean;
910
+ /** 名前の下に置く日本語の呼び名 (#578) */
911
+ subtitle?: string;
912
+ /** 状態の中身 (#578)。 書くと設計の意匠 (行頭の印 + 右端の薄い添え) で出る */
913
+ actions?: FsmStateAction[];
914
+ /**
915
+ * 格子の列 (#578)。 書かなければ **宣言した順に横 1 列** で、従来どおり。
916
+ *
917
+ * 1 つでも書いた図は格子に置く。
918
+ */
919
+ col?: number;
920
+ /** 格子の段 (#578)。 `col` と対で書く */
921
+ row?: number;
922
+ };
923
+
924
+ /**
925
+ * 箱ではない節点 (#578)。 始まりは塗った丸、終わりは輪で囲んだ丸。
926
+ *
927
+ * **箱として扱えない**。 始まりと終わりは行も名前も持たないので、箱にすると題も呼び名も空で
928
+ * 寸法が出せない。 描画側は種別 (`mark-start` / `mark-end`) を持っており、そちらを使う。
929
+ */
930
+ /**
931
+ * 箱ではない節点の大きさ (#578)。 印は箱の短い辺いっぱいに描かれる (`markRadius`) ので、
932
+ * ここが丸の直径になる。 状態の箱より十分小さく、線の先が触れる大きさ。
933
+ */
934
+ export const MARK_BOX = 96;
935
+
936
+ export type FsmMark = {
937
+ id: string;
938
+ kind: "start" | "end";
939
+ /** 格子の列。 状態と同じ格子に載る */
940
+ col?: number;
941
+ /** 格子の段 */
942
+ row?: number;
943
+ };
944
+
945
+ /** 状態の中身の「いつ」 から行頭の印を引く表 (#578) */
946
+ export const FSM_ACTION_MARK: Record<FsmStateAction["when"], RowMark> = {
947
+ entry: { shape: "chevron", filled: true },
948
+ exit: { shape: "chevron", filled: false },
949
+ do: { shape: "square", filled: true },
950
+ internal: { shape: "square", filled: false },
741
951
  };
742
952
 
743
953
  export type FsmTransition = {
@@ -748,6 +958,13 @@ export type FsmTransition = {
748
958
  /** guard 条件 (例 "if validated") */
749
959
  guard?: string;
750
960
  tone?: Tone;
961
+ /**
962
+ * 端の形 (#578)。 書かなければ **開いた矢**。
963
+ *
964
+ * 状態遷移の線は 1 種類しかない = クラス図は 6 種、ER 図は端 4 種 × 線 2 種あるのに、
965
+ * ここでは線と印が分かれない。 違いは語の中 (`きっかけ` または `きっかけ [条件]`) に入る。
966
+ */
967
+ head?: EdgeHead;
751
968
  };
752
969
 
753
970
  export type StateMachinePreset = {
@@ -769,6 +986,8 @@ export type StateMachinePreset = {
769
986
 
770
987
  export type StateMachineBuilder = {
771
988
  state: (s: FsmState) => StateMachineBuilder;
989
+ /** 箱ではない節点 (始まり / 終わり) を置く (#578) */
990
+ mark: (m: FsmMark) => StateMachineBuilder;
772
991
  transition: (t: FsmTransition) => StateMachineBuilder;
773
992
  build: () => ReturnType<DiagramBuilder["build"]>;
774
993
  };
@@ -792,55 +1011,133 @@ export type StateMachineBuilder = {
792
1011
  */
793
1012
  export function stateMachine(preset: StateMachinePreset): StateMachineBuilder {
794
1013
  const stateW = resolveStateW(preset.stateWidth, "stateMachine");
795
- const laneW = boxLaneW(stateW);
796
1014
  const tone: Tone = preset.defaultTone ?? "accent";
797
1015
  const b = diagram(preset.id, { topic: preset.topic });
798
1016
 
799
1017
  const phaseActivate: string[] = [];
800
- let transIdx = 0;
801
1018
  const stateOrder = new Map<string, number>();
1019
+ // 状態と節点は **溜めてから** 組む (#578)。 格子に置く図では、列の帯幅がその列の全ての箱を
1020
+ // 見ないと決まらない
1021
+ const 状態: FsmState[] = [];
1022
+ const 節点: FsmMark[] = [];
1023
+ const 遷移: FsmTransition[] = [];
1024
+ /**
1025
+ * 書いた順 (#578)。 状態と節点を別々に溜めると、箱の並びが「節点が先、状態が後」 に
1026
+ * 固定される = 記法から同じ図を書いた時に並びが合わない (実測)。
1027
+ */
1028
+ const 並び: { 種: "状態" | "節点"; i: number }[] = [];
802
1029
 
803
1030
  const api: StateMachineBuilder = {
804
1031
  state(s) {
805
- const laneId = `lane-${s.id}`;
806
- b.lane(laneId, { width: laneW });
807
- const eyebrow = s.initial ? "初期" : s.final ? "最終" : "状態";
808
- b.node(s.id, {
809
- lane: laneId,
810
- stack: 0,
811
- kind: "card",
812
- w: stateW,
813
- title: s.title,
814
- eyebrow,
815
- });
1032
+ 並び.push({ 種: "状態", i: 状態.length });
1033
+ 状態.push(s);
816
1034
  stateOrder.set(s.id, stateOrder.size);
817
- phaseActivate.push(s.id);
1035
+ return api;
1036
+ },
1037
+ mark(m) {
1038
+ 並び.push({ 種: "節点", i: 節点.length });
1039
+ 節点.push(m);
1040
+ // 節点も並びに数える (#578)。 数えないと、節点へ向かう遷移が「前へ戻る」 と判定されて
1041
+ // 上へ迂回する (実測 = `Paid → 終わり` が図の上へ伸びた)
1042
+ stateOrder.set(m.id, stateOrder.size);
818
1043
  return api;
819
1044
  },
820
1045
  transition(t) {
821
- const currentIdx = transIdx++;
822
- const edgeId = `t${currentIdx}-${t.from}-${t.to}`;
823
- // back transition (state idx 後ろ → 前) は side=top で node 上端から出させ、
824
- // 上方経路で detour させて forward path と重ならないようにする。
825
- // CAR-468 SSOT ... 旧 labelOffsetY = isBack ? -200 : ±170 (label を強制的に上下シフト) を廃止、
826
- // 全 transition で labelOffsetY = 0 とし routePath() の SSOT init 位置 (LABEL_INIT_CLEARANCE=34、 CAR-630 で 26 → 34 復帰) を
827
- // そのまま採用する。 複数 transition の label 分散は engine 側 parallel / fan-in / fan-out logic
828
- // (PARALLEL_STEP_Y=52 / FAN_STEP=40 in edges.ts) と post-pass 2 chain shift が担当する。
829
- const fromIdx = stateOrder.get(t.from) ?? 0;
830
- const toIdx = stateOrder.get(t.to) ?? 0;
831
- const isBack = toIdx < fromIdx;
832
- b.edge(t.from, t.to, {
833
- id: edgeId,
834
- label: t.trigger,
835
- ...(t.guard ? { sub: t.guard } : {}),
836
- tone: t.tone ?? tone,
837
- style: "solid",
838
- ...(isBack ? { routing: "back-detour" as const } : {}),
839
- });
840
- phaseActivate.push(edgeId);
1046
+ 遷移.push(t);
841
1047
  return api;
842
1048
  },
843
1049
  build() {
1050
+ // 設計の形かは **中身を書いたか** で決める (#578)。 書かない図は従来どおり card の
1051
+ // 横 1 列で、1 画素も変わらない
1052
+ const 設計の形 = 状態.some((s) => s.actions !== undefined || s.subtitle !== undefined)
1053
+ || 節点.length > 0;
1054
+ const 格子 = 状態.some((s) => s.col !== undefined) || 節点.some((m) => m.col !== undefined);
1055
+
1056
+ const 中身 = 状態.map((s) => {
1057
+ const 行 = (s.actions ?? []).map((a) => (a.note ? `${a.label}: ${a.note}` : a.label));
1058
+ const 印 = (s.actions ?? []).map((a) => FSM_ACTION_MARK[a.when]);
1059
+ const laneId = 格子 && s.col !== undefined ? `col-${s.col}` : `lane-${s.id}`;
1060
+ const boxW = 設計の形 ? fitRowsWidth(stateW, 行) : stateW;
1061
+ return { s, 行, 印, laneId, boxW, stack: 格子 ? (s.row ?? 0) : 0 };
1062
+ });
1063
+ const 節点の中身 = 節点.map((m) => ({
1064
+ m,
1065
+ laneId: 格子 && m.col !== undefined ? `col-${m.col}` : `lane-${m.id}`,
1066
+ stack: 格子 ? (m.row ?? 0) : 0,
1067
+ }));
1068
+
1069
+ // 帯は **その列でいちばん広い箱** に合わせる
1070
+ const 帯幅 = new Map<string, number>();
1071
+ for (const c of 中身) 帯幅.set(c.laneId, Math.max(帯幅.get(c.laneId) ?? 0, c.boxW));
1072
+ for (const c of 節点の中身) 帯幅.set(c.laneId, Math.max(帯幅.get(c.laneId) ?? 0, MARK_BOX));
1073
+ const 済 = new Set<string>();
1074
+ const 帯を作る = (laneId: string): void => {
1075
+ if (済.has(laneId)) return;
1076
+ 済.add(laneId);
1077
+ b.lane(laneId, { width: boxLaneW(帯幅.get(laneId) ?? stateW) });
1078
+ };
1079
+ // 宣言の順で帯を作る = 帯の並びが図の列の並びになる。 節点も同じ格子に載る
1080
+ for (const c of 節点の中身) if (c.stack === 0) 帯を作る(c.laneId);
1081
+ for (const c of 中身) 帯を作る(c.laneId);
1082
+ for (const c of 節点の中身) 帯を作る(c.laneId);
1083
+
1084
+ // 書いた順に置く。 節点と状態を分けて置くと、記法から同じ図を書いた時に並びが合わない
1085
+ for (const o of 並び) {
1086
+ if (o.種 === "節点") {
1087
+ const c = 節点の中身[o.i]!;
1088
+ b.node(c.m.id, {
1089
+ lane: c.laneId,
1090
+ stack: c.stack,
1091
+ kind: c.m.kind === "start" ? "mark-start" : "mark-end",
1092
+ w: MARK_BOX,
1093
+ h: MARK_BOX,
1094
+ title: "",
1095
+ });
1096
+ phaseActivate.push(c.m.id);
1097
+ continue;
1098
+ }
1099
+ const c = 中身[o.i]!;
1100
+ // 初期 / 最終の札は **節点を置いた図では出さない** (#578)。 印そのものが始まりと
1101
+ // 終わりを示すので、札と二重になる
1102
+ const eyebrow = 節点.length > 0
1103
+ ? undefined
1104
+ : c.s.initial ? "初期" : c.s.final ? "最終" : "状態";
1105
+ b.node(c.s.id, {
1106
+ lane: c.laneId,
1107
+ stack: c.stack,
1108
+ kind: 設計の形 ? "storage" : "card",
1109
+ w: c.boxW,
1110
+ title: c.s.title,
1111
+ ...(eyebrow ? { eyebrow } : {}),
1112
+ ...(c.s.subtitle ? { subtitle: c.s.subtitle } : {}),
1113
+ // 行を持たない状態でも欄を書く (#578)。 書かないとその箱だけ従来の意匠に落ち、
1114
+ // 呼び名が消える
1115
+ ...(設計の形 ? { rows: c.行, rowMarks: c.印 } : {}),
1116
+ });
1117
+ phaseActivate.push(c.s.id);
1118
+ }
1119
+
1120
+ 遷移.forEach((t, currentIdx) => {
1121
+ const edgeId = `t${currentIdx}-${t.from}-${t.to}`;
1122
+ // back transition (state idx 後ろ → 前) は side=top で node 上端から出させ、
1123
+ // 上方経路で detour させて forward path と重ならないようにする。
1124
+ // CAR-468 SSOT ... label の分散は engine 側 (LABEL_INIT_CLEARANCE + fan offset) に一任する
1125
+ const fromIdx = stateOrder.get(t.from) ?? 0;
1126
+ const toIdx = stateOrder.get(t.to) ?? 0;
1127
+ const isBack = toIdx < fromIdx;
1128
+ b.edge(t.from, t.to, {
1129
+ id: edgeId,
1130
+ label: t.trigger,
1131
+ ...(t.guard ? { sub: t.guard } : {}),
1132
+ tone: t.tone ?? tone,
1133
+ style: "solid",
1134
+ // 遷移の印は 1 種類だけ = 実線に開いた矢 (#578)。 線と印が分かれず、違いは語の中に入る
1135
+ head: t.head ?? "open",
1136
+ ...(isBack ? { routing: "back-detour" as const } : {}),
1137
+ });
1138
+ phaseActivate.push(edgeId);
1139
+ });
1140
+
844
1141
  b.phase(
845
1142
  "fsm",
846
1143
  { duration: 2400, title: preset.topic, body: "FSM の全 state + transition を visible 化。" },
@@ -982,33 +1279,74 @@ export function infrastructure(preset: InfrastructurePreset): InfrastructureBuil
982
1279
 
983
1280
  // ─── classDiagram (UML class + attribute / method / relation) ─
984
1281
 
985
- export type ClassRelationType = "extends" | "implements" | "uses" | "aggregates" | "composes";
1282
+ export type ClassRelationType =
1283
+ | "extends"
1284
+ | "implements"
1285
+ | "uses"
1286
+ | "aggregates"
1287
+ | "composes"
1288
+ | "associates";
986
1289
 
987
1290
  /**
988
- * 関係の種類ごとの端の形 (cdl#574)。
1291
+ * 関係の 6 種の見た目 (cdl#574 / #578)。
989
1292
  *
990
- * `#560` で足した端の形は「関係の種類を示す」 ためのもので、この preset が第一の使い手。
991
- * 表にしないと 5 種とも同じ塗った三角になり、線の種類と語だけで区別することになる。
1293
+ * **線 / 印の形 / 印の塗り / 印が付く側** の 4 つで見分ける。 覚え方は 3 つとも 1 行で言える。
992
1294
  *
993
- * | 種類 | | 意味 |
994
- * |---|---|---|
995
- * | `extends` / `implements` | 三角 | 継ぐ |
996
- * | `aggregates` / `composes` | 菱 | 持つ |
997
- * | `uses` | 開いた矢 | 使う |
1295
+ * - **線** ... 実線 = そのものを持っている / 破線 = 約束と一時の使用
1296
+ * - **印** ... 三角 = 上下の関係 / 菱 = 所有 / 開いた矢 = 向きだけ
1297
+ * - **側** ... 印は「その印が指すもの」 の箱に付く
998
1298
  *
999
- * **`extends` `implements` が同じ形になるのは意図**。 UML では線の種類 (実線 / 破線)
1000
- * 分ける決まりで、端の形は「継ぐ」 を表す 1 種しかない。 `aggregates` と `composes` も同様に
1001
- * 塗りの有無で分けるので、端の形は同じ菱になる。
1299
+ * | 種類 | | | 塗り | 側 | 意味 |
1300
+ * |---|---|---|---|---|---|
1301
+ * | `extends` | 実線 | 三角 | 白抜き | 行き先 | 継ぐ |
1302
+ * | `implements` | 破線 | 三角 | 白抜き | 行き先 | 満たす |
1303
+ * | `aggregates` | 実線 | 菱 | 白抜き | 出どころ | 持つ |
1304
+ * | `composes` | 実線 | 菱 | 塗る | 出どころ | 抱える |
1305
+ * | `associates` | 実線 | 開いた矢 | — | 行き先 | 結ぶ |
1306
+ * | `uses` | 破線 | 開いた矢 | — | 行き先 | 使う |
1002
1307
  *
1003
- * `Record<ClassRelationType, EdgeHead>` なので、種類を足すと形も要る。
1308
+ * **側が種で割れるのが要点**。 三角は「この先が親」 を指すので行き先に立ち、菱は
1309
+ * 「この箱が持っている」 を指すので出どころに立つ。 1 つの決まりで書こうとするとどちらかが
1310
+ * 必ず逆になり、菱を逆に置くと **持ち主が入れ替わって読める**。
1311
+ *
1312
+ * `Record<ClassRelationType, ...>` なので、種類を足すと 4 つとも要る。
1004
1313
  */
1005
- export const CLASS_RELATION_HEAD: Record<ClassRelationType, EdgeHead> = {
1006
- extends: "triangle",
1007
- implements: "triangle",
1008
- uses: "open",
1009
- aggregates: "diamond",
1010
- composes: "diamond",
1011
- };
1314
+ export const CLASS_RELATION_LOOK: Record<
1315
+ ClassRelationType,
1316
+ { head: EdgeHead; fill: EdgeHeadFill; tailHead: EdgeHead; tailFill: EdgeHeadFill; style: EdgeStyle }
1317
+ > = {
1318
+ extends: { head: "triangle", fill: "hollow", tailHead: "none", tailFill: "solid", style: "solid" },
1319
+ implements: { head: "triangle", fill: "hollow", tailHead: "none", tailFill: "solid", style: "dashed" },
1320
+ aggregates: { head: "none", fill: "solid", tailHead: "diamond", tailFill: "hollow", style: "solid" },
1321
+ composes: { head: "none", fill: "solid", tailHead: "diamond", tailFill: "solid", style: "solid" },
1322
+ associates: { head: "open", fill: "solid", tailHead: "none", tailFill: "solid", style: "solid" },
1323
+ uses: { head: "open", fill: "solid", tailHead: "none", tailFill: "solid", style: "dashed" },
1324
+ };
1325
+
1326
+ /** 端の形だけを引く経路 (cdl#574 からの名前を残す)。 実体は `CLASS_RELATION_LOOK` */
1327
+ export const CLASS_RELATION_HEAD: Record<ClassRelationType, EdgeHead> = Object.fromEntries(
1328
+ Object.entries(CLASS_RELATION_LOOK).map(([k, v]) => [k, v.head]),
1329
+ ) as Record<ClassRelationType, EdgeHead>;
1330
+
1331
+ /**
1332
+ * 公開かどうかを行の頭 1 文字で見る (#578)。 `+` = 公開 / `-` = 非公開 / `#` = 継いだ先まで。
1333
+ *
1334
+ * **書かなければ公開**。 UML の既定に合わせる = 印は塗った側で出る。
1335
+ */
1336
+ function isPublic(row: string): boolean {
1337
+ return !row.startsWith("-") && !row.startsWith("#");
1338
+ }
1339
+
1340
+ /**
1341
+ * 行から公開の記号と呼び出しの括弧を落とす (#578)。
1342
+ *
1343
+ * どちらも **印が担う**ようになったので字では書かない = `+` と `-` は印の塗りが、
1344
+ * `()` は印の形 (山形) が示す。 残すと同じことを 2 回言うことになり、名前の左端も揃わない。
1345
+ */
1346
+ function stripAccess(row: string): string {
1347
+ const 本体 = isPublic(row) && !row.startsWith("+") ? row : row.slice(1);
1348
+ return 本体.replace("()", "");
1349
+ }
1012
1350
 
1013
1351
  export type UmlClass = {
1014
1352
  id: string;
@@ -1019,6 +1357,15 @@ export type UmlClass = {
1019
1357
  methods?: string[];
1020
1358
  /** stereotype (例 "abstract" / "interface" / "trait") */
1021
1359
  stereotype?: string;
1360
+ /**
1361
+ * 格子の列 (#578)。 書かなければ **宣言した順に横 1 列** で、従来どおり。
1362
+ *
1363
+ * 1 つでも書いた図は格子に置く。 **箱の 1 つの辺には関係を 1 本しか載せられない**ので、
1364
+ * 関係が 3 本以上ある箱を持つ図は段に分けないと線が重なる。
1365
+ */
1366
+ col?: number;
1367
+ /** 格子の段 (#578)。 `col` と対で書く */
1368
+ row?: number;
1022
1369
  };
1023
1370
 
1024
1371
  export type UmlRelation = {
@@ -1031,11 +1378,17 @@ export type UmlRelation = {
1031
1378
  cardinality?: string;
1032
1379
  tone?: Tone;
1033
1380
  /**
1034
- * 矢印の先の形 (cdl#574)。 書かなければ種類から決まる (`CLASS_RELATION_HEAD`)。
1381
+ * 矢印の先の形 (cdl#574)。 書かなければ種類から決まる (`CLASS_RELATION_LOOK`)。
1035
1382
  *
1036
1383
  * **書いた形が勝つ**。 表と違う形にしたい関係だけを個別に書ける。
1037
1384
  */
1038
1385
  head?: EdgeHead;
1386
+ /** 端の印の塗り (#578)。 書かなければ種類から決まる */
1387
+ headFill?: EdgeHeadFill;
1388
+ /** 出どころ側の端の印 (#578)。 書かなければ種類から決まる */
1389
+ tailHead?: EdgeHead;
1390
+ /** 線の種類 (#578)。 書かなければ種類から決まる */
1391
+ style?: EdgeStyle;
1039
1392
  };
1040
1393
 
1041
1394
  export type ClassDiagramPreset = {
@@ -1074,66 +1427,93 @@ export function classDiagram(preset: ClassDiagramPreset): ClassDiagramBuilder {
1074
1427
 
1075
1428
  const classes: string[] = [];
1076
1429
  const phaseActivate: string[] = [];
1077
- let relIdx = 0;
1430
+ // 箱と関係は **溜めてから** 組む (#578)。 格子に置く図では、列の帯幅がその列の全ての箱を
1431
+ // 見ないと決まらない = 1 件目を見た時点で `b.lane` を呼ぶと、後から来た広い箱に合わせられない
1432
+ const 箱: UmlClass[] = [];
1433
+ const 関係: UmlRelation[] = [];
1078
1434
 
1079
1435
  const api: ClassDiagramBuilder = {
1080
1436
  class(c) {
1081
- const laneId = `lane-${c.id}`;
1082
- // UML クラスは attributes と methods を区切って表示。
1083
- // v10.5 = 旧 separator "─".repeat(20) は 20 chars × 14px = 280 world で node w 強制拡張、
1084
- // class-demo の viewBox scale 5.2 倍化で label 過小化の root cause。 separator を短い
1085
- // "───" (3 chars) に短縮、 視覚区切りは維持しつつ node w 拡張を抑制する。
1086
- const rows: string[] = [];
1087
- if (c.attributes && c.attributes.length > 0) {
1088
- rows.push(...c.attributes);
1089
- }
1090
- if (c.methods && c.methods.length > 0) {
1091
- if (rows.length > 0) rows.push("───");
1092
- rows.push(...c.methods);
1093
- }
1094
- // 帯はクラスごとに決める。 行の長さで箱が広がるクラスがあるため、 全クラスを同じ帯幅で
1095
- // 宣言すると宣言座標と実座標がずれる。
1096
- const boxW = fitRowsWidth(classW, rows);
1097
- b.lane(laneId, { width: boxLaneW(boxW) });
1098
- b.node(c.id, {
1099
- lane: laneId,
1100
- stack: 0,
1101
- kind: "storage",
1102
- w: boxW,
1103
- title: c.title,
1104
- eyebrow: c.stereotype ?? "クラス",
1105
- ...(rows.length > 0 ? { rows } : {}),
1106
- });
1107
- classes.push(c.id);
1108
- phaseActivate.push(c.id);
1437
+ 箱.push(c);
1109
1438
  return api;
1110
1439
  },
1111
1440
  relation(r) {
1112
- const currentIdx = relIdx++;
1113
- const edgeId = `cr-${currentIdx}-${r.from}-${r.to}`;
1114
- const label = r.label ?? r.type;
1115
- const sub = r.cardinality;
1116
- b.edge(r.from, r.to, {
1117
- id: edgeId,
1118
- label,
1119
- ...(sub ? { sub } : {}),
1120
- tone: r.tone ?? tone,
1121
- style: "solid",
1122
- // 関係の種類から端の形を決める (cdl#574)。 書いた形が勝つ
1123
- head: r.head ?? CLASS_RELATION_HEAD[r.type],
1124
- // CAR-531 SSOT ... 旧実装は `labelOffsetY: currentIdx % 2 === 0 ? -60 : 60` で
1125
- // 複数 relation label の path 沿い集中を強制分散していた。 CAR-430 で導入した
1126
- // engine 側 fan offset (FAN_STEP 40 world) が同 from / to 群を自動分散する現在、
1127
- // author 指定 ±60 は engine 分散と二重発火して extends label が SSOT
1128
- // LABEL_INIT_CLEARANCE (34、 CAR-630 で 26 → 34 復帰) を大幅に超える 114 world 離れる bug の直接原因になっていた。
1129
- // author 指定 offset を削除、 engine 側の LABEL_INIT_CLEARANCE + fan offset に一任する。
1130
- // 個別 relation で offset を再導入したい author は future extension (UmlRelation に
1131
- // labelOffsetY? を追加) で明示的に指定する経路を残す。
1132
- });
1133
- phaseActivate.push(edgeId);
1441
+ 関係.push(r);
1134
1442
  return api;
1135
1443
  },
1136
1444
  build() {
1445
+ // 行頭の印で持ち物と振る舞いを分ける (#578)。 群の間は **空の行** で開ける = 区切りの
1446
+ // 線を引く代わりに余白で分ける。 印を持つ行は描き手が線を引かない
1447
+ const 中身 = 箱.map((c) => {
1448
+ const rows: string[] = [];
1449
+ const rowMarks: (RowMark | null)[] = [];
1450
+ for (const a of c.attributes ?? []) {
1451
+ rows.push(stripAccess(a));
1452
+ rowMarks.push({ shape: "square", filled: isPublic(a) });
1453
+ }
1454
+ if ((c.attributes?.length ?? 0) > 0 && (c.methods?.length ?? 0) > 0) {
1455
+ rows.push("");
1456
+ rowMarks.push(null);
1457
+ }
1458
+ for (const m of c.methods ?? []) {
1459
+ rows.push(stripAccess(m));
1460
+ rowMarks.push({ shape: "chevron", filled: isPublic(m) });
1461
+ }
1462
+ // 帯はクラスごとに決める。 行の長さで箱が広がるクラスがあるため、 全クラスを同じ帯幅で
1463
+ // 宣言すると宣言座標と実座標がずれる。
1464
+ const boxW = fitRowsWidth(classW, rows);
1465
+ // 格子に置くかは `col` を書いたかで決める (#578)。 書かない図は従来どおり横 1 列
1466
+ const laneId = c.col !== undefined ? `col-${c.col}` : `lane-${c.id}`;
1467
+ return { c, rows, rowMarks, boxW, laneId, stack: c.col !== undefined ? (c.row ?? 0) : 0 };
1468
+ });
1469
+
1470
+ // 帯は **その列でいちばん広い箱** に合わせる。 狭い方に合わせると宣言座標と実座標がずれる
1471
+ const 帯幅 = new Map<string, number>();
1472
+ for (const m of 中身) 帯幅.set(m.laneId, Math.max(帯幅.get(m.laneId) ?? 0, m.boxW));
1473
+ // 宣言の順で帯を作る。 帯の並びが図の列の並びになる
1474
+ const 済 = new Set<string>();
1475
+ for (const m of 中身) {
1476
+ if (済.has(m.laneId)) continue;
1477
+ 済.add(m.laneId);
1478
+ b.lane(m.laneId, { width: boxLaneW(帯幅.get(m.laneId) ?? m.boxW) });
1479
+ }
1480
+
1481
+ for (const m of 中身) {
1482
+ b.node(m.c.id, {
1483
+ lane: m.laneId,
1484
+ stack: m.stack,
1485
+ kind: "storage",
1486
+ w: m.boxW,
1487
+ title: m.c.title,
1488
+ // 種類の札は **書いた時だけ** 出す (#578)。 「クラス」 と書いた札は箱がクラスで
1489
+ // あることしか言わず、図の中の全ての箱に同じ字が並ぶ
1490
+ ...(m.c.stereotype ? { eyebrow: m.c.stereotype } : {}),
1491
+ ...(m.rows.length > 0 ? { rows: m.rows, rowMarks: m.rowMarks } : {}),
1492
+ });
1493
+ classes.push(m.c.id);
1494
+ phaseActivate.push(m.c.id);
1495
+ }
1496
+
1497
+ 関係.forEach((r, currentIdx) => {
1498
+ const edgeId = `cr-${currentIdx}-${r.from}-${r.to}`;
1499
+ const 見た目 = CLASS_RELATION_LOOK[r.type];
1500
+ b.edge(r.from, r.to, {
1501
+ id: edgeId,
1502
+ label: r.label ?? r.type,
1503
+ ...(r.cardinality ? { sub: r.cardinality } : {}),
1504
+ tone: r.tone ?? tone,
1505
+ // 関係の種類から線と端の形を決める (cdl#574 / #578)。 書いた値が勝つ
1506
+ style: r.style ?? 見た目.style,
1507
+ head: r.head ?? 見た目.head,
1508
+ headFill: r.headFill ?? 見た目.fill,
1509
+ tailHead: r.tailHead ?? 見た目.tailHead,
1510
+ tailHeadFill: 見た目.tailFill,
1511
+ // CAR-531 SSOT ... label の分散は engine 側 (LABEL_INIT_CLEARANCE + fan offset) に
1512
+ // 一任する。 author 指定 ±60 は engine 分散と二重発火して label が大きく離れる
1513
+ });
1514
+ phaseActivate.push(edgeId);
1515
+ });
1516
+
1137
1517
  b.phase(
1138
1518
  "class",
1139
1519
  { duration: 2400, title: preset.topic, body: "UML class 全 class + relation を visible 化。" },