@cardenelabs/dragon 0.26.0 → 0.28.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/compile.ts CHANGED
@@ -150,6 +150,18 @@ export type CompileNotice = {
150
150
  | "direction-not-honored"
151
151
  // `倍率:` を書いた見本が、同じ名前の状態も持っていた (#1026)
152
152
  | "scale-reserved"
153
+ // 部品に書いた色が効かない (#1973)。 色番号を入れる状態を 1 つも持たない部品に色番号を
154
+ // 書いた時と、色番号でなく色の名前を書いた時
155
+ | "part-color-ignored"
156
+ // 部品に、部品が持たない状態の名前で値を書いた (#1976)。 綴り違いと、外した欄 (`nodes`) を書いた時
157
+ | "part-state-missing"
158
+ // 部品へ引いた矢印を、部品の中のどの要素にも繋げず外した (#1979)。 要素が 2 つ以上あり名指しが無い時、
159
+ // 名指しした要素が部品に無い時、部品を取り込まなかった時
160
+ | "part-edge-dropped"
161
+ // 矢印に部品の要素の名指し (`fromPartNode` / `toPartNode`) を書いたが、その端が部品でない (#1979)
162
+ | "part-node-ignored"
163
+ // 縦列に置くはずの部品が他の箱の位置の基準になっていて、縦列に置けなかった (#1980)
164
+ | "part-lane-ignored"
153
165
  // 値で描く図 (`pie` / `bar` / `line`) で値を読めなかった (#1154)
154
166
  | "chart-value-unreadable"
155
167
  // 同上で矢印を書いた。 これらの図は関係を描けない (#1154)
@@ -182,10 +194,14 @@ export type CompileNotice = {
182
194
  | "event-target-missing"
183
195
  // 順序図で面に種類を書いたが、板は名前と呼び名しか描かない (#1466)
184
196
  | "actor-kind-not-honored"
185
- // 箱の中の小さな箱に位置を書いたが、その名前の箱が図に無かった (#1466)
186
- | "sub-node-not-found"
187
197
  // 順序図の言づてに、板が描かない飾り (色味 / 添え字 / 寄せ) を書いた (#1466)
188
- | "message-option-not-honored";
198
+ | "message-option-not-honored"
199
+ // 位置のずらし (`pos` / `offsetX` / `offsetY`) を載せる相手が無いか、書いた量だけ動かせなかった (#1971)
200
+ | "position-offset-ignored"
201
+ // 組 (`groups:`) が束ねる縦列が図に無い (#1972)
202
+ | "group-lane-missing"
203
+ // 組が束ねる縦列の間に、束ねない縦列を挟んでいる (#1972)
204
+ | "group-lanes-apart";
189
205
  /** 対象の名前。 光らせる相手なら書かれた指定そのまま */
190
206
  actor: string;
191
207
  /** 書かれていた行 */
@@ -226,7 +242,7 @@ export function compileToCdl(doc: DslDocument, opts?: CompileToCdlOpts): CdlDiag
226
242
  diagram = compileSequence(doc);
227
243
  break;
228
244
  case "flow":
229
- diagram = compileFlow(doc);
245
+ diagram = compileFlow(doc, opts?.partsCatalog);
230
246
  break;
231
247
  case "swimlane":
232
248
  diagram = compileSwimlane(doc);
@@ -306,8 +322,11 @@ export function compileToCdl(doc: DslDocument, opts?: CompileToCdlOpts): CdlDiag
306
322
  // edge と本文の行の対応は表に集めてから 1 edge = 1 回で知らせる (#998)。 経路ごとに
307
323
  // その場で呼ぶと、 同じ edge に別の行を 2 度知らせることになる。
308
324
  const edgeSourceLines = opts?.onEdgeSource ? new Map<string, number>() : undefined;
309
- applyEdgeInlineOptions(diagram, doc, edgeSourceLines);
310
- applyGroupContainers(diagram, doc);
325
+ // 矢印ごとに、どの行から来たか。 部品へ引いた矢印を部品の要素へ繋ぎ直す時に、その行に書いた
326
+ // 名指し (`toPartNode`) を読む (#1979)。 矢印の id は部品の取り込みで変わらない
327
+ const 矢印の行 = new Map<string, DslStep>();
328
+ applyEdgeInlineOptions(diagram, doc, edgeSourceLines, 矢印の行, opts?.partsCatalog);
329
+ const 作った組の枠 = applyGroupContainers(diagram, doc);
311
330
  applyNodeTones(diagram, doc);
312
331
  // 光らせる相手が実在するかを確かめる。 id への解決は図種ごとに違うが、 名前が居るか
313
332
  // 居ないかは記述だけで決まるので 1 か所で見る
@@ -317,19 +336,21 @@ export function compileToCdl(doc: DslDocument, opts?: CompileToCdlOpts): CdlDiag
317
336
  // 両端が同じ矢印を伝える (#1227)。 落とす前の `flow` を見る
318
337
  // 静止した `type: flow` で書いた矢印の端が使われないことを伝える (#1269)。
319
338
  // 自分へ戻る形と居ない名前を指す形は既に落ちた後の `doc` を見る = 上の 2 件と重ねない
320
- reportFlowEndpointNotHonored(doc, 分けた.元の名前, opts?.onNotice);
339
+ reportFlowEndpointNotHonored(doc, 分けた.元の名前, opts?.onNotice, opts?.partsCatalog);
321
340
  reportLaneNotHonored(書いたまま, opts?.onNotice);
322
341
  reportActorKindNotHonored(書いたまま, opts?.onNotice);
323
342
  reportMessageOptionNotHonored(書いたまま, opts?.onNotice);
343
+ reportFlowOffsetNotHonored(書いたまま, opts?.onNotice);
324
344
  reportDocEyebrowNotHonored(書いたまま, opts?.onNotice);
325
345
  reportDirectionNotHonored(書いたまま, opts?.onNotice);
326
346
  reportDrawNotHonored(書いたまま, opts?.onNotice);
327
347
  reportChartFieldsNotHonored(書いたまま, opts?.onNotice);
328
348
  reportAxesNotHonored(書いたまま, opts?.onNotice);
349
+ reportPartNodeNotHonored(書いたまま, opts?.onNotice);
329
350
  // `位置: Web の右` を実際の配置から絶対座標に直す。 以降は座標を直接書いた時と同じ経路
330
351
  const placed = resolveRelativeDoc(diagram, doc, opts?.onNotice, opts?.partsCatalog);
331
352
  // canvas pivot 新 spec = 全 preset 共通の post-process で actor.posX/Y を CDL lane / node に伝播
332
- applyCanvasPivotPositions(diagram, placed, opts?.onNotice);
353
+ applyCanvasPivotPositions(diagram, placed);
333
354
  // CAR-1657 = parts kind actor を merge (opts.partsCatalog 経由)、 applyV05Extensions 後段で実行
334
355
  const 追加した縦列: DslLane[] = [];
335
356
  const extended = applyV05Extensions(diagram, placed, 追加した縦列);
@@ -345,6 +366,7 @@ export function compileToCdl(doc: DslDocument, opts?: CompileToCdlOpts): CdlDiag
345
366
  opts?.partsCatalog,
346
367
  opts?.onNotice,
347
368
  inheritedDerivedSourceLines,
369
+ 矢印の行,
348
370
  );
349
371
  // 箱が 1 つも入らなかった縦列を伝える (#1241)。
350
372
  //
@@ -385,6 +407,7 @@ export function compileToCdl(doc: DslDocument, opts?: CompileToCdlOpts): CdlDiag
385
407
  * どれかを見落とす (`injectStaticPhase` / `materializeStates` と同じ理由)。
386
408
  */
387
409
  if (doc.reveal !== undefined) merged.edgeReveal = doc.reveal;
410
+ if (doc.relations !== undefined) merged.relationFocus = doc.relations;
388
411
  // 語の欄が状態を読むとき、その状態には記法の語が入っている。 図の語へ直す (#1201)
389
412
  語の状態を図の語へ直す(merged);
390
413
  // きっかけ形の値を段の時計を読む式へ畳む (#1161 段 2)。 **値を載せるより先に呼ぶ** =
@@ -425,6 +448,8 @@ export function compileToCdl(doc: DslDocument, opts?: CompileToCdlOpts): CdlDiag
425
448
  const ownFormulas = doc.formulas.map((formula) => ({
426
449
  id: formula.id,
427
450
  expression: formula.expression,
451
+ // 名札は操作部が式の名前として描く (#1916、cdl#853)。 書かなければ欄ごと付けない
452
+ ...(formula.label !== undefined ? { label: formula.label } : {}),
428
453
  line: formula.pos?.line ?? 0,
429
454
  }));
430
455
  /*
@@ -484,7 +509,11 @@ export function compileToCdl(doc: DslDocument, opts?: CompileToCdlOpts): CdlDiag
484
509
  });
485
510
  }
486
511
  if (解けない) continue;
487
- 載せる式.push({ id: formula.id, expression: formula.expression });
512
+ 載せる式.push({
513
+ id: formula.id,
514
+ expression: formula.expression,
515
+ ...(formula.label !== undefined ? { label: formula.label } : {}),
516
+ });
488
517
  先に書かれた式.add(formula.id);
489
518
  }
490
519
  if (載せる式.length > 0) merged.formulas = 載せる式;
@@ -551,6 +580,12 @@ export function compileToCdl(doc: DslDocument, opts?: CompileToCdlOpts): CdlDiag
551
580
  // 20 か所以上あり、そのどれに足しても残りが取り残される
552
581
  配色と縞を当てる(merged, doc);
553
582
  静止した図の焦点を外す(merged, doc);
583
+ // 位置のずらしは **配置に効く欄を全て載せた後** に当てる (#1971)。 縦列の幅や視点の間隔を
584
+ // 足す前に測ると、後から足された分だけ狙いがずれる
585
+ applyLayoutOffsets(merged, doc, opts?.onNotice);
586
+ // 組の枠は **縦列の位置が全て決まった後** に置く (#1972)。 位置のずらしより前に置くと、
587
+ // ずらした縦列から枠が離れる
588
+ applyGroupFrames(merged, doc, 作った組の枠, opts?.onNotice);
554
589
  return merged;
555
590
  }
556
591
 
@@ -907,7 +942,7 @@ function reportDrawNotHonored(doc: DslDocument, onNotice?: (n: CompileNotice) =>
907
942
  * 段の `draw:` が効く図種 (#1312 / #1314 / #1318)。
908
943
  *
909
944
  * **語と図種の対応表から導く** (`DRAW_TARGETS`)。 一覧を写すと、語を足した時に片方だけ
910
- * 古いまま残る。 描画側 (`cdl` の `CdlPhase.draw`) が対応する 8 種と一致する。
945
+ * 古いまま残る。 描画側 (`cdl` の `DRAW_KINDS`) が対応する種別と一致する。
911
946
  */
912
947
  const DRAWABLE_DOC_TYPES: ReadonlySet<PresetType> = new Set<PresetType>(DRAW_TARGETS.values());
913
948
 
@@ -946,7 +981,8 @@ function reportLaneMixed(doc: DslDocument, onNotice: (n: CompileNotice) => void)
946
981
  * ## 鎖にすること自体は変えない
947
982
  *
948
983
  * 線形の流れを描く図種なので、鎖にするのは仕様。 書いた端どおりに繋ぎたい形は
949
- * 箱に `lane:` を書けば別の経路へ回る (#1266)。 知らせの hint でそれを案内する。
984
+ * 箱に `lane:` を書くか (#1266)、`direction:` を書けば別の経路へ回る (#1494)。 知らせの hint で
985
+ * それを案内する。 `direction: 縦` を先に挙げるのは、箱ごとに書かずに済み、並びも鎖の時と変わらないため。
950
986
  *
951
987
  * ## 偶然一致する形では知らせない
952
988
  *
@@ -964,6 +1000,8 @@ function reportFlowEndpointNotHonored(
964
1000
  doc: DslDocument,
965
1001
  元の名前: Map<string, string>,
966
1002
  onNotice?: (n: CompileNotice) => void,
1003
+ /** 鎖に並べない部品を決める一覧 (#1987)。 `compileFlow` に渡したものと同じ */
1004
+ partsCatalog?: Record<string, CdlDiagram>,
967
1005
  ): void {
968
1006
  if (!onNotice) return;
969
1007
  if (!鎖でつなぐ形か(doc)) return;
@@ -975,8 +1013,9 @@ function reportFlowEndpointNotHonored(
975
1013
  // 鎖が作る組を集める。 登場人物が 1 人以下なら矢印が 1 本も出来ないので、
976
1014
  // 書いた矢印は全て使われない扱いになる
977
1015
  const 鎖の組 = new Set<string>();
978
- doc.actors.forEach((a, i) => {
979
- const 次 = doc.actors[i + 1];
1016
+ const 並び = 鎖に並べる登場人物(doc, partsCatalog);
1017
+ 並び.forEach((a, i) => {
1018
+ const 次 = 並び[i + 1];
980
1019
  if (次 === undefined) return;
981
1020
  鎖の組.add(`${a.name}\u0000${次.name}`);
982
1021
  });
@@ -987,7 +1026,7 @@ function reportFlowEndpointNotHonored(
987
1026
  actor: 書いた名前(s.from),
988
1027
  line: s.pos.line,
989
1028
  message: `"${truncateForMessage(書いた名前(s.from))} -> ${truncateForMessage(書いた名前(s.to))}" の端は使われません (type: flow は登場人物を書いた順に繋ぎます)`,
990
- hint: "書いた端どおりに繋ぐには、箱に lane: を書いてください (縦列を書いた形は書いた端がそのまま矢印になります)",
1029
+ hint: "書いた端どおりに繋ぐには、direction: 縦 を書くか箱に lane: を書いてください (向きか縦列を書いた形は書いた端がそのまま矢印になります)",
991
1030
  });
992
1031
  }
993
1032
  }
@@ -1039,6 +1078,10 @@ function reportMessageOptionNotHonored(doc: DslDocument, onNotice?: (n: CompileN
1039
1078
  s.tone !== undefined ? "色味" : "",
1040
1079
  s.sub !== undefined ? "添え字" : "",
1041
1080
  s.side !== undefined ? "寄せ" : "",
1081
+ // 名前のずらしも板には載せる先が無い (#1971)。 位置のずらし (`pos`) は名前のずらしに足す欄なので同じ扱い
1082
+ s.layoutPos !== undefined || s.labelOffsetX !== undefined || s.labelOffsetY !== undefined
1083
+ ? "名前のずらし"
1084
+ : "",
1042
1085
  ].filter((x) => x !== "");
1043
1086
  if (効かない.length === 0) continue;
1044
1087
  onNotice({
@@ -1051,6 +1094,28 @@ function reportMessageOptionNotHonored(doc: DslDocument, onNotice?: (n: CompileN
1051
1094
  }
1052
1095
  }
1053
1096
 
1097
+ /**
1098
+ * 名前の無い矢印に書いた位置のずらしを知らせる (#1971)。
1099
+ *
1100
+ * 矢印の位置のずらしは名前をずらす欄に足す。 名前が無い矢印では動くものが無く、書いても図は
1101
+ * 変わらない。 順序図の板は `reportMessageOptionNotHonored` が知らせるので、ここでは見ない。
1102
+ */
1103
+ function reportFlowOffsetNotHonored(doc: DslDocument, onNotice?: (n: CompileNotice) => void): void {
1104
+ if (!onNotice) return;
1105
+ if (doc.type === "sequence" || doc.type === "solidity") return;
1106
+ for (const s of doc.flow) {
1107
+ if (s.layoutPos === undefined) continue;
1108
+ if (s.label !== undefined && s.label.trim() !== "") continue;
1109
+ onNotice({
1110
+ kind: "position-offset-ignored",
1111
+ actor: s.from,
1112
+ line: s.pos?.line ?? 0,
1113
+ message: `${s.from} -> ${s.to} の位置のずらしは、名前の無い矢印では動かすものがありません`,
1114
+ hint: "矢印の位置のずらしは名前をずらします。 名前を書くか、ずらしを外す",
1115
+ });
1116
+ }
1117
+ }
1118
+
1054
1119
  function reportActorKindNotHonored(doc: DslDocument, onNotice?: (n: CompileNotice) => void): void {
1055
1120
  if (!onNotice) return;
1056
1121
  if (doc.type !== "sequence" && doc.type !== "solidity") return;
@@ -1068,7 +1133,13 @@ function reportActorKindNotHonored(doc: DslDocument, onNotice?: (n: CompileNotic
1068
1133
  */
1069
1134
  doc.type === "sequence" && a.kindWritten !== false && a.kind !== undefined ? "種類" : "",
1070
1135
  a.posW !== undefined || a.posH !== undefined ? "大きさ" : "",
1071
- a.posX !== undefined || a.posY !== undefined || a.posRel !== undefined ? "位置" : "",
1136
+ a.posX !== undefined ||
1137
+ a.posY !== undefined ||
1138
+ a.posRel !== undefined ||
1139
+ // 位置のずらし (#1971)。 板は面ごとの箱を持たないので動かす相手が無い
1140
+ a.layoutPos !== undefined
1141
+ ? "位置"
1142
+ : "",
1072
1143
  a.rows !== undefined ? "行" : "",
1073
1144
  a.tone !== undefined ? "色" : "",
1074
1145
  a.eyebrow !== undefined ? "小見出し" : "",
@@ -1789,6 +1860,309 @@ function withPositions(
1789
1860
  return probe;
1790
1861
  }
1791
1862
 
1863
+ /** ずらしを当てる相手 1 つ。 狙いは配置後の位置 (箱は中心、縦列は左上) */
1864
+ type ずらしの相手 = {
1865
+ 名前: string;
1866
+ line: number;
1867
+ 箱: { id: string; 狙いX: number; 狙いY: number }[];
1868
+ 縦列: { id: string; 狙いX: number; 狙いY: number }[];
1869
+ /** 縦列に書いたずらしの相手なら、その縦列の id */
1870
+ ずらした縦列?: string;
1871
+ };
1872
+
1873
+ /**
1874
+ * 書いた位置のずらし (JSON の `pos` / 記法の `offsetX` / `offsetY`) を、配置後の位置に足して置き直す (#1971)。
1875
+ *
1876
+ * **最後の図の上で置き直す**。 配置は縦列の間隔や視点の間隔を足した後でないと決まらず、途中の
1877
+ * 図で測ると、後から足される間隔の分だけ狙いがずれる。
1878
+ *
1879
+ * 手順は 3 つ。
1880
+ *
1881
+ * 1. 1 度配置して、ずらす箱の中心と縦列の左上を測る
1882
+ * 2. 狙い (測った位置 + ずらし) を `posX` / `posY` に書いてもう 1 度配置する
1883
+ * 3. 狙いからずれた分を足して直し (最大 2 回)、それでも 1 を超えてずれる相手は固定を戻して知らせる
1884
+ *
1885
+ * **縦列は 1 本でもずらすなら全ての縦列を固定する**。 1 本だけ固定すると、残りの縦列が左端から
1886
+ * 詰め直されて崩れる (実測 = 3 本のうち最初を動かすと 2 本目が 0 に来た)。 固定する位置と大きさは
1887
+ * 配置後の値なので、ずらさない縦列の配置は変わらない (実測 = 6 図種で一致)。
1888
+ *
1889
+ * **ずらした縦列の箱も同じ量で固定し、他の箱は動いたら元の位置に留める**。 縦列を固定しただけでは、
1890
+ * 中の箱は縦列の並ぶ向きにしか付いて来ず (`topology` / `c4` は縦列自身も縦に 32 ずれた)、最初の
1891
+ * 縦列を縦にずらすと他の縦列の箱まで動く (実測)。 動いた箱を留めるのは手順 3 の直し。
1892
+ *
1893
+ * **近すぎる箱を押し下げるのは止めない**。 ずらした箱に同じ縦列の箱が近づくと、描画側が間隔を保つよう
1894
+ * そちらを動かす (実測 = 静止した flow で下の箱が 100 下がった)。 これは描画側の規則で、ずらした箱
1895
+ * 自身は狙いに置かれる。
1896
+ *
1897
+ * ずらしを持つ相手が 1 つも無い図は何もしない = 配置を 1 度も計算しない。
1898
+ */
1899
+ function applyLayoutOffsets(
1900
+ diagram: CdlDiagram,
1901
+ doc: DslDocument,
1902
+ onNotice?: (notice: CompileNotice) => void,
1903
+ ): void {
1904
+ // `mind` は放射に描けない欄として「配置のずらし」 を既に知らせる (`放射で描けない欄の名前`)。 2 度知らせない
1905
+ const ずらす箱 =
1906
+ doc.type === "mind" ? [] : doc.actors.filter((a) => a.layoutPos !== undefined);
1907
+ const ずらす縦列 = Object.entries(doc.lanes ?? {}).filter(([, l]) => l.layoutPos !== undefined);
1908
+ if (ずらす箱.length === 0 && ずらす縦列.length === 0) return;
1909
+ // 順序図の板は面ごとの箱を持たない。 効かないことは `reportActorKindNotHonored` が伝える
1910
+ if (doc.type === "sequence" || doc.type === "solidity") return;
1911
+
1912
+ const 前 = layout(diagram);
1913
+ const 箱の配置 = new Map(前.nodes.map((n) => [n.id, n] as const));
1914
+ const 縦列の配置 = new Map(前.lanes.map((l) => [l.id, l] as const));
1915
+ const 相手たち: ずらしの相手[] = [];
1916
+ const 縦列のずらし = new Map<string, { x: number; y: number }>();
1917
+ /** 箱のずらしだけの狙い (縦列のずらしを足さない)。 縦列をずらす図で、ずらさない箱の基準を取るのに使う */
1918
+ const 箱だけの狙い = new Map<string, { x: number; y: number }>();
1919
+
1920
+ for (const [id, lane] of ずらす縦列) {
1921
+ const laid = 縦列の配置.get(id);
1922
+ const d = lane.layoutPos!;
1923
+ if (!laid) {
1924
+ onNotice?.({
1925
+ kind: "position-offset-ignored",
1926
+ actor: id,
1927
+ line: lane.pos?.line ?? 0,
1928
+ message: `縦列 "${id}" の位置のずらしを載せる縦列が図にありません (type: ${doc.type})`,
1929
+ hint: "縦列を作る図種で書くか、ずらしを外す",
1930
+ });
1931
+ continue;
1932
+ }
1933
+ 縦列のずらし.set(id, d);
1934
+ 相手たち.push({
1935
+ 名前: id,
1936
+ line: lane.pos?.line ?? 0,
1937
+ // 縦列を固定しただけでは、中の箱は縦列の並ぶ向きにしか付いて来ない (実測 = flow の縦列を
1938
+ // 右へ 120、下へ 40 ずらすと、見出しは両方動き箱は右へ 120 だけ動いた)。 箱も同じ量で固定する
1939
+ 箱: diagram.nodes.flatMap((n) => {
1940
+ const 箱laid = n.lane === id ? 箱の配置.get(n.id) : undefined;
1941
+ return 箱laid ? [{ id: n.id, 狙いX: 箱laid.cx + d.x, 狙いY: 箱laid.cy + d.y }] : [];
1942
+ }),
1943
+ 縦列: [{ id, 狙いX: (laid.x ?? 0) + d.x, 狙いY: (laid.y ?? 0) + d.y }],
1944
+ ずらした縦列: id,
1945
+ });
1946
+ }
1947
+
1948
+ for (const a of ずらす箱) {
1949
+ const d = a.layoutPos!;
1950
+ // 見本は `{名前}__{元の id}` で重なる。 箱と縦列を見本 1 つ分まとめて動かす
1951
+ const 前置き = `${a.name}__`;
1952
+ const 箱 =
1953
+ a.partId !== undefined
1954
+ ? diagram.nodes.filter((n) => n.id.startsWith(前置き))
1955
+ : diagram.nodes.filter((n) => n.id === slugify(a.name));
1956
+ const 縦列 = a.partId !== undefined ? diagram.lanes.filter((l) => l.id.startsWith(前置き)) : [];
1957
+ if (箱.length === 0) {
1958
+ onNotice?.({
1959
+ kind: "position-offset-ignored",
1960
+ actor: a.name,
1961
+ line: a.pos?.line ?? 0,
1962
+ message: `"${truncateForMessage(a.name)}" の位置のずらしを載せる箱が図にありません (type: ${doc.type})`,
1963
+ hint: "箱を描く図種で書くか、ずらしを外す",
1964
+ });
1965
+ continue;
1966
+ }
1967
+ 相手たち.push({
1968
+ 名前: a.name,
1969
+ line: a.pos?.line ?? 0,
1970
+ 箱: 箱.map((n) => {
1971
+ const laid = 箱の配置.get(n.id)!;
1972
+ 箱だけの狙い.set(n.id, { x: laid.cx + d.x, y: laid.cy + d.y });
1973
+ // 箱を固定すると縦列を動かしても付いて来ないので、縦列のずらしも狙いに足す
1974
+ const 列 = 縦列のずらし.get(n.lane);
1975
+ return { id: n.id, 狙いX: laid.cx + d.x + (列?.x ?? 0), 狙いY: laid.cy + d.y + (列?.y ?? 0) };
1976
+ }),
1977
+ 縦列: 縦列.map((l) => {
1978
+ const laid = 縦列の配置.get(l.id)!;
1979
+ return { id: l.id, 狙いX: (laid.x ?? 0) + d.x, 狙いY: (laid.y ?? 0) + d.y };
1980
+ }),
1981
+ });
1982
+ }
1983
+ if (相手たち.length === 0) return;
1984
+
1985
+ // 固定を戻せるよう、書き換える前の欄を控える
1986
+ const 元の箱 = new Map(diagram.nodes.map((n) => [n.id, { posX: n.posX, posY: n.posY }] as const));
1987
+ const 元の縦列 = new Map(
1988
+ diagram.lanes.map((l) => [l.id, { posX: l.posX, posY: l.posY, posW: l.posW, posH: l.posH }] as const),
1989
+ );
1990
+ const 箱の狙い = new Map<string, { x: number; y: number }>();
1991
+ const 縦列の狙い = new Map<string, { x: number; y: number }>();
1992
+ for (const t of 相手たち) {
1993
+ for (const b of t.箱) 箱の狙い.set(b.id, { x: b.狙いX, y: b.狙いY });
1994
+ for (const l of t.縦列) 縦列の狙い.set(l.id, { x: l.狙いX, y: l.狙いY });
1995
+ }
1996
+ const 縦列を固定する = 縦列の狙い.size > 0;
1997
+
1998
+ /**
1999
+ * 縦列をずらす図で、ずらさない箱を留める位置。
2000
+ *
2001
+ * 描画側は箱を並べ始める高さを最初の縦列に合わせるので、最初の縦列を縦にずらすと固定しない箱が
2002
+ * 全て一緒に動く (実測 = 3 本のうち最初の縦列を 40 下げると他の縦列の箱も 40 下がり、2 本目と
2003
+ * 3 本目を下げても動かない)。 箱のずらしだけを当てた配置を基準に取り、そこから動いた箱をその位置に
2004
+ * 留める。 基準に箱のずらしを含めるのは、近すぎる箱の押し下げを残すため。
2005
+ */
2006
+ const 留める位置 = new Map<string, { x: number; y: number }>();
2007
+ if (縦列を固定する) {
2008
+ let 基準 = 前;
2009
+ if (箱だけの狙い.size > 0) {
2010
+ for (const node of diagram.nodes) {
2011
+ const 狙い = 箱だけの狙い.get(node.id);
2012
+ if (!狙い) continue;
2013
+ node.posX = 狙い.x;
2014
+ node.posY = 狙い.y;
2015
+ }
2016
+ 基準 = layout(diagram);
2017
+ for (const node of diagram.nodes) {
2018
+ if (!箱だけの狙い.has(node.id)) continue;
2019
+ const 元 = 元の箱.get(node.id)!;
2020
+ node.posX = 元.posX;
2021
+ node.posY = 元.posY;
2022
+ }
2023
+ }
2024
+ const 基準の箱 = new Map(基準.nodes.map((n) => [n.id, n] as const));
2025
+ // 縦列と一緒に動かす箱は、押し下げを含む基準の位置から縦列のずらしだけ動かす
2026
+ for (const t of 相手たち) {
2027
+ const d = t.ずらした縦列 === undefined ? undefined : 縦列のずらし.get(t.ずらした縦列);
2028
+ if (!d) continue;
2029
+ for (const b of t.箱) {
2030
+ const n = 基準の箱.get(b.id);
2031
+ if (!n || 箱だけの狙い.has(b.id)) continue;
2032
+ 箱の狙い.set(b.id, { x: n.cx + d.x, y: n.cy + d.y });
2033
+ }
2034
+ }
2035
+ for (const n of 基準.nodes) {
2036
+ if (!箱の狙い.has(n.id)) 留める位置.set(n.id, { x: n.cx, y: n.cy });
2037
+ }
2038
+ }
2039
+ /** 基準から動いたので固定した、ずらさない箱 */
2040
+ const 留めた箱 = new Set<string>();
2041
+
2042
+ // 手順 2。 縦列を固定する時は、ずらさない縦列も配置後の位置と大きさで固定する
2043
+ const 書く = (直し: ReadonlyMap<string, { x: number; y: number }>): void => {
2044
+ if (縦列を固定する) {
2045
+ for (const lane of diagram.lanes) {
2046
+ const laid = 縦列の配置.get(lane.id);
2047
+ if (!laid) continue;
2048
+ const 狙い = 縦列の狙い.get(lane.id) ?? { x: laid.x ?? 0, y: laid.y ?? 0 };
2049
+ const 差 = 直し.get(`lane:${lane.id}`) ?? { x: 0, y: 0 };
2050
+ lane.posX = 狙い.x - 差.x;
2051
+ lane.posY = 狙い.y - 差.y;
2052
+ lane.posW = laid.width;
2053
+ lane.posH = laid.height;
2054
+ }
2055
+ }
2056
+ for (const node of diagram.nodes) {
2057
+ const 狙い = 箱の狙い.get(node.id);
2058
+ const 留める = 留めた箱.has(node.id) ? 留める位置.get(node.id) : undefined;
2059
+ if (!狙い && !留める) continue;
2060
+ const 差 = 直し.get(狙い ? `node:${node.id}` : `keep:${node.id}`) ?? { x: 0, y: 0 };
2061
+ node.posX = (狙い ?? 留める)!.x - 差.x;
2062
+ node.posY = (狙い ?? 留める)!.y - 差.y;
2063
+ }
2064
+ };
2065
+ /** 狙いと実際の差。 縦列を固定する時は、ずらさない縦列と箱も元の位置に居るかを見る */
2066
+ const 測る = (): Map<string, { x: number; y: number }> => {
2067
+ const 後 = layout(diagram);
2068
+ const 差 = new Map<string, { x: number; y: number }>();
2069
+ for (const n of 後.nodes) {
2070
+ const 狙い = 箱の狙い.get(n.id);
2071
+ if (狙い) 差.set(`node:${n.id}`, { x: n.cx - 狙い.x, y: n.cy - 狙い.y });
2072
+ const 留める = 留める位置.get(n.id);
2073
+ if (留める) 差.set(`keep:${n.id}`, { x: n.cx - 留める.x, y: n.cy - 留める.y });
2074
+ }
2075
+ if (縦列を固定する) {
2076
+ for (const l of 後.lanes) {
2077
+ const laid = 縦列の配置.get(l.id);
2078
+ if (!laid) continue;
2079
+ const 狙い = 縦列の狙い.get(l.id) ?? { x: laid.x ?? 0, y: laid.y ?? 0 };
2080
+ 差.set(`lane:${l.id}`, { x: (l.x ?? 0) - 狙い.x, y: (l.y ?? 0) - 狙い.y });
2081
+ }
2082
+ }
2083
+ return 差;
2084
+ };
2085
+ const ずれた = (差: { x: number; y: number } | undefined): boolean =>
2086
+ 差 !== undefined && (Math.abs(差.x) > PLACEMENT_TOLERANCE || Math.abs(差.y) > PLACEMENT_TOLERANCE);
2087
+
2088
+ const 直し = new Map<string, { x: number; y: number }>();
2089
+ 書く(直し);
2090
+ let 差 = 測る();
2091
+ // 手順 3。 狙いからの差を書いた位置から引いて置き直す。 固定した相手の差は図種ごとに一定なので
2092
+ // 1 度で合う。 基準から動いた箱はその回に初めて固定し、固定で出る差をもう 1 度で直す。
2093
+ //
2094
+ // **ずらす箱と縦列そのものの差 (`node:` / `lane:`) を直す入力は組めていない** (#1971 の実測)。
2095
+ // 縦列だけを固定していた形では `topology` / `c4` の縦列が縦に 32 ずれたが、中の箱も固定する今の形
2096
+ // ではずれない。 試した形 = 7 図種の箱 / 全ての縦列を 1 本ずつ / 箱の無い縦列。 実際に直すのは
2097
+ // 留める箱 (`keep:`) で、最初の縦列を縦にずらした図が通る
2098
+ for (let 回 = 0; 回 < 2 && [...差.values()].some(ずれた); 回++) {
2099
+ for (const [k, v] of 差) {
2100
+ if (!ずれた(v)) continue;
2101
+ const id = k.slice(k.indexOf(":") + 1);
2102
+ if (k.startsWith("keep:") && !留めた箱.has(id)) {
2103
+ 留めた箱.add(id);
2104
+ continue;
2105
+ }
2106
+ const 前の直し = 直し.get(k) ?? { x: 0, y: 0 };
2107
+ 直し.set(k, { x: 前の直し.x + v.x, y: 前の直し.y + v.y });
2108
+ }
2109
+ 書く(直し);
2110
+ 差 = 測る();
2111
+ }
2112
+
2113
+ const 戻す箱 = (t: ずらしの相手): void => {
2114
+ for (const b of t.箱) {
2115
+ const node = diagram.nodes.find((n) => n.id === b.id)!;
2116
+ const 元 = 元の箱.get(b.id)!;
2117
+ node.posX = 元.posX;
2118
+ node.posY = 元.posY;
2119
+ }
2120
+ };
2121
+ const 知らせる = (t: ずらしの相手): void =>
2122
+ onNotice?.({
2123
+ kind: "position-offset-ignored",
2124
+ actor: t.名前,
2125
+ line: t.line,
2126
+ message: `"${truncateForMessage(t.名前)}" の位置のずらしは type: ${doc.type} の図では書いた量だけ動かせません`,
2127
+ hint: "ずらしを外すか、座標 (`位置: 300,200`) で置く",
2128
+ });
2129
+
2130
+ // **ここから下の「戻して知らせる」 経路に届く入力は組めていない** (#1971 の実測)。 描画側は
2131
+ // `posX` / `posY` を書いた箱と縦列をそのまま置き、囲いの縦列のずれも 1 度の直しで合う。 試した
2132
+ // 形 = 大きく正と負にずらす / 図の広さを固定してその外へ出す / 7 図種の箱 / 囲いを持つ 2 図種と
2133
+ // クラス図と横長の図の縦列。 描画側が置いた位置を動かす規則を持った時に備えて残す
2134
+ //
2135
+ // 縦列が 1 本でも狙いに置けないか、ずらさない箱を留められなければ、全ての固定を戻して全ての相手を
2136
+ // 知らせる。 縦列を 1 本だけ残すと他が詰め直され、箱の狙いは縦列のずらしを含むので、縦列を戻すと
2137
+ // 箱の狙いも崩れる
2138
+ if ([...差].some(([k, v]) => (k.startsWith("lane:") || k.startsWith("keep:")) && ずれた(v))) {
2139
+ for (const lane of diagram.lanes) {
2140
+ const 元 = 元の縦列.get(lane.id);
2141
+ if (!元) continue;
2142
+ lane.posX = 元.posX;
2143
+ lane.posY = 元.posY;
2144
+ lane.posW = 元.posW;
2145
+ lane.posH = 元.posH;
2146
+ }
2147
+ for (const node of diagram.nodes) {
2148
+ if (!留めた箱.has(node.id)) continue;
2149
+ const 元 = 元の箱.get(node.id)!;
2150
+ node.posX = 元.posX;
2151
+ node.posY = 元.posY;
2152
+ }
2153
+ for (const t of 相手たち) {
2154
+ 戻す箱(t);
2155
+ 知らせる(t);
2156
+ }
2157
+ return;
2158
+ }
2159
+ for (const t of 相手たち) {
2160
+ if (!t.箱.some((b) => ずれた(差.get(`node:${b.id}`)))) continue;
2161
+ 戻す箱(t);
2162
+ 知らせる(t);
2163
+ }
2164
+ }
2165
+
1792
2166
  /**
1793
2167
  * 全図種共通の後処理で、 登場人物に書かれた色を対応する箱に載せる。
1794
2168
  *
@@ -1824,13 +2198,11 @@ function applyNodeTones(diagram: CdlDiagram, doc: DslDocument): void {
1824
2198
  * preset builder が生成した diagram に対して、 doc.actors の 4 field を絶対座標として反映する。
1825
2199
  * slugify で actor 名 → lane id / node id の逆引き、 posX/Y set 済 actor に対応する lane / node に
1826
2200
  * 座標を書込む。 未指定 actor は従来 auto layout 経路そのまま。
2201
+ *
2202
+ * **知らせは出さない**。 かつて `sub-node-not-found` を出していたが、 その知らせが見ていた
2203
+ * `nodes` の欄を #1976 で外した時に出し所が消えた。 知らせる相手が戻った時に引数を足す。
1827
2204
  */
1828
- function applyCanvasPivotPositions(
1829
- diagram: CdlDiagram,
1830
- doc: DslDocument,
1831
- // 下見 (`probe`) の呼出では渡さない = 同じ知らせが 2 度出る
1832
- onNotice?: (notice: CompileNotice) => void,
1833
- ): void {
2205
+ function applyCanvasPivotPositions(diagram: CdlDiagram, doc: DslDocument): void {
1834
2206
  for (const actor of doc.actors) {
1835
2207
  if (actor.partId !== undefined) continue; // parts actor は別経路 (mergePartsFromActors) で処理
1836
2208
  const aliasSlug = slugify(actor.name);
@@ -1853,45 +2225,6 @@ function applyCanvasPivotPositions(
1853
2225
  }
1854
2226
  }
1855
2227
  }
1856
- // canvas pivot UX 修正 (B1) = actor.nodes[subKey] を対応 CDL node に個別反映。
1857
- // sub-node id pattern を actor scope 限定の 2 経路に絞る (subagent review MAJOR-1 対応、 CAR-canvas-pivot):
1858
- // 1. `{aliasSlug}-{subKey}` = header / footer / spacer 等 suffix
1859
- // 2. `{subKey}-{aliasSlug}` = sequence step box `s{N}-{aliasSlug}` 等 prefix
1860
- // 旧 `node.id === subKey` 完全一致 fallback は actor scope を持たず cross-actor pollution risk
1861
- // (別 actor が保有する同名 id node に座標が漏れる silent bug) のため削除。 全 sub-node は必ず
1862
- // aliasSlug を接頭 / 接尾に含む形式で生成されるため、 2 経路で網羅済。
1863
- // lane 側は触らない = 他 sub-node の auto layout 経路を保持 (B1 独立性の SSOT)。
1864
- if (actor.nodes) {
1865
- for (const [subKey, override] of Object.entries(actor.nodes)) {
1866
- if (override.posX === undefined || override.posY === undefined) continue;
1867
- let 当たった = 0;
1868
- for (const node of diagram.nodes) {
1869
- if (node.id === `${aliasSlug}-${subKey}` || node.id === `${subKey}-${aliasSlug}`) {
1870
- node.posX = override.posX;
1871
- node.posY = override.posY;
1872
- if (override.posW !== undefined) node.posW = override.posW;
1873
- if (override.posH !== undefined) node.posH = override.posH;
1874
- 当たった += 1;
1875
- }
1876
- }
1877
- /*
1878
- * **当たらなかったことを伝える** (#1466)。
1879
- *
1880
- * この経路が動くのは、図種が 1 人につき複数の箱を作る時だけ。 順序図が名札 / 余白 /
1881
- * 足を作っていた頃はそこに当たっていたが、板になって作らなくなった = いまはどの図種も
1882
- * この形の箱を作らない。 黙って落とすと、書いた側は効いていると思い込む。
1883
- */
1884
- if (当たった === 0) {
1885
- onNotice?.({
1886
- kind: "sub-node-not-found",
1887
- actor: actor.name,
1888
- line: actor.pos?.line ?? 0,
1889
- message: `"${truncateForMessage(actor.name)}" の nodes に書いた "${truncateForMessage(subKey)}" に当たる箱が図にありません`,
1890
- hint: "1 人に複数の箱を作る図種でだけ効きます。 箱そのものの位置は actor 側の 位置: に書いてください",
1891
- });
1892
- }
1893
- }
1894
- }
1895
2228
  }
1896
2229
  }
1897
2230
 
@@ -1975,10 +2308,13 @@ function minOf(values: readonly number[], fallback: number): number {
1975
2308
  }
1976
2309
 
1977
2310
  /**
1978
- * merge がパーツを縦に送る幅。 `mergePartIntoDiagram` の `STACK_PITCH_APPROX` と同じ値。
2311
+ * 段 1 つぶんの送り幅。 2 つの用途で使う。
2312
+ *
2313
+ * - `大きさ:` の縦の基準 (`partScaleBase`)。 縦は段の送り幅の合計に対する倍率で掛かる
2314
+ * - 部品の頁を配置できない部品を、書いた値で置く時の段の間 (`書いた置き方`)
1979
2315
  *
1980
- * 大きさの見積りは merge が実際に置く形と揃える。 別の規則で見積ると、 間隔が狂う
1981
- * (実測 = 2 段のパーツで 200 空けたいところが 90 になった)。
2316
+ * 頁を配置できる部品の要素の縦位置は、頁で配置した位置から取る (#1992)。 この値で置くと、
2317
+ * 頁で段の間が 220 より広い部品 (実測 = `bind-grid-4` は段の中心の間 280) が置いた図で詰まる。
1982
2318
  */
1983
2319
  const PART_STACK_PITCH = 220;
1984
2320
 
@@ -2058,28 +2394,24 @@ export function partTargetSize(
2058
2394
  /**
2059
2395
  * `大きさ:` と `倍率:` が掛かる時の基準の大きさ (#1026)。
2060
2396
  *
2061
- * 横は縦列の外接矩形、縦は段の送り幅の合計。 **図枠 (`partRenderSize`) ではない**。
2062
- * 図枠は余白を含むため、これを基準にすると書いた倍率より大きく掛かる。
2397
+ * 横は部品の頁で配置した縦列の外接矩形 (`部品の置き方を読む`)、縦は段の送り幅の合計。
2398
+ * **図枠 (`partRenderSize`) ではない**。 図枠は余白を含むため、これを基準にすると書いた倍率より
2399
+ * 大きく掛かる。
2400
+ *
2401
+ * 横を頁の縦列にするのは、組み込みが要素を頁の縦列に沿って置くため (#1992)。 書いた縦列を
2402
+ * 基準のまま残すと、頁で縦列が広がる部品は `大きさ:` に書いた幅より広く描かれる。
2063
2403
  *
2064
2404
  * `partTargetScale` と `partTargetSize` が同じ物差しを使うことで、
2065
2405
  * `partTargetScale(part, base.w * k, base.h * k)` が丁度 `k` 倍を返す関係が保たれる。
2406
+ * 組み込み (`mergePartIntoDiagram`) も同じ基準で伸縮する。
2066
2407
  */
2067
2408
  function partScaleBase(part: CdlDiagram): { w: number; h: number } {
2068
- const lanes = Array.isArray(part.lanes) ? part.lanes : [];
2069
2409
  const nodes = Array.isArray(part.nodes) ? part.nodes : [];
2070
- const lefts: number[] = [];
2071
- const rights: number[] = [];
2072
- for (const l of lanes) {
2073
- const lx = typeof l.x === "number" && Number.isFinite(l.x) ? l.x : 0;
2074
- const lw = positiveOr(l.width, 400);
2075
- lefts.push(lx);
2076
- rights.push(lx + lw);
2077
- }
2078
2410
  const stacks = nodes.map((n) =>
2079
2411
  typeof n.stack === "number" && Number.isFinite(n.stack) ? n.stack : 0,
2080
2412
  );
2081
2413
  return {
2082
- w: positiveOr(maxOf(rights, 400) - minOf(lefts, 0), 400),
2414
+ w: 部品の置き方を読む(part).幅,
2083
2415
  h: Math.max(1, (maxOf(stacks, 0) - minOf(stacks, 0) + 1) * PART_STACK_PITCH),
2084
2416
  };
2085
2417
  }
@@ -2123,9 +2455,8 @@ export function partTargetScale(
2123
2455
  * 箱ごとに位置と大きさを見る。 一番高い箱の高さと段の数から概算すると実際の矩形と合わない
2124
2456
  * (実測 = 段 5 だけのパーツで 200 空けたいところが 750、 段 0,5 で高さが違うと 275 になった)。
2125
2457
  *
2126
- * 段の送り幅は merge の近似 (`PART_STACK_PITCH`) を使う。 パーツを自分の図として配置計算した
2127
- * 実寸とは段を持つパーツで 3% ほど違うが (実測 = 3 段で実高 620 に対して 640)、 ここで見たいのは
2128
- * 「merge がどこに置くか」 なので merge の規則に合わせる。
2458
+ * 箱の位置は merge と同じ置き方 (`部品の置き方を読む`) と同じ式 (`部品の要素のずれ`) から出す。
2459
+ * ここで見たいのは「merge がどこに置くか」 なので、merge と別の規則を持たない (#1992)。
2129
2460
  */
2130
2461
  function partExtent(
2131
2462
  part: CdlDiagram,
@@ -2136,37 +2467,17 @@ function partExtent(
2136
2467
  if (!Array.isArray(part.lanes) || !Array.isArray(part.nodes)) return fallback;
2137
2468
  if (part.nodes.length === 0) return fallback;
2138
2469
 
2139
- // 縦列の位置と幅を先に正す。 catalog は呼出側が渡す値なので、 数でない値を計算に入れない
2140
- const lanes = new Map<string, { x: number; w: number }>();
2141
- const laneLefts: number[] = [];
2142
- const laneRights: number[] = [];
2143
- for (const l of part.lanes) {
2144
- const x = typeof l.x === "number" && Number.isFinite(l.x) ? l.x : 0;
2145
- const w = positiveOr(l.width, 400);
2146
- lanes.set(l.id, { x, w });
2147
- laneLefts.push(x);
2148
- laneRights.push(x + w);
2149
- }
2150
- const bboxW = positiveOr(maxOf(laneRights, 400) - minOf(laneLefts, 0), 400);
2151
- const bboxCenterX = minOf(laneLefts, 0) + bboxW / 2;
2470
+ const 置き方 = 部品の置き方を読む(part);
2152
2471
  const { x: scaleX, y: scaleY } = partTargetScale(part, targetW, targetH);
2153
2472
 
2154
- const stacks = part.nodes.map((n) =>
2155
- typeof n.stack === "number" && Number.isFinite(n.stack) ? n.stack : 0,
2156
- );
2157
- const maxStack = maxOf(stacks, 0);
2158
- const minStack = minOf(stacks, 0);
2159
- const centerStack = (minStack + maxStack) / 2;
2160
-
2161
2473
  // 箱ごとに、 merge が置く位置 (基準からの相対) と大きさから上下左右の端を出す
2162
2474
  const tops: number[] = [];
2163
2475
  const bottoms: number[] = [];
2164
2476
  const lefts: number[] = [];
2165
2477
  const rights: number[] = [];
2166
2478
  part.nodes.forEach((n, i) => {
2167
- const lane = lanes.get(n.lane) ?? { x: 0, w: 320 };
2168
- const cx = (lane.x + lane.w / 2 - bboxCenterX) * scaleX;
2169
- const cy = ((stacks[i] ?? 0) - centerStack) * PART_STACK_PITCH * scaleY;
2479
+ const 頁 = 置き方.要素[i] ?? { x: 置き方.左端 + 置き方.幅 / 2, y: 0 };
2480
+ const { dx: cx, dy: cy } = 部品の要素のずれ(n, 頁, 置き方, scaleX, scaleY);
2170
2481
  const halfW = (positiveOr(n.w, CDL_DEFAULT_NODE_W) * scaleX) / 2;
2171
2482
  const halfH = (positiveOr(n.h, CDL_DEFAULT_NODE_H) * scaleY) / 2;
2172
2483
  lefts.push(cx - halfW);
@@ -2279,8 +2590,8 @@ export function partBoxInFrame(part: CdlDiagram): {
2279
2590
  * 別名の数だけ組み立て直す (相対指定があると 1 個につき 4 回になる)。 覚えるのは大きさだけで、
2280
2591
  * 色などの見た目は含まないため、 呼出側が色を差し替えても古い値にはならない。
2281
2592
  *
2282
- * 組み立てと違い画面を描くたびに呼ばれるので、 大きすぎる図は測る前に止める。 上限は
2283
- * 組み立て側と同じ物差しを使う (#1005)。 catalog の見本は数十要素なので通常は掛からない。
2593
+ * 配置は `部品の頁を配置する` から受け取る。 置き方 (`部品の置き方を読む`) も同じ配置を読むので、
2594
+ * 部品を 1 つ置いても配置は 1 度で済む。
2284
2595
  *
2285
2596
  * 測れない図では既定の大きさと余白 0 に落とす。
2286
2597
  */
@@ -2300,45 +2611,210 @@ function partFrameGeometry(part: CdlDiagram): PartFrameGeometry {
2300
2611
  if (cached) return cached;
2301
2612
  const fallback = { w: 400, h: 200, left: 0, top: 0, boxW: 400, boxH: 200 };
2302
2613
  let out = fallback;
2303
- if (countDiagramElements(part) <= MAX_INPUT_ELEMENTS) {
2304
- try {
2305
- const own = layout(part);
2306
- const vb = own.viewBox;
2307
- const w = positiveOr(vb.w, 400);
2308
- const h = positiveOr(vb.h, 200);
2309
- if (own.nodes.length === 0) {
2310
- // 箱を持たない図では図枠をそのまま箱として扱う。 相対指定の間隔は図枠の縁から測る
2311
- out = { w, h, left: 0, top: 0, boxW: w, boxH: h };
2312
- } else {
2313
- let x0 = Infinity;
2314
- let y0 = Infinity;
2315
- let x1 = -Infinity;
2316
- let y1 = -Infinity;
2317
- for (const n of own.nodes) {
2318
- x0 = Math.min(x0, n.cx - n.w / 2);
2319
- x1 = Math.max(x1, n.cx + n.w / 2);
2320
- y0 = Math.min(y0, n.cy - n.h / 2);
2321
- y1 = Math.max(y1, n.cy + n.h / 2);
2322
- }
2323
- const left = x0 - vb.x;
2324
- const top = y0 - vb.y;
2325
- out = {
2326
- w,
2327
- h,
2328
- left: Number.isFinite(left) ? left : 0,
2329
- top: Number.isFinite(top) ? top : 0,
2330
- boxW: positiveOr(x1 - x0, w),
2331
- boxH: positiveOr(y1 - y0, h),
2332
- };
2614
+ const own = 部品の頁を配置する(part);
2615
+ if (own !== undefined) {
2616
+ const vb = own.viewBox;
2617
+ const w = positiveOr(vb.w, 400);
2618
+ const h = positiveOr(vb.h, 200);
2619
+ if (own.nodes.length === 0) {
2620
+ // 箱を持たない図では図枠をそのまま箱として扱う。 相対指定の間隔は図枠の縁から測る
2621
+ out = { w, h, left: 0, top: 0, boxW: w, boxH: h };
2622
+ } else {
2623
+ let x0 = Infinity;
2624
+ let y0 = Infinity;
2625
+ let x1 = -Infinity;
2626
+ let y1 = -Infinity;
2627
+ for (const n of own.nodes) {
2628
+ x0 = Math.min(x0, n.cx - n.w / 2);
2629
+ x1 = Math.max(x1, n.cx + n.w / 2);
2630
+ y0 = Math.min(y0, n.cy - n.h / 2);
2631
+ y1 = Math.max(y1, n.cy + n.h / 2);
2333
2632
  }
2334
- } catch {
2335
- out = fallback;
2633
+ const left = x0 - vb.x;
2634
+ const top = y0 - vb.y;
2635
+ out = {
2636
+ w,
2637
+ h,
2638
+ left: Number.isFinite(left) ? left : 0,
2639
+ top: Number.isFinite(top) ? top : 0,
2640
+ boxW: positiveOr(x1 - x0, w),
2641
+ boxH: positiveOr(y1 - y0, h),
2642
+ };
2336
2643
  }
2337
2644
  }
2338
2645
  PART_FRAME_CACHE.set(part, out);
2339
2646
  return out;
2340
2647
  }
2341
2648
 
2649
+ /**
2650
+ * 部品の頁 (部品の図だけ) を配置した結果。 配置できない図では `undefined` (#1992)。
2651
+ *
2652
+ * 図枠 (`partFrameGeometry`) と置き方 (`部品の置き方を読む`) が同じ結果を読む。 見本ごとに
2653
+ * 覚えておくのは、catalog の見本が複数の別名から同じものを指し、画面を描くたびにも呼ばれるため。
2654
+ *
2655
+ * 大きすぎる図は配置する前に止める。 上限は組み立て側と同じ物差しを使う (#1005)。
2656
+ * catalog の見本は数十要素なので通常は掛からない。
2657
+ */
2658
+ const PART_PAGE_CACHE = new WeakMap<CdlDiagram, LaidDiagram | null>();
2659
+
2660
+ function 部品の頁を配置する(part: CdlDiagram): LaidDiagram | undefined {
2661
+ const cached = PART_PAGE_CACHE.get(part);
2662
+ if (cached !== undefined) return cached ?? undefined;
2663
+ let out: LaidDiagram | null = null;
2664
+ if (countDiagramElements(part) <= MAX_INPUT_ELEMENTS) {
2665
+ try {
2666
+ out = layout(part);
2667
+ } catch {
2668
+ out = null;
2669
+ }
2670
+ }
2671
+ PART_PAGE_CACHE.set(part, out);
2672
+ return out ?? undefined;
2673
+ }
2674
+
2675
+ /**
2676
+ * 部品を図に置く時に、縦列と要素をどこに置くか (#1992)。
2677
+ *
2678
+ * 部品の頁で配置した縦列の位置と幅、要素の中心を使う。 組み込み (`mergePartIntoDiagram`)、
2679
+ * 組み込んだ大きさの見積り (`partExtent`)、`大きさ:` の横の基準 (`partScaleBase`) の 3 か所が
2680
+ * これを読む。
2681
+ *
2682
+ * 部品が書いた縦列の位置と幅は、頁で配置した後の値と違う。 描画側が隣と近づきすぎないよう
2683
+ * 縦列を広げ、段の間を箱の高さに合わせて取るため。 書いた値で置くと頁で空いた分が消え、
2684
+ * 置いた図だけが間隔の検査に掛かる (実測 = 80 種のうち 14 種。 `wifi-signal` は頁で要素の間 80、
2685
+ * 書いた値で置くと 40)。
2686
+ *
2687
+ * 頁を配置できない図 (描画側が止める図と、大きすぎて測らない図) は書いた値に落とす
2688
+ * (`書いた置き方`)。 頁の位置が数にならない図も同じ。 描画側は書いた値が数でない時に止めずに
2689
+ * 数でない座標を返す (実測 = 幅を書かない縦列は幅が `NaN`、段が数でない要素は縦が `-Infinity`)。
2690
+ *
2691
+ * 1 つの部品で 2 つを混ぜない。 数にならない縦列や要素が 1 つでもあれば全体を書いた値にする。
2692
+ * 混ぜると、要素ごとに別の物差しで置くことになる。
2693
+ */
2694
+ type 部品の置き方 = {
2695
+ /** 縦列ごとの左端と幅 */
2696
+ 縦列: ReadonlyMap<string, { x: number; w: number }>;
2697
+ /** 縦列の外接矩形の左端と幅。 横に写す時の中心と、`大きさ:` の横の基準に使う */
2698
+ 左端: number;
2699
+ 幅: number;
2700
+ /**
2701
+ * 要素ごとの中心 (`part.nodes` と同じ並び)。 横は縦列と同じ座標、縦は要素全体の縦の中心からの差。
2702
+ * どちらも伸縮を掛ける前の値
2703
+ */
2704
+ 要素: readonly { x: number; y: number }[];
2705
+ };
2706
+
2707
+ const 部品の置き方の控え = new WeakMap<CdlDiagram, 部品の置き方>();
2708
+
2709
+ function 部品の置き方を読む(part: CdlDiagram): 部品の置き方 {
2710
+ const 控え = 部品の置き方の控え.get(part);
2711
+ if (控え) return 控え;
2712
+ const 置き方 = 頁の置き方(part) ?? 書いた置き方(part);
2713
+ 部品の置き方の控え.set(part, 置き方);
2714
+ return 置き方;
2715
+ }
2716
+
2717
+ /**
2718
+ * 縦列の外接矩形。 幅が正でない時 (縦列が 1 本も無い図) は 400 に落とす。
2719
+ * 1 未満の正の幅はそのまま使う。 1 に切り上げると、その分だけ `大きさ:` の倍率が小さくなる
2720
+ */
2721
+ function 縦列の外接(縦列: ReadonlyMap<string, { x: number; w: number }>): { 左端: number; 幅: number } {
2722
+ const 値 = [...縦列.values()];
2723
+ const 左端 = minOf(値.map((g) => g.x), 0);
2724
+ const 右端 = maxOf(値.map((g) => g.x + g.w), 400);
2725
+ return { 左端, 幅: positiveOr(右端 - 左端, 400) };
2726
+ }
2727
+
2728
+ /**
2729
+ * 部品の頁で配置した縦列と要素の中心。 頁を配置できない時と、頁の位置が数にならない時は
2730
+ * `undefined` を返し、呼出側が書いた値に落とす。
2731
+ *
2732
+ * 縦の中心は、頁に並んだ要素全体の上下の中心 (一番上と一番下の要素の中心の中間)。 書いた値の
2733
+ * 経路が段の番号の中間を中心にするのと同じ取り方で、部品の中心を置く位置に合わせる。
2734
+ *
2735
+ * 部品の要素が頁に見つからない場合も書いた値に落とす。 描画側は部品の要素を全て配置して返す
2736
+ * ため、この場合に届く入力は作れない (実測 = カタログ 80 種で見つからない要素は 0)。 残すのは、
2737
+ * 見つからない要素だけを別の物差しで置かないため。
2738
+ */
2739
+ function 頁の置き方(part: CdlDiagram): 部品の置き方 | undefined {
2740
+ if (!Array.isArray(part.lanes) || !Array.isArray(part.nodes)) return undefined;
2741
+ const laid = 部品の頁を配置する(part);
2742
+ if (laid === undefined) return undefined;
2743
+ const 縦列 = new Map<string, { x: number; w: number }>();
2744
+ for (const l of laid.lanes) {
2745
+ if (!Number.isFinite(l.x) || !Number.isFinite(l.width) || !(l.width > 0)) return undefined;
2746
+ 縦列.set(l.id, { x: l.x, w: l.width });
2747
+ }
2748
+ const 頁の箱 = new Map(laid.nodes.map((n) => [n.id, n]));
2749
+ const 箱たち: { cx: number; cy: number }[] = [];
2750
+ for (const n of part.nodes) {
2751
+ const 箱 = 頁の箱.get(n.id);
2752
+ if (箱 === undefined || !Number.isFinite(箱.cx) || !Number.isFinite(箱.cy)) return undefined;
2753
+ 箱たち.push(箱);
2754
+ }
2755
+ const 縦たち = 箱たち.map((b) => b.cy);
2756
+ const 縦の中心 = (minOf(縦たち, 0) + maxOf(縦たち, 0)) / 2;
2757
+ return {
2758
+ 縦列,
2759
+ ...縦列の外接(縦列),
2760
+ 要素: 箱たち.map((b) => ({ x: b.cx, y: b.cy - 縦の中心 })),
2761
+ };
2762
+ }
2763
+
2764
+ /**
2765
+ * 部品が書いた値で置く時の縦列と要素の中心。 頁を配置できない部品だけが使う。
2766
+ *
2767
+ * 横は書いた縦列の中心、縦は段の番号に段の送り幅 (`PART_STACK_PITCH`) を掛けた位置。
2768
+ * 縦列の位置と幅は先に正す。 catalog は呼出側が渡す値で、生値のまま外接矩形を出すと
2769
+ * 拡大の基準が崩れて箱が桁違いに大きくなる (実測 = 指定間隔 200 が -31800 になった)。
2770
+ * 数でない段は 0 として扱う (#1018)。
2771
+ */
2772
+ function 書いた置き方(part: CdlDiagram): 部品の置き方 {
2773
+ const lanes = Array.isArray(part.lanes) ? part.lanes : [];
2774
+ const nodes = Array.isArray(part.nodes) ? part.nodes : [];
2775
+ const 縦列 = new Map<string, { x: number; w: number }>();
2776
+ for (const l of lanes) {
2777
+ 縦列.set(l.id, {
2778
+ x: typeof l.x === "number" && Number.isFinite(l.x) ? l.x : 0,
2779
+ w: positiveOr(l.width, 400),
2780
+ });
2781
+ }
2782
+ const stacks = nodes.map((n) =>
2783
+ typeof n.stack === "number" && Number.isFinite(n.stack) ? n.stack : 0,
2784
+ );
2785
+ const 中央の段 = (minOf(stacks, 0) + maxOf(stacks, 0)) / 2;
2786
+ return {
2787
+ 縦列,
2788
+ ...縦列の外接(縦列),
2789
+ 要素: nodes.map((n, i) => {
2790
+ const l = 縦列.get(n.lane) ?? { x: 0, w: 320 };
2791
+ return { x: l.x + l.w / 2, y: ((stacks[i] ?? 0) - 中央の段) * PART_STACK_PITCH };
2792
+ }),
2793
+ };
2794
+ }
2795
+
2796
+ /**
2797
+ * 部品の要素 1 つの中心が、置く位置 (merge に渡す座標) からどれだけずれるか (#1992)。
2798
+ *
2799
+ * 組み込み (`mergePartIntoDiagram`) と見積り (`partExtent`) が同じ式を通る。 別々に書くと、
2800
+ * 格子が確保した場所と実際に置いた場所がずれる。
2801
+ *
2802
+ * 要素が自分で位置を書いていれば、その値を使う。 横は縦列と同じ写し方 (縦列の外接矩形の中心を
2803
+ * 置く位置に合わせて伸縮する) で写し、縦は置く位置に足すだけで伸縮を掛けない。
2804
+ */
2805
+ function 部品の要素のずれ(
2806
+ node: { posX?: number; posY?: number },
2807
+ 頁: { x: number; y: number },
2808
+ 置き方: 部品の置き方,
2809
+ scaleX: number,
2810
+ scaleY: number,
2811
+ ): { dx: number; dy: number } {
2812
+ return {
2813
+ dx: ((node.posX ?? 頁.x) - (置き方.左端 + 置き方.幅 / 2)) * scaleX,
2814
+ dy: node.posY ?? 頁.y * scaleY,
2815
+ };
2816
+ }
2817
+
2342
2818
  /**
2343
2819
  * パーツ 1 個が図の上で確保する図枠 (merge に渡す座標での表し方)。
2344
2820
  *
@@ -2406,26 +2882,25 @@ export function partVisualSize(
2406
2882
  /** 格子に並べる時の 1 行あたりの個数と隙間。 */
2407
2883
  const PARTS_PER_ROW = 3;
2408
2884
  const PARTS_GAP = 120;
2409
- /**
2410
- * 既存の図の下に置く時の目安。
2411
- *
2412
- * 既存の箱は自動配置なので、 この時点では座標を持たない。 箱の数から概算する。
2413
- * 1 段あたりの高さは cdl の既定の縦送り幅に合わせる。
2414
- */
2415
- const STACK_PITCH = 280;
2416
2885
 
2417
2886
  /**
2418
- * 既存の図が縦に何段ぶんを占めるかの目安 (#1481)。
2887
+ * 組み立てた図の下端 (#2002)。 箱が 1 つも無ければ `undefined`。
2888
+ *
2889
+ * **段数で概算しない**。 かつては箱の数から `箱 1 つ = 1 段 (280)` で見積もっていたが、
2890
+ * `flow` の箱は実際には高さ 68 で 168 おきに並ぶため 1 段あたり 112 ずつ余分に見積もる。
2891
+ * 誤差は箱の数に比例して積み上がり、実測で箱 2 つの図に 496、箱 4 つの図に 720 の空きが
2892
+ * 入っていた (部品どうしの間は 120)。
2419
2893
  *
2420
- * **箱の数では数えない**。 数で数えると、図を丸ごと 1 つの箱で描く種別が 1 段に潰れる。
2421
- * 順序図は `#1466` で 1 枚の板になり、高さ 432 の箱 1 つになった = 1 段 (280) と見積もられ、
2422
- * その下に置いたパーツが板に 31px 重なっていた (実測)。
2894
+ * **箱の下端ではなく図枠の下端を返す**。 格子は図枠どうしを `PARTS_GAP` 空けて並べるので、
2895
+ * 既存の図との間も図枠で測らないと、部品どうしの間と既存の図との間が揃わない
2896
+ * (箱で測ると 180、図枠で測ると 240 で、後者が部品どうしの段の間と一致する)。
2423
2897
  *
2424
- * 大きさを自分で持つ箱は、その高さから段数を出す。 持たない箱は今までどおり 1 段と数えるので、
2425
- * 高さを書かない図の並び方は変わらない。
2898
+ * **箱が無いことを 0 に潰さない**。 0 を下端として使うと、部品だけを並べた図で部品が
2899
+ * 図の上端へ貼り付く。 呼出側が「箱が無い」 と「下端が 0」 を分けられるように `undefined` を返す。
2426
2900
  */
2427
- export function partsBaseRows(nodes: ReadonlyArray<{ h?: number }>): number {
2428
- return nodes.reduce((acc, n) => acc + Math.max(1, Math.ceil(positiveOr(n.h, 0) / STACK_PITCH)), 0);
2901
+ export function partsBaseBottom(laid: LaidDiagram): number | undefined {
2902
+ if (laid.nodes.length === 0) return undefined;
2903
+ return laid.viewBox.y + laid.viewBox.h;
2429
2904
  }
2430
2905
 
2431
2906
  /**
@@ -2438,18 +2913,24 @@ export function partsBaseRows(nodes: ReadonlyArray<{ h?: number }>): number {
2438
2913
  * 隣と重なる (実測 = 400 の次に 200 を置くと 280 重なった)。 段の高さも段内の最大高で揃える。
2439
2914
  * 縦は自分の高さの半分だけ段の上端から下げて、 段内で上端を揃える。
2440
2915
  *
2441
- * @param baseRows 既存の図が占める段数の目安 (`partsBaseRows`)。 図の下から並べ始めるために使う
2916
+ * @param baseBottom 既存の図の下端 (`partsBaseBottom`)。 その下に間を空けて並べ始める。
2917
+ * 箱が 1 つも無い図では `undefined` を渡す
2442
2918
  */
2443
2919
  export function partsGridCenters(
2444
- baseRows: number,
2920
+ baseBottom: number | undefined,
2445
2921
  items: ReadonlyArray<{ id: string; w: number; h: number }>,
2446
2922
  ): Map<string, { cx: number; cy: number }> {
2447
2923
  const out = new Map<string, { cx: number; cy: number }>();
2448
2924
  if (items.length === 0) return out;
2449
- // 公開している関数なので、 呼出側が渡す値を入口で閉じる。 数でない箱の数や桁溢れを
2450
- // そのまま計算に入れると、 描けない座標を返すことになる
2451
- const safeCount = Number.isSafeInteger(baseRows) && baseRows >= 0 ? baseRows : 0;
2452
- const top = safeCount * STACK_PITCH + PARTS_GAP * 2;
2925
+ // 公開している関数なので、 呼出側が渡す値を入口で閉じる。 数でない下端や桁溢れを
2926
+ // そのまま計算に入れると、 描けない座標を返すことになる。
2927
+ //
2928
+ // 負の下端は捨てない = 図は原点より上にも置ける。 0 で下げ止めると、上にある図の下に
2929
+ // 無駄な空きが入る
2930
+ const top =
2931
+ baseBottom === undefined || !Number.isFinite(baseBottom)
2932
+ ? PARTS_GAP * 2
2933
+ : baseBottom + PARTS_GAP;
2453
2934
  // 同じ名前が 2 度来たら先の方だけを見る。 後の分を残すと、 どちらを指したか決められない
2454
2935
  // まま列の送り幅にも影響する
2455
2936
  const seen = new Set<string>();
@@ -2516,6 +2997,44 @@ function partsBudget(
2516
2997
  return accepted;
2517
2998
  }
2518
2999
 
3000
+ /**
3001
+ * パーツを除いた図を組み立てて、その下端を返す (#2002)。 箱が 1 つも無ければ `undefined`。
3002
+ *
3003
+ * **宣言のままの箱は座標を持たない** (`CdlNode` に `cx` / `cy` が無い)。 下端を知るには
3004
+ * 一度組み立てる必要がある。 パーツを除いた図を組み立てるのは、パーツを含めて測ると
3005
+ * パーツを足すたびに下端が下がり、置き場所が自分自身に追随して逃げるため。
3006
+ *
3007
+ * ## 除く 3 つは、いまの呼び出し順では結果を変えない
3008
+ *
3009
+ * 箱 / 縦列 / 矢印の 3 つを除いているが、**この 3 つを残しても下端は 1 も動かない**
3010
+ * (変異試験で 3 つとも、また 3 つ同時でも落ちる検査は 0 件)。 この関数が走るのは
3011
+ * パーツを取り込む前で、その時点のパーツの仮の箱は書いた位置も縦列の段も反映しておらず、
3012
+ * 必ず本体の箱と同じ段に居るため。 図種 5 種 × 段の深さ 2 通り、位置を書いた深さ 3 通り、
3013
+ * 縦列に置いた段数 2 通りを実測して、1 件も差が出なかった。
3014
+ *
3015
+ * それでも残すのは、この関数が「パーツを除いた図を測る」 と読めることに意味があるため。
3016
+ * 呼び出し順が変わった時に黙って下端が動くより、除く形が書いてある方が直しやすい。
3017
+ */
3018
+ function baseDiagramBottom(
3019
+ target: CdlDiagram,
3020
+ baseNodes: readonly CdlDiagram["nodes"][number][],
3021
+ partsLaneIds: ReadonlySet<string>,
3022
+ ): number | undefined {
3023
+ // 箱が 1 つも無い図では組み立てない。 **返す値を変えるための分岐ではない**
3024
+ // (`partsBaseBottom` も箱 0 個で `undefined` を返す) = 組み立て 1 回ぶんを省くためだけの
3025
+ // 早い戻り。 変異試験でこの行を外しても落ちる検査は 0 件で、それが期待どおり
3026
+ if (baseNodes.length === 0) return undefined;
3027
+ const baseIds = new Set(baseNodes.map((n) => n.id));
3028
+ return partsBaseBottom(
3029
+ layout({
3030
+ ...target,
3031
+ lanes: target.lanes.filter((l) => !partsLaneIds.has(l.id)),
3032
+ nodes: [...baseNodes],
3033
+ edges: target.edges.filter((e) => baseIds.has(e.from) && baseIds.has(e.to)),
3034
+ }),
3035
+ );
3036
+ }
3037
+
2519
3038
  /**
2520
3039
  * 位置を書かなかったパーツの、 merge に渡す座標。
2521
3040
  *
@@ -2536,8 +3055,16 @@ function partGridCenters(
2536
3055
  //
2537
3056
  // merge 側は「縦横どちらも書かなかった時」 に格子へ落とす。 条件が食い違うと、 片方だけ
2538
3057
  // 書いたパーツが格子の枠を 1 つ消費して後続がずれる (実測 = 後続の中心が 200 から 720 に動いた)
3058
+ //
3059
+ // 縦列に置く部品も外す (#1980)。 格子の枠を使うと、縦列へ動かした後に後続の部品との間に
3060
+ // 空きが残る。 相対の位置を解く側 (`partBoxes`) も同じ関数を通るので、基準の位置は揃う
3061
+ const 縦列の部品 = 縦列に置く部品(target, doc).縦列;
2539
3062
  const autoActors = partsActors.filter(
2540
- (a) => a.posX === undefined && a.posY === undefined && a.posRel === undefined,
3063
+ (a) =>
3064
+ a.posX === undefined &&
3065
+ a.posY === undefined &&
3066
+ a.posRel === undefined &&
3067
+ !縦列の部品.has(a.name),
2541
3068
  );
2542
3069
  if (autoActors.length === 0) return new Map();
2543
3070
  // パーツ自身の仮の箱は数えない。 この時点では未削除で残っており、 数えるとパーツを足すたびに
@@ -2596,7 +3123,7 @@ function partGridCenters(
2596
3123
  extents.set(a.name, partFrameExtent(part, t.w, t.h));
2597
3124
  }
2598
3125
  const centers = partsGridCenters(
2599
- partsBaseRows(baseNodes),
3126
+ baseDiagramBottom(target, baseNodes, partsLaneIds),
2600
3127
  placedActors.map((a) => ({ id: a.name, ...extents.get(a.name)! })),
2601
3128
  );
2602
3129
  // merge に渡すのは段の中心。 矩形の中心とのずれを引く。 引かないと、 段ごとに箱の高さが
@@ -2659,6 +3186,240 @@ function partBoxes(
2659
3186
  return out;
2660
3187
  }
2661
3188
 
3189
+ /**
3190
+ * 部品へ引いた矢印を、部品の図の要素へ繋ぎ直す手順を作る (#1979)。
3191
+ *
3192
+ * 部品を置くと組み立ては仮の箱を消し、部品の図の要素を `{名前}__{要素の id}` で足す。 仮の箱へ
3193
+ * 引いた矢印は繋ぎ先を失うため、以前は知らせも無く消えていた。
3194
+ *
3195
+ * | 部品 | 繋ぎ先 |
3196
+ * |---|---|
3197
+ * | 要素 1 つ | その要素 |
3198
+ * | 要素 2 つ以上 | 矢印の行に書いた `fromPartNode` / `toPartNode` の要素 |
3199
+ *
3200
+ * **要素が 2 つ以上ある部品で 1 つを自動で選ばない**。 カタログの部品は外枠を持たず同格の要素が並ぶ
3201
+ * (信号の 3 灯、星 5 つ、棒 5 本) ため、最初の要素や一番大きい要素を選ぶと「その 1 つだけ」 を
3202
+ * 指す矢印に見える。 名指しが無い時、名指しした要素が無い時、部品を取り込まなかった時 (`part` が
3203
+ * `undefined`) は矢印を外し、矢印ごとに 1 件知らせる。
3204
+ *
3205
+ * **繋ぐのは書いた矢印だけ** = 矢印を書いた行の端の名前が、この部品の名前と一致する時。 静止した
3206
+ * `type: flow` は行を書かなくても箱を並び順で繋ぐが、その矢印は書き手が部品へ引いたものではないので
3207
+ * 従来どおり知らせずに外す。 繋ぐと、部品を本文から抜いて図の上に重ねる編集画面 (行が部品を指す
3208
+ * 本文だけを抜かずに描く) と絵が食い違う (実測 = 箱と部品を並べただけの本文に、組み立て側だけ矢印が出た)。
3209
+ */
3210
+ function 部品の要素へ繋ぐ(
3211
+ 部品の名前: string,
3212
+ partId: string,
3213
+ part: CdlDiagram | undefined,
3214
+ edgeSteps: Map<string, DslStep> | undefined,
3215
+ onNotice: ((notice: CompileNotice) => void) | undefined,
3216
+ ): (edge: CdlEdge, 端: { from: boolean; to: boolean }) => CdlEdge | undefined {
3217
+ const 要素 = (part?.nodes ?? []).map((n) => n.id);
3218
+ const 見せる数 = 8;
3219
+ const 要素の一覧 = `${要素.slice(0, 見せる数).map(truncateForMessage).join(", ")}${要素.length > 見せる数 ? ` ほか ${要素.length - 見せる数} 件` : ""}`;
3220
+ return (edge, 端) => {
3221
+ const s = edgeSteps?.get(edge.id);
3222
+ const 繋いだ: CdlEdge = { ...edge };
3223
+ for (const 側 of ["from", "to"] as const) {
3224
+ if (!端[側]) continue;
3225
+ // 書いていない矢印 (並び順で作られた矢印) は知らせずに外す
3226
+ if (s === undefined || s[側] !== 部品の名前) return undefined;
3227
+ const 欄 = 側 === "from" ? "fromPartNode" : "toPartNode";
3228
+ const 名指し = s[欄];
3229
+ let 理由: { message: string; hint: string } | undefined;
3230
+ if (part === undefined) {
3231
+ 理由 = {
3232
+ message: "を図に取り込まなかった",
3233
+ hint: "部品が図に入らないため、矢印の端にできません",
3234
+ };
3235
+ } else if (名指し !== undefined) {
3236
+ if (要素.includes(名指し)) 繋いだ[側] = `${部品の名前}__${名指し}`;
3237
+ else
3238
+ 理由 = {
3239
+ message: `の中に ${欄} に書いた "${truncateForMessage(名指し)}" という要素が無い`,
3240
+ hint: `この部品の要素 = ${要素の一覧}`,
3241
+ };
3242
+ } else if (要素.length === 1) {
3243
+ 繋いだ[側] = `${部品の名前}__${要素[0]}`;
3244
+ } else {
3245
+ 理由 =
3246
+ 要素.length === 0
3247
+ ? { message: "の中に繋げる要素が無い", hint: "この部品は要素を持たないため、矢印の端にできません" }
3248
+ : {
3249
+ message: "の中のどの要素に繋ぐかが決まらない",
3250
+ hint: `要素が 2 つ以上ある部品は、矢印に ${欄}: <要素の id> を書いて繋ぐ要素を選ぶ (要素 = ${要素の一覧})`,
3251
+ };
3252
+ }
3253
+ if (理由 === undefined) continue;
3254
+ const 矢印 = `"${truncateForMessage(s.from)}" から "${truncateForMessage(s.to)}" への矢印`;
3255
+ onNotice?.({
3256
+ kind: "part-edge-dropped",
3257
+ actor: 部品の名前,
3258
+ line: s.pos.line,
3259
+ message: `${矢印}は、"${truncateForMessage(部品の名前)}" (${truncateForMessage(partId)}) ${理由.message}ため外しました`,
3260
+ hint: 理由.hint,
3261
+ });
3262
+ return undefined;
3263
+ }
3264
+ return 繋いだ;
3265
+ };
3266
+ }
3267
+
3268
+ /**
3269
+ * 矢印に書いた部品の要素の名指し (`fromPartNode` / `toPartNode`) が効かないことを知らせる (#1979)。
3270
+ *
3271
+ * 名指しは部品の端でだけ読む。 部品でない箱の端に書くと値はどこにも届かず、黙って捨てると
3272
+ * 「書いたのに繋ぎ先が変わらない」 が手掛かりなしで起きる。
3273
+ *
3274
+ * 順序図 (`sequence` / `solidity`) は板の言づてが縦の線に届き、部品の要素へは繋がないため、
3275
+ * 部品の端に書いても効かない。 部品の一覧に無い部品は組み立てが部品を引いてから知らせる。
3276
+ */
3277
+ function reportPartNodeNotHonored(
3278
+ doc: DslDocument,
3279
+ onNotice?: (notice: CompileNotice) => void,
3280
+ ): void {
3281
+ if (!onNotice) return;
3282
+ const 順序図 = doc.type === "sequence" || doc.type === "solidity";
3283
+ const 部品 = new Set(doc.actors.filter((a) => a.partId !== undefined).map((a) => a.name));
3284
+ const 居る = new Set(doc.actors.map((a) => a.name));
3285
+ for (const s of doc.flow) {
3286
+ for (const [側, 欄] of [
3287
+ ["from", "fromPartNode"],
3288
+ ["to", "toPartNode"],
3289
+ ] as const) {
3290
+ const 値 = s[欄];
3291
+ if (値 === undefined) continue;
3292
+ const 名 = s[側];
3293
+ // 居ない名前は `flow-actor-missing` が知らせる
3294
+ if (!居る.has(名)) continue;
3295
+ if (!順序図 && 部品.has(名)) continue;
3296
+ onNotice({
3297
+ kind: "part-node-ignored",
3298
+ actor: 名,
3299
+ line: s.pos.line,
3300
+ message: 順序図
3301
+ ? `順序図の言づては "${truncateForMessage(名)}" の縦の線に届くため、${欄} に書いた "${truncateForMessage(値)}" は効きません`
3302
+ : `"${truncateForMessage(名)}" は部品ではないため、${欄} に書いた "${truncateForMessage(値)}" は効きません`,
3303
+ hint: 順序図
3304
+ ? "部品の中の要素へ矢印を繋ぐのは、矢印を箱の間に引く図種 (flow / swimlane など)"
3305
+ : "部品の中の要素の名指しは、kind に部品の名前を書いた箱の端にだけ効く",
3306
+ });
3307
+ }
3308
+ }
3309
+ }
3310
+
3311
+ /**
3312
+ * 部品のために作られた仮の箱の id かを見分ける。
3313
+ *
3314
+ * 仮の箱の id は部品の名前の slug か、それを頭に持つ形 (`a-header` / `s0-a`) で作られる。
3315
+ * 仮の箱を消す側 (`cleanupPlaceholderActor`) と、部品を置く縦列を探す側 (`縦列に置く部品`) が
3316
+ * 同じ判定を使う = 別々に持つと、消した箱と縦列を探した箱が食い違う。
3317
+ *
3318
+ * 素の登場人物が持つ id は仮の箱に数えない (#1466)。 名前が重なった素の登場人物は
3319
+ * `重なりを解く` 側で `a-c40bf6` のような id に作り替えられるため、同じ頭で始まり
3320
+ * **本体の箱まで巻き込む** (実測で `flow` / `swimlane` / `er` / `state` / `topology` /
3321
+ * `class` / `c4` の 7 図種すべてで素の箱が黙って消えていた)。
3322
+ *
3323
+ * 名前の頭が自分と重なる別の登場人物 (`設備` に対する `設備-予備`) の仮の箱も数えない (#1980)。
3324
+ * 数えると `設備` の仮の箱が 2 本の縦列にまたがって見え、`設備` を消す時に `設備-予備` の
3325
+ * 仮の箱と縦列まで消す。 部品同士でも起き、後から組み込む `設備-予備` が消えた縦列を指して
3326
+ * 配置が止まっていた (`cdl layout: node "設備-予備__ind" の lane "設備-予備" が定義されていない`)。
3327
+ */
3328
+ function 仮の箱のidか(doc: DslDocument, name: string): (id: string) => boolean {
3329
+ const aliasSlug = slugify(name);
3330
+ const 素の箱のid = new Set(
3331
+ doc.actors.filter((x) => x.partId === undefined).map((x) => slugify(x.name)),
3332
+ );
3333
+ const 頭が重なるid = [
3334
+ ...new Set(
3335
+ doc.actors
3336
+ .filter((x) => x.name !== name)
3337
+ .map((x) => slugify(x.name))
3338
+ .filter((s) => s.startsWith(`${aliasSlug}-`) || s.endsWith(`-${aliasSlug}`)),
3339
+ ),
3340
+ ];
3341
+ // sequence step anchor = `s{N}-{slug}` pattern
3342
+ const 自分の形か = (id: string, slug: string): boolean =>
3343
+ id === slug || id.startsWith(`${slug}-`) || (/^s\d+-/.test(id) && id.endsWith(`-${slug}`));
3344
+ return (id: string): boolean => {
3345
+ if (素の箱のid.has(id)) return false;
3346
+ if (頭が重なるid.some((s) => 自分の形か(id, s))) return false;
3347
+ return 自分の形か(id, aliasSlug);
3348
+ };
3349
+ }
3350
+
3351
+ /**
3352
+ * 縦列の中に置く部品と、その縦列の id (#1980)。
3353
+ *
3354
+ * 位置を書かない部品は格子に並ぶが、格子は縦列の位置を読まない。 部品が縦列に属する図では、
3355
+ * 部品の要素だけが縦列に属し、描く位置は別の縦列の上になっていた (実測 = 縦列 b 480〜890 に
3356
+ * 属する部品が 60〜420 に描かれた)。
3357
+ *
3358
+ * 縦列に置くのは、位置 (`posX` / `posY` / `位置:`) を書かない部品のうち次のどちらかに当たるもの。
3359
+ *
3360
+ * | 形 | 縦列 |
3361
+ * |---|---|
3362
+ * | `lane:` に図にある縦列を書いた | 書いた縦列 |
3363
+ * | 仮の箱が自分だけの縦列を持つ (`swimlane` / `state` / `er` / `class` の作り方) | その縦列 |
3364
+ *
3365
+ * **自分だけの縦列は図の形で見分ける**。 図種の名前で分けると、縦列の作り方を変えた図種で
3366
+ * 黙って外れる。 仮の箱が入った縦列に他の登場人物の箱が無く、図に縦列が 2 本以上ある時に
3367
+ * 限る = 全員が 1 本の縦列を共有する図種 (`flow` / `topology` / `c4`) は格子のまま残る。
3368
+ * 仮の箱が作られない形 (流れに現れない `swimlane` の登場人物) は、名札が部品の名前の空の縦列で引く。
3369
+ *
3370
+ * **順序図 (`sequence` / `solidity`) は対象にしない**。 1 枚の板で描き、縦列を持たない (#1466)。
3371
+ *
3372
+ * **他の箱の位置の基準になっている部品は縦列に置かない**。 相対の位置 (`位置: 印 の右 200`) は
3373
+ * 組み立ての前に格子の位置を基準に解くため、基準の部品だけを縦列へ動かすと書いた位置関係が
3374
+ * 崩れる。 外した部品は `基準のため外した` に入れ、呼出側が知らせる。
3375
+ */
3376
+ function 縦列に置く部品(
3377
+ target: CdlDiagram,
3378
+ doc: DslDocument,
3379
+ ): { 縦列: Map<string, string>; 基準のため外した: Map<string, string> } {
3380
+ const 縦列 = new Map<string, string>();
3381
+ const 基準のため外した = new Map<string, string>();
3382
+ if (doc.type === "sequence" || doc.type === "solidity") return { 縦列, 基準のため外した };
3383
+ const 基準の名前 = new Set(
3384
+ doc.actors.map((a) => a.posRel?.anchor).filter((n): n is string => n !== undefined),
3385
+ );
3386
+ const 縦列のid = new Set(target.lanes.map((l) => l.id));
3387
+ for (const a of doc.actors) {
3388
+ if (a.partId === undefined) continue;
3389
+ // 同じ名前を 2 度書いた時は先の 1 件を使う (組み立て側の他の判定と同じ)
3390
+ if (縦列.has(a.name) || 基準のため外した.has(a.name)) continue;
3391
+ if (a.posX !== undefined || a.posY !== undefined || a.posRel !== undefined) continue;
3392
+ const 置く縦列 = 部品の縦列(target, doc, a, 縦列のid);
3393
+ if (置く縦列 === undefined) continue;
3394
+ if (基準の名前.has(a.name)) 基準のため外した.set(a.name, 置く縦列);
3395
+ else 縦列.set(a.name, 置く縦列);
3396
+ }
3397
+ return { 縦列, 基準のため外した };
3398
+ }
3399
+
3400
+ /** 部品を置く縦列。 書いた縦列か、仮の箱が自分だけで使う縦列。 どちらも無ければ `undefined` */
3401
+ function 部品の縦列(
3402
+ target: CdlDiagram,
3403
+ doc: DslDocument,
3404
+ a: DslActor,
3405
+ 縦列のid: ReadonlySet<string>,
3406
+ ): string | undefined {
3407
+ if (a.lane !== undefined) return 縦列のid.has(a.lane) ? a.lane : undefined;
3408
+ if (target.lanes.length < 2) return undefined;
3409
+ const 仮の箱か = 仮の箱のidか(doc, a.name);
3410
+ const 仮の箱の縦列 = new Set(target.nodes.filter((n) => 仮の箱か(n.id)).map((n) => n.lane));
3411
+ // 2 本にまたがる入力は見つかっていない (実測 = 縦列に置く 7 図種とも仮の箱は登場人物ごとに 1 つ。
3412
+ // 名前の頭が重なる登場人物の箱は `仮の箱のidか` が外す)。 またがった時にどちらかの縦列を選ぶと
3413
+ // 他の縦列を部品が占めるため、格子に残す
3414
+ if (仮の箱の縦列.size > 1) return undefined;
3415
+ const [入っていた] = [...仮の箱の縦列];
3416
+ const 候補 =
3417
+ 入っていた ?? target.lanes.find((l) => l.label === a.name && !target.nodes.some((n) => n.lane === l.id))?.id;
3418
+ if (候補 === undefined) return undefined;
3419
+ // 他の登場人物の箱が同じ縦列に居れば、自分だけの縦列ではない
3420
+ return target.nodes.some((n) => n.lane === 候補 && !仮の箱か(n.id)) ? undefined : 候補;
3421
+ }
3422
+
2662
3423
  /**
2663
3424
  * パーツ用に作られた仮の箱 / 線 / 列を掃除する (#1015 で helper 化)。
2664
3425
  *
@@ -2669,6 +3430,11 @@ function cleanupPlaceholderActor(
2669
3430
  target: CdlDiagram,
2670
3431
  doc: DslDocument,
2671
3432
  a: { name: string; lane?: string },
3433
+ /**
3434
+ * 仮の箱に繋がっていた矢印を、部品の図の要素へ繋ぎ直す (#1979)。 仮の箱の側の端を受け取り、
3435
+ * 繋ぎ直した矢印を返す。 `undefined` を返した矢印は外す。 渡さなければ全て外す
3436
+ */
3437
+ 繋ぎ直す?: (edge: CdlEdge, 端: { from: boolean; to: boolean }) => CdlEdge | undefined,
2672
3438
  ): void {
2673
3439
  const aliasSlug = slugify(a.name);
2674
3440
  const ownedLaneIds = new Set<string>();
@@ -2683,41 +3449,30 @@ function cleanupPlaceholderActor(
2683
3449
  for (const n of target.nodes) {
2684
3450
  if (ownedLaneIds.has(n.lane)) ownedNodeIds.add(n.id);
2685
3451
  }
2686
- /*
2687
- * 素の登場人物が持つ id は消さない (#1466)。
2688
- *
2689
- * 下の頭一致は、見本のために作られた仮の箱 (`a-header` / `s0-a`) を拾うためのもの。
2690
- * ところが名前が重なった素の登場人物は `重なりを解く` 側で `a-c40bf6` のような id に
2691
- * 作り替えられるため、同じ頭で始まり **本体の箱まで巻き込む**。
2692
- *
2693
- * 見本と重なる名前を書いた図で、素の箱が黙って消えていた (実測で `flow` / `swimlane` /
2694
- * `er` / `state` / `topology` / `class` / `c4` の 7 図種すべて)。 順序図だけは面ごとの
2695
- * 縦列で引けたため免れており、#1466 で板になって縦列が無くなると同じ穴に落ちる。
2696
- */
2697
- const 素の箱のid = new Set(
2698
- doc.actors.filter((x) => x.partId === undefined).map((x) => slugify(x.name)),
2699
- );
2700
3452
  // actor 専用 lane を引き当てられない経路 (flow / topology 等の共有 lane preset) は従来どおり dragon
2701
3453
  // slug の prefix match に fallback する。 これらは 1 actor = 1 node (id = slug) の生成規則。
2702
- const matchesAliasSlug = (id: string): boolean => {
2703
- if (素の箱のid.has(id)) return false;
2704
- if (id === aliasSlug) return true;
2705
- if (id.startsWith(`${aliasSlug}-`)) return true;
2706
- // sequence step anchor = `s{N}-{aliasSlug}` pattern
2707
- if (/^s\d+-/.test(id) && id.endsWith(`-${aliasSlug}`)) return true;
2708
- return false;
2709
- };
3454
+ const matchesAliasSlug = 仮の箱のidか(doc, a.name);
2710
3455
  const relatedToActor = (id: string): boolean =>
2711
3456
  ownedLaneIds.size > 0 ? ownedNodeIds.has(id) : matchesAliasSlug(id);
3457
+ // 仮の箱が入っていた縦列。 仮の箱を消して空になった縦列は下で消す (#1973)
3458
+ const 仮の箱の縦列 = new Set(target.nodes.filter((n) => relatedToActor(n.id)).map((n) => n.lane));
2712
3459
  target.nodes = target.nodes.filter((n) => !relatedToActor(n.id));
2713
- // edge も同経路で削除 (parts actor に接続していた flow を除去、 parts merge 後の flow は user が
2714
- // 別途書く経路になる)。 削除した edge の id は phase.activate に残ると dangling 参照になるため回収する。
3460
+ // 仮の箱に繋がっていた矢印は、部品の要素へ繋ぎ直せたものだけ残す (#1979)。 以前は全て消しており、
3461
+ // 部品へ引いた矢印が知らせも無く図から消えていた。 繋ぎ直した矢印は id を変えないので、段の
3462
+ // 点灯 (`activate`) からも外さない。 外した矢印の id は `activate` に残ると存在しない参照になるため回収する
2715
3463
  const removedEdgeIds = new Set<string>();
2716
- target.edges = target.edges.filter((e) => {
2717
- const drop = relatedToActor(e.from) || relatedToActor(e.to);
2718
- if (drop) removedEdgeIds.add(e.id);
2719
- return !drop;
2720
- });
3464
+ const 残す矢印: CdlEdge[] = [];
3465
+ for (const e of target.edges) {
3466
+ const 端 = { from: relatedToActor(e.from), to: relatedToActor(e.to) };
3467
+ if (!端.from && !端.to) {
3468
+ 残す矢印.push(e);
3469
+ continue;
3470
+ }
3471
+ const 繋いだ = 繋ぎ直す?.(e, 端);
3472
+ if (繋いだ) 残す矢印.push(繋いだ);
3473
+ else removedEdgeIds.add(e.id);
3474
+ }
3475
+ target.edges = 残す矢印;
2721
3476
  // lane も削除 = sequence preset は parts actor 用に lane (id = aliasSlug、 label = actor 名) を
2722
3477
  // 生成する。 node/edge だけ消して lane を残すと、 merge 後の part 側 lane (label = alias) と 2 本が
2723
3478
  // 同じ label を lane-label として描画し二重表示になる (actor ラベル二重表示 bug の root cause)。
@@ -2749,6 +3504,10 @@ function cleanupPlaceholderActor(
2749
3504
  if (残る箱を持つ.has(l.id)) return true;
2750
3505
  if (l.label === a.name) return false;
2751
3506
  if (l.id === aliasSlug) return false;
3507
+ // 図種が自動で作った縦列 (`flow` 等) に部品の箱しか無かった図では、仮の箱を消すと縦列が
3508
+ // 空のまま残る (実測 = 部品 1 つだけの図に中身の無い `flow` の縦列)。 書き手が `lanes:` に
3509
+ // 書いた縦列は、空でも書いたとおりに残す (#1973)
3510
+ if (仮の箱の縦列.has(l.id) && !Object.hasOwn(doc.lanes ?? {}, l.id)) return false;
2752
3511
  return true;
2753
3512
  });
2754
3513
  // 削除された node / edge を activate 参照している既存 phase の cleanup (node 削除と同じ判定経路
@@ -2769,6 +3528,8 @@ function mergePartsFromActors(
2769
3528
  partsCatalog?: Record<string, CdlDiagram>,
2770
3529
  onNotice?: (notice: CompileNotice) => void,
2771
3530
  derivedSourceLines?: Map<string, number[]>,
3531
+ /** 矢印の id から、その矢印を書いた行。 行に書いた要素の名指しを読む (#1979) */
3532
+ edgeSteps?: Map<string, DslStep>,
2772
3533
  ): CdlDiagram {
2773
3534
  const partsActors = doc.actors.filter((a) => a.partId !== undefined);
2774
3535
  if (partsActors.length === 0) return target;
@@ -2793,6 +3554,10 @@ function mergePartsFromActors(
2793
3554
  // 格子より先に決める。 後にすると、落とす見本が格子の枠を消費して後続がずれる
2794
3555
  const budget = partsBudget(target, partsActors, partsCatalog);
2795
3556
  const gridCenters = partGridCenters(target, doc, partsCatalog, budget, partsActors);
3557
+ // 縦列の中に置く部品 (#1980)。 仮の箱を消す前に探す = 消した後は自分だけの縦列を見分けられない
3558
+ const { 縦列: 縦列の部品, 基準のため外した } = 縦列に置く部品(target, doc);
3559
+ const 縦列に置いた: 縦列に置いた部品[] = [];
3560
+ const 自分の縦列に置いた: 自分の縦列に置いた部品[] = [];
2796
3561
 
2797
3562
  for (const [actorIndex, actor] of partsActors.entries()) {
2798
3563
  const partId = actor.partId;
@@ -2815,8 +3580,14 @@ function mergePartsFromActors(
2815
3580
  ? `要素数が上限 (${MAX_INPUT_ELEMENTS}) を超えています`
2816
3581
  : `図全体の要素数が上限 (${MAX_INPUT_ELEMENTS}) を超えます`,
2817
3582
  });
2818
- // 落とす時も仮の箱を掃除する。 残すと格子から外した後続の見本と重なる
2819
- cleanupPlaceholderActor(target, doc, actor);
3583
+ // 落とす時も仮の箱を掃除する。 残すと格子から外した後続の見本と重なる。
3584
+ // 部品が図に入らないので、部品へ引いた矢印も繋ぎ先が無い = 矢印ごとに知らせて外す
3585
+ cleanupPlaceholderActor(
3586
+ target,
3587
+ doc,
3588
+ actor,
3589
+ 部品の要素へ繋ぐ(actor.name, partId, undefined, edgeSteps, onNotice),
3590
+ );
2820
3591
  continue;
2821
3592
  }
2822
3593
  const part = found;
@@ -2826,6 +3597,25 @@ function mergePartsFromActors(
2826
3597
  `[dragon] parts kind "${partId}" not found in partsCatalog (actor: ${actor.name})`,
2827
3598
  );
2828
3599
  }
3600
+ // 一覧に無い部品は仮の箱のまま描かれ、矢印も仮の箱に繋がる。 要素の名指しは効かない
3601
+ for (const s of doc.flow) {
3602
+ for (const [側, 欄] of [
3603
+ ["from", "fromPartNode"],
3604
+ ["to", "toPartNode"],
3605
+ ] as const) {
3606
+ const 値 = s[欄];
3607
+ if (値 === undefined || s[側] !== actor.name) continue;
3608
+ // 順序図は部品の有無に依らず名指しが効かない。 そちらの知らせと 2 度出さない
3609
+ if (doc.type === "sequence" || doc.type === "solidity") continue;
3610
+ onNotice?.({
3611
+ kind: "part-node-ignored",
3612
+ actor: actor.name,
3613
+ line: s.pos.line,
3614
+ message: `"${truncateForMessage(actor.name)}" (${truncateForMessage(partId)}) は部品の一覧に無いため、${欄} に書いた "${truncateForMessage(値)}" は効きません`,
3615
+ hint: "kind に部品の名前を書く (綴りを確かめる)",
3616
+ });
3617
+ }
3618
+ }
2829
3619
  continue;
2830
3620
  }
2831
3621
  // codex-review MAJOR fix (§ sequence header/footer/spacer 削除) = preset (sequence 等) が生成した
@@ -2843,14 +3633,80 @@ function mergePartsFromActors(
2843
3633
  // 厳密収集する。 slug の prefix 推測を挟まないため、 slug 実装差の取りこぼしと、 別 actor を巻き込む
2844
3634
  // 誤削除 (parts actor `a_b` の lane id `a-b` が actor `a-b-c` の `a-b-c-header` に prefix match する)
2845
3635
  // の両方を同時に排除する。
2846
- cleanupPlaceholderActor(target, doc, actor);
3636
+ //
3637
+ // 仮の箱に繋がっていた矢印は、部品の図の要素へ繋ぎ直す (#1979)。
3638
+ // 縦列に置く部品は、仮の箱の縦列を消さずに残す (#1980)。 部品用の縦列 (`名前__l`) を足すと、
3639
+ // 縦列が書いた順ではなく仮に置いた横位置の順に並ぶ (実測 = 3 人目に書いた部品が真ん中になった)
3640
+ const 置く縦列 = 縦列の部品.get(actor.name);
3641
+ cleanupPlaceholderActor(
3642
+ target,
3643
+ doc,
3644
+ 置く縦列 !== undefined ? { name: actor.name, lane: 置く縦列 } : actor,
3645
+ 部品の要素へ繋ぐ(actor.name, partId, part, edgeSteps, onNotice),
3646
+ );
3647
+ const 外した縦列 = 基準のため外した.get(actor.name);
3648
+ if (外した縦列 !== undefined) {
3649
+ onNotice?.({
3650
+ kind: "part-lane-ignored",
3651
+ actor: actor.name,
3652
+ line: actor.pos?.line ?? 0,
3653
+ message: `"${actor.name}" は他の箱の位置の基準になっているため、縦列 "${target.lanes.find((l) => l.id === 外した縦列)?.label ?? 外した縦列}" には置かず図の下に並べました`,
3654
+ hint: `縦列に置きたい時は、"${actor.name}" を基準にした位置 (位置: ${actor.name} の右 200 など) を座標で書く`,
3655
+ });
3656
+ }
2847
3657
  const merged = applyColorHex(part, actor.colorHex, actor.stateOverride ?? {});
3658
+ // 色番号は部品の色の状態へ入れる。 塗りを図形に直接書いた部品 (`arc-gauge` 等) は入れる先が
3659
+ // 無く、書いても絵が変わらない。 黙って既定の色で描くと手掛かりが残らないので知らせる (#1973)
3660
+ if (actor.colorHex && !(part.states ?? []).some((st) => isColorValue(st.initial))) {
3661
+ onNotice?.({
3662
+ kind: "part-color-ignored",
3663
+ actor: actor.name,
3664
+ line: actor.pos?.line ?? 0,
3665
+ message: `"${actor.name}" (${partId}) は色を変えられる状態を持たないため、色番号 ${actor.colorHex} は効きません`,
3666
+ hint: "色を変えられる部品は、初期値が色番号の状態を持つもの (例 = state-indicator)",
3667
+ });
3668
+ }
3669
+ // 色の名前は箱の色にしか効かず、部品の色の状態は色番号しか受けない。 名前の色は描く側の
3670
+ // 配色の変数 (`--cdl-tone-*`) で決まり固定の色番号を持たないため、置き換えて入れることもしない
3671
+ if (actor.partColorName) {
3672
+ onNotice?.({
3673
+ kind: "part-color-ignored",
3674
+ actor: actor.name,
3675
+ line: actor.pos?.line ?? 0,
3676
+ // 残っている名前は読み替えた後の正規の名前 (`成功` なら `success`) なので文には出さない
3677
+ message: `"${actor.name}" (${partId}) の色は色の名前では変わりません`,
3678
+ hint: '部品の色は色番号で書く (例 = color: "#d9534f")',
3679
+ });
3680
+ }
3681
+ // 上書きが読むのは部品の状態の名前と `phase` (段を外す) だけで、他の名前は何も変えない。
3682
+ // 綴り違いや、外した欄 (`nodes`、#1976) を書いた時に、効いていない値を黙って持たない
3683
+ const 部品の状態 = new Set((part.states ?? []).map((st) => st.id));
3684
+ const 無い状態 = Object.keys(actor.stateOverride ?? {}).filter(
3685
+ (k) => k !== "phase" && !部品の状態.has(k),
3686
+ );
3687
+ if (無い状態.length > 0) {
3688
+ onNotice?.({
3689
+ kind: "part-state-missing",
3690
+ actor: actor.name,
3691
+ line: actor.pos?.line ?? 0,
3692
+ message: `"${actor.name}" (${partId}) は ${無い状態.map((k) => `"${truncateForMessage(k)}"`).join(" / ")} という状態を持たないため、書いた値は効きません`,
3693
+ hint:
3694
+ 部品の状態.size > 0
3695
+ ? `この部品の状態 = ${[...部品の状態].join(", ")}`
3696
+ : "この部品は状態を持たないため、値を書いても変わりません",
3697
+ });
3698
+ }
2848
3699
  // 位置を書いていないパーツは格子に並べる。 書いてあればその位置を使う
2849
3700
  let placeX = actor.posX;
2850
3701
  let placeY = actor.posY;
2851
3702
  // 格子に落とすのは縦横どちらも書かなかった時だけ。 片方だけ書いた時に残りを格子で
2852
3703
  // 埋めると、 書いた値と格子が混ざった位置になる (従来の条件をそのまま保つ)
2853
- if (placeX === undefined && placeY === undefined) {
3704
+ if (置く縦列 !== undefined) {
3705
+ // 縦列の位置は図を配置するまで決まらない。 ここでは座標で置く形にだけして、
3706
+ // 全部の部品を組み込んだ後に縦列へ寄せる (`縦列に置いた部品を揃える`)
3707
+ placeX = 0;
3708
+ placeY = 0;
3709
+ } else if (placeX === undefined && placeY === undefined) {
2854
3710
  const center = gridCenters.get(actor.name);
2855
3711
  placeX = center?.cx;
2856
3712
  placeY = center?.cy;
@@ -2874,12 +3730,13 @@ function mergePartsFromActors(
2874
3730
  }
2875
3731
  }
2876
3732
  const t = partTargetSize(part, actor.posW, actor.posH, actor.scale);
2877
- mergePartIntoDiagram(
3733
+ const 組み込む前の箱の数 = target.nodes.length;
3734
+ const 作った縦列 = mergePartIntoDiagram(
2878
3735
  target,
2879
3736
  part,
2880
3737
  actor.name,
2881
3738
  merged,
2882
- actor.lane,
3739
+ 置く縦列 ?? actor.lane,
2883
3740
  placeX,
2884
3741
  placeY,
2885
3742
  t.w,
@@ -2889,10 +3746,219 @@ function mergePartsFromActors(
2889
3746
  derivedSourceLines,
2890
3747
  値の前置き.get(actor.name),
2891
3748
  );
3749
+ const 要素 = new Set(target.nodes.slice(組み込む前の箱の数).map((n) => n.id));
3750
+ if (置く縦列 !== undefined) {
3751
+ 縦列に置いた.push({ 縦列: 置く縦列, 要素 });
3752
+ } else if (作った縦列.length > 0) {
3753
+ 自分の縦列に置いた.push({ 名前: actor.name, 縦列: 作った縦列, 要素 });
3754
+ }
2892
3755
  }
3756
+ // 部品用の縦列を先に固定する。 縦列に置く部品は配置した縦列の位置に合わせるので、固定する前に
3757
+ // 揃えると、揃えた時の縦列 (部品用の縦列も詰める送りに加わる) と描いた図の縦列が食い違う
3758
+ 部品の縦列を部品に固定する(target, 自分の縦列に置いた);
3759
+ 縦列に置いた部品を揃える(target, 縦列に置いた);
2893
3760
  return target;
2894
3761
  }
2895
3762
 
3763
+ type 自分の縦列に置いた部品 = {
3764
+ /** 登場人物の名前。 名札に使う */
3765
+ 名前: string;
3766
+ /** 部品用に作った縦列。 部品の縦列の順 */
3767
+ 縦列: readonly 部品用の縦列[];
3768
+ 要素: ReadonlySet<string>;
3769
+ };
3770
+
3771
+ type 部品用の縦列 = {
3772
+ id: string;
3773
+ /** 部品自身が縦列に書いた名札 */
3774
+ 部品の名札: string | undefined;
3775
+ };
3776
+
3777
+ /** 部品用の縦列の、部品の要素の上端から縦列の上端までの高さ。 名札 (縦列の上端から 26 下) を収める */
3778
+ const 部品の縦列の名札の高さ = 60;
3779
+
3780
+ /**
3781
+ * 部品用の縦列を部品の位置に固定し、登場人物の名前の名札を部品のすぐ上に 1 つだけ出す (#1990)。
3782
+ *
3783
+ * 部品の要素は座標で置くが、縦列は描画側が左から詰めて並べ直す。 格子の 2 段目の部品の縦列は
3784
+ * 1 段目の縦列と重なるため右へ送られ、名札が別の部品の上に出て、送った分だけ図が横に伸びた
3785
+ * (実測 = 部品 4 つの流れ図で `稼働2` の名札が `稼働3` の上、図の右に 875 の空き)。 部品が 1 つでも
3786
+ * 名札は図の上端に出て、流れ図の途中に置くと先頭の箱の見出しに見えた。
3787
+ *
3788
+ * 描画側は `posX` と `posY` を両方持つ縦列を置いた位置に置き、詰める送りも幅の自動拡張もしない。
3789
+ * 幅は `posW` が無ければ縦列の `width` を使うので、横は位置だけを書く。
3790
+ *
3791
+ * | 向き | 固定する範囲 |
3792
+ * |---|---|
3793
+ * | 横 | 組み込みで決めた位置と幅のまま (部品が書いた縦列を、置いた位置へ写したもの。 #879 / #880) |
3794
+ * | 縦 | 部品全体の上端より名札の分 (60) 上から、部品全体の下端まで。 部品の縦列は全て同じ |
3795
+ *
3796
+ * | 縦列 | 名札 |
3797
+ * |---|---|
3798
+ * | 左端が最も左の縦列 (同じなら部品の縦列の順で先) | 部品が書いた名札、無ければ登場人物の名前 |
3799
+ * | 他の縦列 | 部品が書いた名札だけ |
3800
+ *
3801
+ * 縦を部品全体に揃えるのは、部品の頁の絵と同じく縦列の名札を 1 列に並べるため (部品の頁では
3802
+ * 縦列が全て同じ上端から始まる)。 縦列ごとの要素に合わせると、左の縦列の要素が他より低い部品で
3803
+ * 名前の名札が下がり、右の縦列の要素と重なる。
3804
+ *
3805
+ * **下に余白を足さない**。 縦列の枠は塗りも線も持たず絵に出ないが、図の大きさには数えられる。
3806
+ * 足すと、部品が一番下にある図だけ図の下の空きが広がる (実測 = 箱だけの図と直す前の図は 60、
3807
+ * 下に 25 足すと 85)。 高さを書かないと縦列は名札の分 (40) に縮み、部品の要素が縦列の外に出る。
3808
+ *
3809
+ * **要素の高さは配置しないと分からない** (種類ごとの既定値で描画側が決める)。 全ての部品を
3810
+ * 組み込んだ後に 1 度だけ配置して測る。 座標で置いた要素は配置で動かないので、測った範囲に
3811
+ * 固定しても要素は動かない。
3812
+ *
3813
+ * 座標で置かない要素を持つ部品は固定しない。 そういう要素は縦列の中心に描かれるため、縦列の
3814
+ * 詰める送りを止めると要素の位置も変わる。 **この分岐はテストで覆えていない** = 本文から入力を
3815
+ * 作れない。 格子の部品は必ず格子の中心を持ち、位置を書いた部品は縦横の両方を持つ (片方だけの
3816
+ * 位置と基準が見つからない位置は読む段階で止まる)。 どちらも座標で置かれる (`shouldForcePos`)。
3817
+ *
3818
+ * 配置できない図 (描画側が止める図) では固定しない。 描画でも同じ所で止まる。
3819
+ */
3820
+ function 部品の縦列を部品に固定する(
3821
+ target: CdlDiagram,
3822
+ 部品たち: readonly 自分の縦列に置いた部品[],
3823
+ ): void {
3824
+ const 座標で置いた = (id: string): boolean => {
3825
+ const n = target.nodes.find((x) => x.id === id);
3826
+ return n !== undefined && n.posX !== undefined && n.posY !== undefined;
3827
+ };
3828
+ const 対象 = 部品たち.filter((p) => [...p.要素].every(座標で置いた));
3829
+ if (対象.length === 0) return;
3830
+ let laid: LaidDiagram;
3831
+ try {
3832
+ laid = layout(target);
3833
+ } catch {
3834
+ return;
3835
+ }
3836
+ for (const p of 対象) {
3837
+ const 箱 = laid.nodes.filter((n) => p.要素.has(n.id));
3838
+ if (箱.length === 0) continue;
3839
+ const 上端 = Math.min(...箱.map((n) => n.cy - n.h / 2));
3840
+ const 下端 = Math.max(...箱.map((n) => n.cy + n.h / 2));
3841
+ const 縦列たち = p.縦列.flatMap((l) => {
3842
+ const 縦列 = target.lanes.find((x) => x.id === l.id);
3843
+ return 縦列 ? [{ ...l, 縦列 }] : [];
3844
+ });
3845
+ let 名札の縦列: (typeof 縦列たち)[number] | undefined;
3846
+ for (const l of 縦列たち) {
3847
+ if (名札の縦列 === undefined || (l.縦列.x ?? 0) < (名札の縦列.縦列.x ?? 0)) 名札の縦列 = l;
3848
+ }
3849
+ for (const l of 縦列たち) {
3850
+ l.縦列.posX = l.縦列.x ?? 0;
3851
+ l.縦列.posY = 上端 - 部品の縦列の名札の高さ;
3852
+ l.縦列.posH = 下端 - 上端 + 部品の縦列の名札の高さ;
3853
+ const 名札 = l === 名札の縦列 ? (l.部品の名札 ?? p.名前) : l.部品の名札;
3854
+ if (名札 === undefined) delete l.縦列.label;
3855
+ else l.縦列.label = 名札;
3856
+ }
3857
+ }
3858
+ }
3859
+
3860
+ type 縦列に置いた部品 = { 縦列: string; 要素: ReadonlySet<string> };
3861
+
3862
+ /** 部品が縦列より広い時に、縦列の左右に残す余白。 描画側が縦列の中の箱に取る最小の余白と同じ */
3863
+ const 部品の縦列の余白 = 25;
3864
+
3865
+ /**
3866
+ * 縦列に置いた部品を、縦列の中心と図の下へ動かす (#1980)。
3867
+ *
3868
+ * 部品は座標で置く (部品の中の要素の並びを保つため)。 座標で置いた箱は描画側の配置が動かさないので、
3869
+ * 縦列の位置を知るには図を 1 度配置する。
3870
+ *
3871
+ * **配置は 1 度で足りる**。 縦列の横位置は縦列の幅・中の箱の幅・矢印の札で決まり、座標で置いた箱の
3872
+ * 座標には依らない。 部品を動かしても縦列は動かない。
3873
+ *
3874
+ * | 向き | 置き方 |
3875
+ * |---|---|
3876
+ * | 横 | 部品の中心を縦列の中心に合わせる。 部品が縦列より広ければ縦列を広げ、配置し直す |
3877
+ * | 縦 | 部品以外の箱の一番下から `PARTS_GAP` 空ける。 同じ縦列の部品は書いた順に下へ積む |
3878
+ *
3879
+ * **縦列の段には入れない**。 段の高さは縦列をまたいで共有され、背の高い部品を段に入れると隣の
3880
+ * 縦列の箱まで伸びる (実測 = `受付` の高さが 68 から 380 になった)。
3881
+ *
3882
+ * 配置できない図 (描画側が止める図) では動かさない。 描画でも同じ所で止まるので、ここで
3883
+ * 別の知らせは足さない。
3884
+ */
3885
+ function 縦列に置いた部品を揃える(target: CdlDiagram, 置いた: readonly 縦列に置いた部品[]): void {
3886
+ if (置いた.length === 0) return;
3887
+ const 配置する = (): LaidDiagram | undefined => {
3888
+ try {
3889
+ return layout(target);
3890
+ } catch {
3891
+ return undefined;
3892
+ }
3893
+ };
3894
+ let laid = 配置する();
3895
+ if (!laid) return;
3896
+ const 範囲 = (d: LaidDiagram, 要素: ReadonlySet<string>) => {
3897
+ const 箱 = d.nodes.filter((n) => 要素.has(n.id));
3898
+ if (箱.length === 0) return undefined;
3899
+ return {
3900
+ x0: Math.min(...箱.map((n) => n.cx - n.w / 2)),
3901
+ x1: Math.max(...箱.map((n) => n.cx + n.w / 2)),
3902
+ y0: Math.min(...箱.map((n) => n.cy - n.h / 2)),
3903
+ y1: Math.max(...箱.map((n) => n.cy + n.h / 2)),
3904
+ };
3905
+ };
3906
+ // 部品より狭い縦列を広げる。 描画側は縦列を中の箱 1 つの幅までしか広げず、要素を横に並べた
3907
+ // 部品は隣の縦列へはみ出す
3908
+ let 広げた = false;
3909
+ for (const p of 置いた) {
3910
+ const r = 範囲(laid, p.要素);
3911
+ const 縦列 = target.lanes.find((l) => l.id === p.縦列);
3912
+ const 描いた縦列 = laid.lanes.find((l) => l.id === p.縦列);
3913
+ if (!r || !縦列 || !描いた縦列) continue;
3914
+ const 要る幅 = r.x1 - r.x0 + 部品の縦列の余白 * 2;
3915
+ if (要る幅 > 描いた縦列.width && 要る幅 > 縦列.width) {
3916
+ 縦列.width = 要る幅;
3917
+ 広げた = true;
3918
+ }
3919
+ }
3920
+ if (広げた) {
3921
+ laid = 配置する();
3922
+ if (!laid) return;
3923
+ }
3924
+ // 動かした後に配置し直して確かめ、狙いと合うまで繰り返す。 座標で置いた箱が他の箱と重なると
3925
+ // 描画側は他の箱を下げるため、仮の位置 (図の上端) のまま測った下端は実際より下にずれる
3926
+ // (実測 = 狙った上端 316 が 400 になった)。 部品を図の下へ動かせば重ならなくなり、2 回目で合う
3927
+ const 部品の要素 = new Set(置いた.flatMap((p) => [...p.要素]));
3928
+ for (let 回 = 0; 回 < 部品を揃え直す上限; 回 += 1) {
3929
+ // 下端は部品以外の箱 (格子に並べた部品を含む) と縦列の名札で測る。 箱を囲む縦列 (`contain`) は
3930
+ // 仮に置いた部品まで囲んでいるため数えない = 中の箱は箱として数えている
3931
+ const 他の箱の下端 = Math.max(
3932
+ ...laid.nodes.filter((n) => !部品の要素.has(n.id)).map((n) => n.cy + n.h / 2),
3933
+ ...laid.lanes.filter((l) => !l.contain).map((l) => l.y + l.height),
3934
+ );
3935
+ const 次の上端 = new Map<string, number>();
3936
+ let 動かした = false;
3937
+ for (const p of 置いた) {
3938
+ const r = 範囲(laid, p.要素);
3939
+ const 縦列 = laid.lanes.find((l) => l.id === p.縦列);
3940
+ if (!r || !縦列) continue;
3941
+ const 上端 = 次の上端.get(p.縦列) ?? 他の箱の下端 + PARTS_GAP;
3942
+ const dx = 縦列.x + 縦列.width / 2 - (r.x0 + r.x1) / 2;
3943
+ const dy = 上端 - r.y0;
3944
+ 次の上端.set(p.縦列, 上端 + (r.y1 - r.y0) + PARTS_GAP);
3945
+ if (Math.abs(dx) <= PLACEMENT_TOLERANCE && Math.abs(dy) <= PLACEMENT_TOLERANCE) continue;
3946
+ for (const n of target.nodes) {
3947
+ if (!p.要素.has(n.id) || n.posX === undefined || n.posY === undefined) continue;
3948
+ n.posX += dx;
3949
+ n.posY += dy;
3950
+ }
3951
+ 動かした = true;
3952
+ }
3953
+ if (!動かした) return;
3954
+ laid = 配置する();
3955
+ if (!laid) return;
3956
+ }
3957
+ }
3958
+
3959
+ /** 縦列に置いた部品を配置し直して確かめる回数の上限。 実測では 2 回目で動かなくなる */
3960
+ const 部品を揃え直す上限 = 3;
3961
+
2896
3962
  /**
2897
3963
  * 状態の初期値に上書きを当てた結果と、 色として読めないため捨てたかどうか。
2898
3964
  *
@@ -2942,6 +4008,38 @@ function applyColorHex(
2942
4008
  return out;
2943
4009
  }
2944
4010
 
4011
+ /**
4012
+ * 本文に書いた状態の上書きと色番号を、部品の図そのものに当てて返す (#1973)。
4013
+ *
4014
+ * 編集画面の本文欄は、部品を図から抜いて別に重ねて描く。 重ねる側が部品の図をそのまま
4015
+ * 描くと、`state: { lvl: 0.4 }` や `color: "#d9534f"` がカタログの絵にだけ効き、
4016
+ * 編集画面では既定の値で描かれる。 値の決め方は組み立て側と同じ 2 つの関数を通す。
4017
+ *
4018
+ * `phase: false` は組み立て側と同じく部品の段を外す。 段の数は残し、中身 (点灯と値の変化)
4019
+ * だけを空にする = 段を消すと、重ねた部品だけ段の進みが止まらない形になる。
4020
+ *
4021
+ * 色として読めない上書きは組み立て側と同じく捨てる。 知らせは組み立て側が出すため、
4022
+ * ここでは出さない (同じ本文で 2 度出さない)。
4023
+ */
4024
+ export function 部品に上書きを当てる(
4025
+ part: CdlDiagram,
4026
+ actor: Pick<DslActor, "stateOverride" | "colorHex">,
4027
+ ): CdlDiagram {
4028
+ const 上書き = applyColorHex(part, actor.colorHex, actor.stateOverride ?? {});
4029
+ const 段を外す = 上書き["phase"] === false;
4030
+ if (Object.keys(上書き).length === 0) return part;
4031
+ return {
4032
+ ...part,
4033
+ states: part.states.map((st) => ({
4034
+ ...st,
4035
+ initial: resolveStateOverride(st.initial, 上書き[st.id]).initial,
4036
+ })),
4037
+ phases: 段を外す
4038
+ ? part.phases.map((ph) => ({ ...ph, activate: [], tweens: [], sets: [] }))
4039
+ : part.phases,
4040
+ };
4041
+ }
4042
+
2945
4043
  /**
2946
4044
  * CAR-1657 = parts CdlDiagram (単一 part 内容) を target CdlDiagram に prefix 付きで merge する。
2947
4045
  * alias = user が書く actor 名 ('arc1')、 全 id を '{alias}__{origId}' で prefix、 lane 参照 rename、
@@ -3030,7 +4128,7 @@ function mergePartIntoDiagram(
3030
4128
  * 登場人物の名前をそのまま使えない。 渡されない経路では従来どおり名前をそのまま使う
3031
4129
  */
3032
4130
  valueAlias?: string,
3033
- ): void {
4131
+ ): 部品用の縦列[] {
3034
4132
  const prefix = (id: string): string => `${alias}__${id}`;
3035
4133
  // 値と状態だけ別の前置きを使う (#1189)。 箱 / 縦列 / 矢印の id は `prefix` のまま
3036
4134
  const 値前置き = valueAlias ?? alias;
@@ -3069,11 +4167,13 @@ function mergePartIntoDiagram(
3069
4167
  // 決定的 lane 参照 = user が書いた lane 指定を優先、 なければ parts 内部 lane を prefix 付きで作る
3070
4168
  const targetLaneId = laneMapping;
3071
4169
  const laneIdMap = new Map<string, string>();
4170
+ // 部品用に作った縦列。 組み込んだ後に部品の位置へ固定する (#1990)
4171
+ const 作った縦列: 部品用の縦列[] = [];
3072
4172
  // parts lane の横位置。
3073
4173
  // offset (drop / click 座標) 指定時 = part 中心を offsetX に合わせる = user が置いた位置に
3074
4174
  // parts の中心が来る。 node は lane 中心 (lane.x + laneW/2) に描画されるため、 lane 左端を
3075
4175
  // offsetX - laneW/2 に置くと node 中心 = offsetX となり cursor / viewport 中央に一致する
3076
- // (縦方向 offsetY と対称、 offsetY 側は partCenterStack で既に中心合わせ済)。
4176
+ // (縦方向 offsetY と対称、 offsetY 側は要素全体の縦の中心で合わせる = `部品の置き方を読む`)。
3077
4177
  // 従来の auto-adjust (max(offsetX, existingMax + gap) で既存 lane 右端へ強制右寄せ) は user
3078
4178
  // directive で廃止 (2026-07-21)。 重なりは user の意図位置を優先し、 手動移動で回避する経路。
3079
4179
  // 未指定 (座標なし fallback) 時のみ existingMax + gap で右外配置 (通常経路は drop/click で座標を渡す)。
@@ -3084,37 +4184,26 @@ function mergePartIntoDiagram(
3084
4184
  // scale 基準は part 全体の bbox 幅 (全 lane の最左端〜最右端) にする。 lane[0] 幅だけを基準にすると
3085
4185
  // multi-lane part (複数 lane を横に並べた part) で全体幅を過小評価し、 非先頭 lane の node が自 lane
3086
4186
  // 中心からずれる (#880)。
3087
- // 縦列の位置と幅を先に正す。 catalog は呼出側が渡す値で、 生値のまま bbox を出すと
3088
- // 拡大の基準が 1 に落ちて箱が桁違いに大きくなる (実測 = 指定間隔 200 が -31800 になった)
3089
- const partLaneGeom = new Map<string, { x: number; w: number }>();
3090
- for (const l of part.lanes) {
3091
- partLaneGeom.set(l.id, {
3092
- x: typeof l.x === "number" && Number.isFinite(l.x) ? l.x : 0,
3093
- w: positiveOr(l.width, 400),
3094
- });
3095
- }
3096
- const laneXs = [...partLaneGeom.values()].map((g) => g.x);
3097
- const laneRights = [...partLaneGeom.values()].map((g) => g.x + g.w);
3098
- const partMinLaneX = minOf(laneXs, 0);
3099
- const partMaxLaneRight = maxOf(laneRights, 400);
3100
- // 幅は max >= min で常に非負。 正の幅 (極小 sub-pixel 含む) はそのまま scale 基準に使い、
3101
- // 0 (全 lane が同一 x + 幅 0 の退化ケース) の時だけ除算保護で 1 に fallback する。
3102
- // Math.max(1, w) だと 0 < w < 1 の正当な幅まで 1 に floor して over-scale するため使わない。
3103
- const rawBboxW = partMaxLaneRight - partMinLaneX;
3104
- const partsBboxW = rawBboxW > 0 ? rawBboxW : 1;
4187
+ //
4188
+ // 縦列の位置と幅、要素の中心は部品の頁で配置した値を使う (`部品の置き方を読む`、#1992)。
4189
+ // 書いた値で置くと、頁で描画側が広げた縦列の間と段の間が消え、要素が頁より詰まる。
4190
+ // 伸縮の基準は画面側 (`partScaleFactor`) と同じ関数から取る
4191
+ const 置き方 = 部品の置き方を読む(part);
4192
+ const 伸縮の基準 = partScaleBase(part);
3105
4193
  // 非有限は 1 に倒す。 桁が溢れた `大きさ:` (`Number()` が Infinity を返す長さ) を
3106
4194
  // そのまま掛けると描けない座標になり、 大きさを見積る側 (`partTargetScale`) だけが
3107
4195
  // 1 に倒していたため経路で食い違っていた (#1018)
3108
- const rawLaneScaleX = targetW !== undefined && targetW > 0 ? targetW / partsBboxW : 1;
4196
+ const rawLaneScaleX = targetW !== undefined && targetW > 0 ? targetW / 伸縮の基準.w : 1;
3109
4197
  const laneScaleX = Number.isFinite(rawLaneScaleX) && rawLaneScaleX > 0 ? rawLaneScaleX : 1;
3110
4198
  // part 全体を「元 bbox 中心 → drop 座標」 の scale 変換で写す単一式 mapLaneX。 lane も node も同じ式で
3111
4199
  // 変換し、 lane.x = mapLaneX(元 lane 左端) にすることで全 lane / 全 node が一貫して drop 座標を中心に
3112
4200
  // scale 配置される (cc-codex #879 の mapPartX と同じ発想を lane push まで前倒し、 #880 root fix)。
3113
- const partOrigBboxCenterX = partMinLaneX + partsBboxW / 2;
4201
+ // node 側は `部品の要素のずれ` が同じ中心と倍率で写す
4202
+ const partOrigBboxCenterX = 置き方.左端 + 置き方.幅 / 2;
3114
4203
  const dropCenterX =
3115
4204
  offsetX !== undefined
3116
4205
  ? offsetX
3117
- : existingLaneMaxX + PARTS_LANE_GAP + (partsBboxW * laneScaleX) / 2;
4206
+ : existingLaneMaxX + PARTS_LANE_GAP + (置き方.幅 * laneScaleX) / 2;
3118
4207
  const mapLaneX = (x: number): number => (x - partOrigBboxCenterX) * laneScaleX + dropCenterX;
3119
4208
 
3120
4209
  for (const laneOrig of part.lanes) {
@@ -3126,7 +4215,7 @@ function mergePartIntoDiagram(
3126
4215
  // lane の左端を mapLaneX で変換 = 元 lane 左端 (x) を scale 変換後の位置に置く。 lane 幅も
3127
4216
  // scale して lane 中心が mapLaneX(元 lane 中心) に一致する。 これで multi-lane でも各 lane が
3128
4217
  // part 全体の scale 変換に沿って配置される。
3129
- const geom = partLaneGeom.get(laneOrig.id) ?? { x: 0, w: 400 };
4218
+ const geom = 置き方.縦列.get(laneOrig.id) ?? { x: 0, w: 400 };
3130
4219
  target.lanes.push({
3131
4220
  ...laneOrig,
3132
4221
  id: newLaneId,
@@ -3134,6 +4223,7 @@ function mergePartIntoDiagram(
3134
4223
  x: mapLaneX(geom.x),
3135
4224
  width: geom.w * laneScaleX,
3136
4225
  });
4226
+ 作った縦列.push({ id: newLaneId, 部品の名札: laneOrig.label });
3137
4227
  }
3138
4228
  }
3139
4229
 
@@ -3144,8 +4234,8 @@ function mergePartIntoDiagram(
3144
4234
  // (1) 全 parts node に posX/posY 明示 set (CDL layout の絶対配置経路 = stack 計算 skip)
3145
4235
  // (2) parts の stack 番号を target 側 max stack + STACK_ISOLATION_OFFSET (1000) に shift
3146
4236
  // = 万一 layout が rowH で参照しても sequence stack と重ならず影響 0 化
3147
- // D1 = drop 座標尊重の縦方向 = parts の元 stack (0..N) から近似 pitch で cy を組み立て、
3148
- // offsetY を加算して drop 座標付近に描画。 lane.x + lane.width/2 + offsetX で横位置。
4237
+ // D1 = drop 座標尊重 = 部品の頁で配置した要素の中心を、部品の中心が drop 座標に来るよう
4238
+ // 写して描画する (#1992、`部品の要素のずれ`)。
3149
4239
  //
3150
4240
  const STACK_ISOLATION_OFFSET = 1000;
3151
4241
  const shouldForcePos = offsetX !== undefined || offsetY !== undefined;
@@ -3160,34 +4250,23 @@ function mergePartIntoDiagram(
3160
4250
  // に対する比率 = scale 係数、 全 sub-node の w / h + cx / cy 相対位置に scale 反映。
3161
4251
  // scaleX は lane push と同じ part bbox 幅基準 (laneScaleX) を使う = multi-lane で lane と node の
3162
4252
  // scale 係数が一致する (#880、 lane[0] 幅基準だと非先頭 lane の node がずれる)。
3163
- // parts 内部 stack 別の垂直 pitch (world unit)。 CDL layout の実 stackGap (~100) +
3164
- // 標準 node h (~140-200) の合計相当。 parts の cy を厳密に再現しないが、 渡した座標付近に
3165
- // parts が中心配置される見た目に十分な近似。
3166
- //
3167
- // 実配置に置き換える案を試したが、 拡大の基準 (縦列基準 → 箱基準) まで変わって既存の
3168
- // 期待 14 件が崩れた。 段を持つパーツ (実 catalog で 80 件中 7 件) の内部比率が実配置と
3169
- // 3% ずれるが、 見た目の大きさは呼出側が揃えるため観測される差は無い
3170
- const STACK_PITCH_APPROX = 220;
3171
- // 数でない段は 0 として扱う。 大きさを見積る側 (`partTargetScale`) が同じ判定をしており、
3172
- // ここだけ NaN を通すと段の数が NaN になって倍率が経路で食い違う (#1018)
3173
- const partStacks = part.nodes.map((n) =>
3174
- typeof n.stack === "number" && Number.isFinite(n.stack) ? n.stack : 0,
3175
- );
3176
- const minStack = partStacks.length > 0 ? Math.min(...partStacks) : 0;
3177
- const maxStack = partStacks.length > 0 ? Math.max(...partStacks) : 0;
3178
- const partCenterStack = (minStack + maxStack) / 2;
3179
- const partOrigH = Math.max(1, (maxStack - minStack + 1) * STACK_PITCH_APPROX);
4253
+ // 縦の基準は段の送り幅の合計 (`partScaleBase`)。 `大きさ:` の縦は、この合計に対する倍率で掛かる
3180
4254
  const scaleX = laneScaleX;
3181
- const rawScaleY = targetH !== undefined && targetH > 0 ? targetH / partOrigH : 1;
4255
+ const rawScaleY = targetH !== undefined && targetH > 0 ? targetH / 伸縮の基準.h : 1;
3182
4256
  const scaleY = Number.isFinite(rawScaleY) && rawScaleY > 0 ? rawScaleY : 1;
3183
4257
 
4258
+ // 縦列を 2 本以上持つ部品を 1 本の縦列へまとめる時は、要素ごとに別の段番号を振る (#1980)。
4259
+ // 元の段番号のままだと横に並んでいた要素が同じ段で重なり、配置が止まる (実測 =
4260
+ // `lane "b" の stack=1000 に node が重複`)。 座標で置く要素の位置は段番号で決まらないので、
4261
+ // 振り直しても絵は変わらない
4262
+ const 段を振り直す = targetLaneId !== undefined && shouldForcePos && part.lanes.length > 1;
3184
4263
  // node merge = id prefix + lane 参照 rewrite + shape / subtitle / value 内 template rewrite
3185
- for (const nodeOrig of part.nodes) {
4264
+ for (const [nodeIndex, nodeOrig] of part.nodes.entries()) {
3186
4265
  const mappedLane = laneIdMap.get(nodeOrig.lane) ?? nodeOrig.lane;
3187
4266
  // codex-review MAJOR fix (§ nested shape template) = recursive walk で shape 内 nested object /
3188
4267
  // array の string leaf 全対象、 前実装は 1 depth のみで `fill: { gradient: "{v}" }` 等 miss。
3189
4268
  let newShape = nodeOrig.shape
3190
- ? deepRewriteStrings(nodeOrig.shape as unknown, rewriteTemplate)
4269
+ ? deepRewriteStrings(nodeOrig.shape, rewriteTemplate)
3191
4270
  : undefined;
3192
4271
  // parts 全体 resize (I2 forensic): shape 内 radius / outerRadius / innerRadius / thickness に
3193
4272
  // scale 反映 = user が SE handle drag で拡大すると shape の見た目も比例拡大される。 scaleX を採用
@@ -3211,31 +4290,24 @@ function mergePartIntoDiagram(
3211
4290
  }
3212
4291
  return out;
3213
4292
  };
3214
- newShape = scaleGeom(newShape) as typeof newShape;
4293
+ newShape = scaleGeom(newShape);
3215
4294
  }
3216
4295
  // parts drop 位置 offset 反映:
3217
- // - node.posX set 済 (parts が絶対座標を持つ) = その posX を part 中心基準で scale 変換
3218
- // - offsetX 指定時 (drop 経路) で posX 未設定 = node が属する lane 中央を同じ式で変換
3219
- // - offset なし (従来経路) は auto layout 継続 (posX undefined)
4296
+ // - node.posX / posY set 済 (parts が座標を持つ) = 横は part 中心基準で scale 変換、縦は offsetY を足す
4297
+ // - offset 指定時 (drop 経路) で未設定 = 部品の頁で配置した node の中心を同じ式で変換
4298
+ // - offset なし (従来経路) は auto layout 継続 (posX / posY undefined)
3220
4299
  //
3221
- // 明示 posX と auto-layout の両経路を、 lane push と同じ単一式 mapLaneX で変換する
3222
- // (cc-codex #879 Round 2/3 MAJOR + #880)。 mapLaneX は part bbox 中心 → drop 座標の scale 変換で、
3223
- // lane / node / 明示 posX / auto-layout の全経路がこの 1 式を共有するため、 lane.x != 0 でも
3224
- // multi-lane でも node 中心と自 lane 中心が一致する。
3225
- let nodePosX: number | undefined =
3226
- nodeOrig.posX !== undefined ? mapLaneX(nodeOrig.posX) : undefined;
3227
- let nodePosY: number | undefined =
3228
- nodeOrig.posY !== undefined ? nodeOrig.posY + (offsetY ?? 0) : undefined;
3229
- if (shouldForcePos && nodePosX === undefined) {
3230
- // posX を持たない node は所属 lane の中央 (auto layout の cx 相当) を同じ mapLaneX で変換する。
3231
- const geom = partLaneGeom.get(nodeOrig.lane) ?? { x: 0, w: 320 };
3232
- nodePosX = mapLaneX(geom.x + geom.w / 2);
3233
- }
3234
- if (shouldForcePos && nodePosY === undefined) {
3235
- // parts の元 stack から近似 pitch で cy を組み立て、 全 parts の中心が offsetY に来るよう調整
3236
- const stack = nodeOrig.stack ?? 0;
3237
- nodePosY = (stack - partCenterStack) * STACK_PITCH_APPROX * scaleY + (offsetY ?? 0);
3238
- }
4300
+ // 明示 posX と頁の中心の両経路を、 lane push と同じ中心と倍率で写す (cc-codex #879 Round 2/3
4301
+ // MAJOR + #880)。 写す式は見積り (`partExtent`) と共有する `部品の要素のずれ` が持つため、
4302
+ // lane.x != 0 でも multi-lane でも node 中心と自 lane 中心が一致し、格子が確保した場所とも一致する。
4303
+ //
4304
+ // offset なしの経路 (格子が場所を返さない部品だけが通る) では、node が書いた縦位置は
4305
+ // `...nodeOrig` がそのまま残す。 足す offsetY が無いので写す必要が無い
4306
+ const 頁 = 置き方.要素[nodeIndex] ?? { x: partOrigBboxCenterX, y: 0 };
4307
+ const ずれ = 部品の要素のずれ(nodeOrig, 頁, 置き方, scaleX, scaleY);
4308
+ const nodePosX =
4309
+ shouldForcePos || nodeOrig.posX !== undefined ? dropCenterX + ずれ.dx : undefined;
4310
+ const nodePosY = shouldForcePos ? (offsetY ?? 0) + ずれ.dy : undefined;
3239
4311
  // parts sub-node の w / h に scale 適用 (I2 forensic 対応、 targetW/H 指定時のみ)
3240
4312
  // catalog の値は呼出側が渡すので、 拡大しない時も数として通るか確かめる。 通さないと
3241
4313
  // 座標が非有限になって図が描けない (実測 = 箱の中心が NaN になった)
@@ -3253,7 +4325,7 @@ function mergePartIntoDiagram(
3253
4325
  subtitle: rewriteTemplate(nodeOrig.subtitle),
3254
4326
  value: rewriteTemplate(nodeOrig.value),
3255
4327
  // parts stack を target 側と分離 (D2 fix、 posX/posY 明示との 2 段防御)
3256
- stack: (nodeOrig.stack ?? 0) + stackShiftBase,
4328
+ stack: (段を振り直す ? nodeIndex : (nodeOrig.stack ?? 0)) + stackShiftBase,
3257
4329
  ...(newShape ? { shape: newShape as CdlDiagram["nodes"][number]["shape"] } : {}),
3258
4330
  ...(nodePosX !== undefined ? { posX: nodePosX } : {}),
3259
4331
  ...(nodePosY !== undefined ? { posY: nodePosY } : {}),
@@ -3312,7 +4384,7 @@ function mergePartIntoDiagram(
3312
4384
  if (!target.readouts) target.readouts = [];
3313
4385
  for (const readoutOrig of part.readouts) {
3314
4386
  const rewritten = deepRewriteStrings(
3315
- readoutOrig as unknown,
4387
+ readoutOrig,
3316
4388
  rewriteTemplate,
3317
4389
  ) as CdlDiagram["readouts"] extends readonly (infer R)[] ? R : never;
3318
4390
  // id は shape 全 walk で rewrite されないので個別に prefix
@@ -3328,7 +4400,7 @@ function mergePartIntoDiagram(
3328
4400
  // は union。 stateOverride.phase === false 時は parts phase 破棄 (opt-out)。
3329
4401
  const phaseOptOut = stateOverride["phase"] === false;
3330
4402
  if (phaseOptOut) {
3331
- return; // parts phase を破棄、 activate / tweens / sets の rewrite 不要
4403
+ return 作った縦列; // parts phase を破棄、 activate / tweens / sets の rewrite 不要
3332
4404
  }
3333
4405
  if (target.phases.length === 0) {
3334
4406
  // target に phase なし = parts phase をそのまま追加 (prefix 付き)
@@ -3373,6 +4445,7 @@ function mergePartIntoDiagram(
3373
4445
  });
3374
4446
  }
3375
4447
  }
4448
+ return 作った縦列;
3376
4449
  }
3377
4450
 
3378
4451
  /**
@@ -3387,8 +4460,8 @@ function deepRewriteStrings(
3387
4460
  ): unknown {
3388
4461
  if (typeof value === "string") return rewrite(value) ?? value;
3389
4462
  if (value === null || typeof value !== "object") return value;
3390
- if (seen.has(value as object)) return value;
3391
- seen.add(value as object);
4463
+ if (seen.has(value)) return value;
4464
+ seen.add(value);
3392
4465
  if (Array.isArray(value)) {
3393
4466
  return value.map((v) => deepRewriteStrings(v, rewrite, seen));
3394
4467
  }
@@ -3416,13 +4489,19 @@ function applyEdgeInlineOptions(
3416
4489
  doc: DslDocument,
3417
4490
  /** 対応が取れた edge を記録する表。 callback は呼ばない (1 edge = 1 回にするため)。 */
3418
4491
  sourceLines?: Map<string, number>,
4492
+ /** 対応が取れた edge の行そのもの。 部品の取り込みが、行に書いた要素の名指しを読む (#1979) */
4493
+ edgeSteps?: Map<string, DslStep>,
4494
+ /** 鎖に並べない部品を決める一覧 (#1987)。 `compileFlow` に渡したものと同じ */
4495
+ partsCatalog?: Record<string, CdlDiagram>,
3419
4496
  ): void {
3420
4497
  // **静止した `type: flow` は書いた端で対応が取れない** (#1267)。 鎖の規則で先に埋める
3421
4498
  if (鎖でつなぐ形か(doc)) {
4499
+ const 並び = 鎖に並べる登場人物(doc, partsCatalog);
3422
4500
  diagram.edges.forEach((e, idx) => {
3423
- const s = 鎖のどの行から来たか(doc, idx);
4501
+ const s = 鎖のどの行から来たか(doc, 並び, idx);
3424
4502
  if (s === undefined) return;
3425
4503
  sourceLines?.set(e.id, s.pos.line);
4504
+ edgeSteps?.set(e.id, s);
3426
4505
  矢印へ書き写す(e, s, doc);
3427
4506
  });
3428
4507
  return;
@@ -3441,6 +4520,7 @@ function applyEdgeInlineOptions(
3441
4520
  if (!target) return;
3442
4521
  used.add(target.id);
3443
4522
  sourceLines?.set(target.id, s.pos.line);
4523
+ edgeSteps?.set(target.id, s);
3444
4524
  矢印へ書き写す(target, s, doc);
3445
4525
  });
3446
4526
  }
@@ -3493,7 +4573,11 @@ function 矢印へ書き写す(target: CdlEdge, s: DslStep, doc: DslDocument): v
3493
4573
  // **書いた補足が勝つ** (#1275)。 ここで写さないと 2 つ落ちる。 静止した `type: flow` は
3494
4574
  // 鎖を作る時に説明文しか渡さないため補足が消え、`er` は見本が多重度から作った補足が
3495
4575
  // 残って書いた値が無視される (どちらも実測)
3496
- if (s.sub !== undefined) target.sub = s.sub;
4576
+ //
4577
+ // **クラス図の `sub` は多重度なので写さない** (#1769)。 クラス図の組み立てが既に `cardinality` として
4578
+ // 渡し、engine が行き先の端に添える (cdl#821)。 ここで札の下の行にも写すと、`1..*` が札と端の
4579
+ // 2 か所に出て、組み立て器で書いた同じ図と食い違う (実測 = 記法の見本が組み立て器の見本と一致しなくなった)
4580
+ if (s.sub !== undefined && doc.type !== "class") target.sub = s.sub;
3497
4581
  if (s.guard !== undefined) {
3498
4582
  target.guard = s.guard;
3499
4583
  // FSM preset では sub が guard 同期、 author 明示 guard を sub に反映 (sub 既存なら上書きしない)
@@ -3523,6 +4607,12 @@ function 矢印へ書き写す(target: CdlEdge, s: DslStep, doc: DslDocument): v
3523
4607
  if (s.labelPlate !== undefined) target.labelPlate = s.labelPlate;
3524
4608
  if (s.labelOffsetX !== undefined) target.labelOffsetX = s.labelOffsetX;
3525
4609
  if (s.labelOffsetY !== undefined) target.labelOffsetY = s.labelOffsetY;
4610
+ // 矢印の位置のずらし (#1971)。 矢印は自分の位置を持たず、通り道は両端の箱で決まるので、
4611
+ // 動かせるのは名前だけ。 名前のずらしに足す = 両方書いた時は合わせた量だけ動く
4612
+ if (s.layoutPos !== undefined) {
4613
+ target.labelOffsetX = (s.labelOffsetX ?? 0) + s.layoutPos.x;
4614
+ target.labelOffsetY = (s.labelOffsetY ?? 0) + s.layoutPos.y;
4615
+ }
3526
4616
  if (s.overlay !== undefined) target.overlay = s.overlay;
3527
4617
  // 値に追随する 3 欄 (#1396)。 太さ / 色 / 破線の位置が値に合わせて動く。
3528
4618
  // 通り道そのものは動かないので、配置計算と重なり解消には影響しない
@@ -3534,15 +4624,48 @@ function 矢印へ書き写す(target: CdlEdge, s: DslStep, doc: DslDocument): v
3534
4624
  /**
3535
4625
  * 静止した `type: flow` かどうか。 この形だけ矢印を鎖状に作る (`compileFlow`)。
3536
4626
  *
3537
- * 段を持つ形と縦列を書いた形は generic 経路へ回るため鎖にならない。 判定を 1 か所に
3538
- * 集めるのは、`compileFlow` の分岐と食い違うと対応の取り方だけがずれるため。
4627
+ * 段を持つ形・縦列を書いた形・向きを書いた形は generic 経路へ回るため鎖にならない。
4628
+ *
4629
+ * **`compileFlow` の分岐もこの判定を使う**。 判定を 2 か所に書くと、組み立てだけが別経路へ回り、
4630
+ * 行の対応と端の知らせが鎖のまま残る。 向きを書いた形 (#1494) は組み立てにだけ足され、`A -> C` に
4631
+ * 書いた `head` が `C -> B` に載り、書いた端のとおりの矢印に「端は使われません」 が出ていた (#1986)。
3539
4632
  */
3540
4633
  function 鎖でつなぐ形か(doc: DslDocument): boolean {
3541
4634
  if (doc.type !== "flow") return false;
3542
4635
  if (doc.animate && doc.animate.phases.length > 0) return false;
4636
+ if (doc.direction !== undefined) return false;
3543
4637
  return !書いた縦列に置く("flow", doc);
3544
4638
  }
3545
4639
 
4640
+ /**
4641
+ * 静止した流れ図の鎖に並べる登場人物 (#1987)。
4642
+ *
4643
+ * **どの行にも書かれていない部品は鎖に並べない**。 並べると、部品の前後の並び順の矢印は書き手が
4644
+ * 部品へ引いたものではないため部品へ繋がずに外され (#1979)、部品を途中に置いた図から矢印が全て
4645
+ * 消えていた (実測 = `受付 / 印 / 出荷` で矢印 0 本、知らせ 0 件)。 編集画面は部品を本文から抜いてから
4646
+ * 鎖を作るので `受付 -> 出荷` を描き、カタログと絵が食い違っていた。 部品は図の下の格子に置くため、
4647
+ * 鎖から外しても流れの列に空きは生まれない。
4648
+ *
4649
+ * - 部品の一覧に無い部品は仮の箱のまま描かれるので残す (一覧を渡さない組み立ても同じ)
4650
+ * - 大きすぎて取り込まない部品は一覧にあるので外す (図に入らない場所へ鎖を通さない)
4651
+ * - 行の端に書かれた部品は残し、#1979 の繋ぎ方に従う
4652
+ *
4653
+ * 鎖を作る `compileFlow`、行との対応 (`鎖のどの行から来たか`)、端の知らせ
4654
+ * (`reportFlowEndpointNotHonored`) の 3 つがこの並びを使う。 1 つでも `doc.actors` を見ると、
4655
+ * N 本目の矢印と行の対応がずれる。
4656
+ */
4657
+ function 鎖に並べる登場人物(
4658
+ doc: DslDocument,
4659
+ partsCatalog: Record<string, CdlDiagram> | undefined,
4660
+ ): DslActor[] {
4661
+ if (!partsCatalog) return doc.actors;
4662
+ const 行に書かれた = new Set(doc.flow.flatMap((s) => [s.from, s.to]));
4663
+ // 部品でない箱は `partId` を持たず一覧を引けないので、一覧に無い部品と同じく残る
4664
+ return doc.actors.filter(
4665
+ (a) => 行に書かれた.has(a.name) || lookupPartRaw(partsCatalog, a.partId) === undefined,
4666
+ );
4667
+ }
4668
+
3546
4669
  /**
3547
4670
  * 鎖の N 本目の矢印が、本文のどの行から来たかを返す (#1267)。
3548
4671
  *
@@ -3552,8 +4675,12 @@ function 鎖でつなぐ形か(doc: DslDocument): boolean {
3552
4675
  *
3553
4676
  * 説明文を決めた規則と同じ規則で指定も決める = 説明文と指定が必ず同じ行から来る。
3554
4677
  */
3555
- function 鎖のどの行から来たか(doc: DslDocument, edgeIndex: number): DslStep | undefined {
3556
- const to = doc.actors[edgeIndex + 1];
4678
+ function 鎖のどの行から来たか(
4679
+ doc: DslDocument,
4680
+ 並び: readonly DslActor[],
4681
+ edgeIndex: number,
4682
+ ): DslStep | undefined {
4683
+ const to = 並び[edgeIndex + 1];
3557
4684
  if (to === undefined) return undefined;
3558
4685
  return doc.flow.find((s) => s.to === to.name);
3559
4686
  }
@@ -4077,6 +5204,16 @@ const VALUE_NOTICE_HINT: Readonly<Record<string, string>> = {
4077
5204
  * `mind-map` は engine 側でその性質を持ち続けており、 記法が到達しないのは当時の
4078
5205
  * `compileMind` の実装によるものだった。 `#1177` で `compileMind` を `mind-map` に寄せたため、
4079
5206
  * **今は記法からも到達する** (一覧へ戻す作業が要らなかったのはこのため)。
5207
+ *
5208
+ * ## 図表の種別は 1 つ残らず載せる (#1668)
5209
+ *
5210
+ * 弧と帯で量を表す 3 種 (`chart-gauge` / `chart-radial` / `chart-stacked-bar`) と、
5211
+ * 値 1 つを大きく示す 2 種 (`chart-stat` / `chart-waffle`) が抜けていた。 5 種とも中身を
5212
+ * payload で受け取って 1 箱で描く = この一覧が言う性質をそのまま持つ。
5213
+ *
5214
+ * 抜けている間、段の `draw:` を書いても指す先が引けず **書けるのに動かない** 状態になる
5215
+ * (`draw` の解決は「1 箱で描く箱がちょうど 1 つ」 を条件にしている)。 `focus:` で登場人物の
5216
+ * 名前を書いた時に何も光らないのも同じ穴で、載せると図の箱が光るようになる。
4080
5217
  */
4081
5218
  const SINGLE_BOX_KINDS: ReadonlySet<string> = new Set([
4082
5219
  "chart-pie",
@@ -4090,6 +5227,14 @@ const SINGLE_BOX_KINDS: ReadonlySet<string> = new Set([
4090
5227
  "journey-map",
4091
5228
  // 2 時点を直線でつなぐ図も 1 箱で全体を描く (#1647)
4092
5229
  "chart-slope",
5230
+ // 弧と帯で量を表す 3 種も 1 箱で全体を描く (#1668)
5231
+ "chart-gauge",
5232
+ "chart-radial",
5233
+ "chart-stacked-bar",
5234
+ // 値 1 つを大きく示す図と、1 個 = 1% の印を埋める図も同じ (#1668)。
5235
+ // 段の `draw:` は受けない (描画側が起点から描く動きを持たない) が、性質は同じなので載せる
5236
+ "chart-stat",
5237
+ "chart-waffle",
4093
5238
  ]);
4094
5239
 
4095
5240
 
@@ -4114,18 +5259,19 @@ const SINGLE_BOX_KINDS: ReadonlySet<string> = new Set([
4114
5259
  * するため、 #998)。
4115
5260
  */
4116
5261
  /**
4117
- * v0.5+ groups section を topology preset 経由の diagram に container lane として反映。
4118
- * group.lanes に含まれる lane id 集合に対し、 wrap する `group-{id}` lane を contain: true で生成。
5262
+ * v0.5+ groups section を、 束ねる縦列に重ねる枠の縦列 (`group-{id}`、 contain: true) として図に足す。
4119
5263
  *
4120
- * 簡易実装 ... group container lane を独立 lane として並べ、 lane label に group.label を採用。
4121
- * lane の物理的内包 (子 lane を group container の x 内に再配置) は engine layout に委ねる範囲外なので、
4122
- * 本実装は CdlDiagram 上に「contain: true な group container lane」 を追加する最小骨格に留める。
5264
+ * ここでは枠を作るだけで、 位置と大きさは縦列の位置が全て決まった後に `applyGroupFrames` が決める。
5265
+ * 作った枠の id を返す = 同じ id の縦列を書き手が `lanes:` に書いていた時は作らず、 後で置き直さない。
4123
5266
  */
4124
- function applyGroupContainers(diagram: CdlDiagram, doc: DslDocument): void {
4125
- if (!doc.groups || Object.keys(doc.groups).length === 0) return;
5267
+ function applyGroupContainers(diagram: CdlDiagram, doc: DslDocument): Set<string> {
5268
+ const 作った = new Set<string>();
5269
+ if (!doc.groups || Object.keys(doc.groups).length === 0) return 作った;
4126
5270
  for (const [id, g] of Object.entries(doc.groups)) {
4127
5271
  const containerId = `group-${id}`;
4128
- if (diagram.lanes.some((l) => l.id === containerId)) continue;
5272
+ // 書き手が `lanes:` に同じ id を書いた縦列は、ここより後で図に入る。 枠として作ると書いた縦列と
5273
+ // 重なって 1 本になり、 置き直しが書き手の位置と幅を上書きする
5274
+ if (diagram.lanes.some((l) => l.id === containerId) || doc.lanes?.[containerId]) continue;
4129
5275
  diagram.lanes.push({
4130
5276
  id: containerId,
4131
5277
  width: 800,
@@ -4134,7 +5280,119 @@ function applyGroupContainers(diagram: CdlDiagram, doc: DslDocument): void {
4134
5280
  // 束ねる lane 群に重ねて描く枠。 横に並べる lane ではないので、 engine の間隔調整
4135
5281
  // (lane を詰めた分を幅で埋め合わせる処理) の対象から外す。
4136
5282
  role: "overlay",
5283
+ // 置き直すまでは座標を固定しておく。 固定しない枠は縦列の並びに加わり、 他の縦列の間隔を
5284
+ // 広げる (実測 = 3 本の縦列の 2 本目が 666 から 710 へ動いた)。 並びから外した配置で測らないと、
5285
+ // 枠を置いた後に縦列が詰め直されて枠から外れる
5286
+ posX: 0,
5287
+ posY: 0,
5288
+ posW: 1,
5289
+ posH: 1,
4137
5290
  });
5291
+ 作った.add(containerId);
5292
+ }
5293
+ return 作った;
5294
+ }
5295
+
5296
+ /**
5297
+ * 組の枠と束ねた縦列の間の余白。
5298
+ *
5299
+ * | 向き | 値 | 理由 (実測) |
5300
+ * |---|---|---|
5301
+ * | 上 | 40 | 枠の名札を縦列の名札に重ねない高さ。 0 で 74 重なり、 40 で重ならない |
5302
+ * | 下 | 20 | 箱の下端から枠を離す |
5303
+ * | 横 (囲いを持つ縦列) | 20 | 枠を縦列の囲いの線に重ねない。 囲いを持つ縦列の間は矢印の名前が入る広さがある |
5304
+ * | 横 (囲いの無い縦列) | 0 | 縦列の端は箱から既に離れている。 20 取ると、 縦列の間に置いた矢印の名前を枠の線が貫く (箱の種別 `person` で 8 食い込んだ) |
5305
+ */
5306
+ const 組の枠の余白 = { 上: 40, 下: 20, 囲いの横: 20, 囲いの無い横: 0 } as const;
5307
+
5308
+ /**
5309
+ * 組 (`groups:`) の枠を、 束ねた縦列とその中の箱を全て囲む位置と大きさに置く (#1972)。
5310
+ *
5311
+ * **縦列の位置が全て決まった後に 1 度だけ配置して測る**。 枠は作った時から座標を固定してあるので、
5312
+ * 枠を置いても他の縦列は動かない (固定した縦列は並びに加わらない)。
5313
+ *
5314
+ * 束ねた縦列の扱い。
5315
+ *
5316
+ * | 状態 | 扱い |
5317
+ * |---|---|
5318
+ * | 図に無い縦列を書いた | その縦列を除いて囲み、 無いことを知らせる |
5319
+ * | 図に在る縦列が 1 本も無い | 枠を作らず、 知らせる (図の端に何も囲まない枠を描かない) |
5320
+ * | 間に束ねない縦列を挟む | 間の縦列ごと囲み、 挟んだ縦列を知らせる (縦列の並びは書いた順を変えない) |
5321
+ */
5322
+ function applyGroupFrames(
5323
+ diagram: CdlDiagram,
5324
+ doc: DslDocument,
5325
+ 作った枠: ReadonlySet<string>,
5326
+ onNotice?: (notice: CompileNotice) => void,
5327
+ ): void {
5328
+ if (作った枠.size === 0) return;
5329
+ const 置く: { 枠: CdlDiagram["lanes"][number]; 名前: string; line: number; 縦列: string[] }[] = [];
5330
+ for (const [id, g] of Object.entries(doc.groups ?? {})) {
5331
+ const 枠 = diagram.lanes.find((l) => l.id === `group-${id}`);
5332
+ if (!枠 || !作った枠.has(枠.id)) continue;
5333
+ const 書いた = Array.isArray(g.lanes) ? g.lanes : [];
5334
+ const 在る = 書いた.filter((x) => diagram.lanes.some((l) => l.id === x && !作った枠.has(l.id)));
5335
+ const 無い = 書いた.filter((x) => !在る.includes(x));
5336
+ if (在る.length === 0) {
5337
+ diagram.lanes = diagram.lanes.filter((l) => l !== 枠);
5338
+ onNotice?.({
5339
+ kind: "group-lane-missing",
5340
+ actor: id,
5341
+ line: g.pos?.line ?? 0,
5342
+ message:
5343
+ 書いた.length === 0
5344
+ ? `組 "${truncateForMessage(id)}" は束ねる縦列を書いていないので、枠を描きません`
5345
+ : `組 "${truncateForMessage(id)}" が束ねる縦列 ${無い.map((x) => `"${truncateForMessage(x)}"`).join(", ")} が図に無いので、枠を描きません`,
5346
+ hint: "`lanes: [縦列の名前, ...]` に `lanes:` で書いた縦列の名前を並べる",
5347
+ });
5348
+ continue;
5349
+ }
5350
+ if (無い.length > 0) {
5351
+ onNotice?.({
5352
+ kind: "group-lane-missing",
5353
+ actor: id,
5354
+ line: g.pos?.line ?? 0,
5355
+ message: `組 "${truncateForMessage(id)}" が束ねる縦列 ${無い.map((x) => `"${truncateForMessage(x)}"`).join(", ")} が図に無いので、残りの縦列だけを囲みます`,
5356
+ hint: "`lanes:` で書いた縦列の名前か確かめる",
5357
+ });
5358
+ }
5359
+ 置く.push({ 枠, 名前: id, line: g.pos?.line ?? 0, 縦列: 在る });
5360
+ }
5361
+ if (置く.length === 0) return;
5362
+
5363
+ const 配置 = layout(diagram);
5364
+ const 並びの縦列 = 配置.lanes.filter((l) => !作った枠.has(l.id));
5365
+ for (const { 枠, 名前, line, 縦列 } of 置く) {
5366
+ const 束 = 並びの縦列.filter((l) => 縦列.includes(l.id));
5367
+ const 左 = Math.min(...束.map((l) => l.x ?? 0));
5368
+ const 右 = Math.max(...束.map((l) => (l.x ?? 0) + l.width));
5369
+ // 束ねた縦列の左端と右端の間に入る縦列は、 束ねなくても枠に入る
5370
+ const 挟んだ = 並びの縦列.filter(
5371
+ (l) => !縦列.includes(l.id) && (l.x ?? 0) < 右 && (l.x ?? 0) + l.width > 左,
5372
+ );
5373
+ if (挟んだ.length > 0) {
5374
+ onNotice?.({
5375
+ kind: "group-lanes-apart",
5376
+ actor: 名前,
5377
+ line,
5378
+ message: `組 "${truncateForMessage(名前)}" が束ねる縦列の間に ${挟んだ.map((l) => `"${truncateForMessage(l.id)}"`).join(", ")} があるので、その縦列も枠に入ります`,
5379
+ hint: "束ねる縦列を `lanes:` で隣り合う順に並べる",
5380
+ });
5381
+ }
5382
+ const 囲む縦列 = new Set([...束, ...挟んだ].map((l) => l.id));
5383
+ const 囲む = [...束, ...挟んだ];
5384
+ const 箱 = 配置.nodes.filter((n) => 囲む縦列.has(n.lane));
5385
+ const 上 = Math.min(...囲む.map((l) => l.y ?? 0));
5386
+ const 下 = Math.max(
5387
+ ...囲む.map((l) => (l.y ?? 0) + (l.height ?? 0)),
5388
+ ...箱.map((n) => n.cy + n.h / 2),
5389
+ );
5390
+ const 囲いを持つ = diagram.lanes.some((l) => 囲む縦列.has(l.id) && l.contain === true);
5391
+ const 横 = 囲いを持つ ? 組の枠の余白.囲いの横 : 組の枠の余白.囲いの無い横;
5392
+ 枠.posX = 左 - 横;
5393
+ 枠.posY = 上 - 組の枠の余白.上;
5394
+ 枠.posW = 右 - 左 + 横 * 2;
5395
+ 枠.posH = 下 - 上 + 組の枠の余白.上 + 組の枠の余白.下;
4138
5396
  }
4139
5397
  }
4140
5398
 
@@ -4459,6 +5717,8 @@ function compileClass(doc: DslDocument): CdlDiagram {
4459
5717
  type: s2.relation ?? "uses",
4460
5718
  ...(s2.label ? { label: s2.label } : {}),
4461
5719
  ...(s2.sub ? { cardinality: s2.sub } : {}),
5720
+ // 出どころ側の多重度 (#1771)。 engine が出どころの端に添える (cdl#825)
5721
+ ...(s2.tailSub ? { tailCardinality: s2.tailSub } : {}),
4462
5722
  ...(s2.tone ? { tone: s2.tone } : {}),
4463
5723
  ...(s2.style ? { style: s2.style } : {}),
4464
5724
  ...(s2.head ? { head: s2.head } : {}),
@@ -5170,7 +6430,7 @@ function compileJourney(doc: DslDocument, onNotice?: (n: CompileNotice) => void)
5170
6430
  読めない.push(a.name);
5171
6431
  continue;
5172
6432
  }
5173
- data.push({ id: slugify(a.name), title: 箱の題(a), emotion: 語 as never, ...道筋の欄(a) });
6433
+ data.push({ id: slugify(a.name), title: 箱の題(a), emotion: 語, ...道筋の欄(a) });
5174
6434
  continue;
5175
6435
  }
5176
6436
  const e = 気持ち.get(語);
@@ -5283,7 +6543,7 @@ function compileQuadrant(doc: DslDocument, onNotice?: (n: CompileNotice) => void
5283
6543
  読めない.push(a.name);
5284
6544
  continue;
5285
6545
  }
5286
- items.push({ id: slugify(a.name), title: 箱の題(a), quadrant: 語 as never });
6546
+ items.push({ id: slugify(a.name), title: 箱の題(a), quadrant: 語 });
5287
6547
  continue;
5288
6548
  }
5289
6549
  const q = 区画.get(語);
@@ -5326,7 +6586,7 @@ function compileQuadrant(doc: DslDocument, onNotice?: (n: CompileNotice) => void
5326
6586
  * 段の目印を読み取る。 目印と、 それを落とした残りの説明を返す (#1098)。
5327
6587
  *
5328
6588
  * 目印 (`L1` / `L2` / `L3`) は「どの段に置くか」 を組み立てに伝えるためのもので、 読む人には
5329
- * 意味を持たない。 段の名前は枠のラベル (`System Context` 等) が出すので二重でもある。
6589
+ * 意味を持たない。 段の名前は枠のラベル (`全体の見取り図` 等) が出すので二重でもある。
5330
6590
  * 読み取ったら説明から落とす = 書いた人が説明として書いた部分だけが箱に出る。
5331
6591
  *
5332
6592
  * | 書いた文字 | 段 | 残る説明 |
@@ -5356,9 +6616,9 @@ function 段を読み取る(subtitle: string | undefined): { 段: number; 説明
5356
6616
  * C4 preset (C4 model 階層 system context 専用 layout)
5357
6617
  *
5358
6618
  * 設計 ... actor.subtitle の先頭に "L1" / "L2" / "L3" を置き、 階層 lane を生成。
5359
- * - L1 = System Context
5360
- * - L2 = Container
5361
- * - L3 = Component
6619
+ * - L1 = 全体の見取り図 (C4 model の System Context)
6620
+ * - L2 = 動かす単位 (同 Container = 単体で動かす application や保管庫)
6621
+ * - L3 = 部品 (同 Component)
5362
6622
  *
5363
6623
  * 実装 ... **中身のある段だけ** lane を作り、 使う段を左から順に詰めて横並び (contain: true で
5364
6624
  * 囲む) 配置する。 3 lane を常に作ると中身のない枠が画面に残り、 描かれ損ねたように見える (#1078)。
@@ -5371,7 +6631,9 @@ function compileC4(doc: DslDocument): CdlDiagram {
5371
6631
  const b = diagram(slugify(doc.title), { topic: doc.title, type: "infrastructure" });
5372
6632
  const LANE_W = 400;
5373
6633
  const LANE_GAP = 80;
5374
- const 段の名前: Record<number, string> = { 1: "System Context", 2: "Container", 3: "Component" };
6634
+ // 段の名前は **枠に描く字** で、記法に打ち込む識別子ではない (#1886)。 打ち込む字は目印
6635
+ // (`L1` / `L2` / `L3`) の側で、そちらは綴りを変えない
6636
+ const 段の名前: Record<number, string> = { 1: "全体の見取り図", 2: "動かす単位", 3: "部品" };
5375
6637
 
5376
6638
  // 段は **先頭一致** で読み、 読んだ目印は説明から落とす (`段を読み取る` の説明を参照)
5377
6639
  const 割当 = doc.actors.map((a, idx) => {
@@ -5612,6 +6874,8 @@ type 放射で描けない欄 =
5612
6874
  // 前の時点の値 (#1450)。 放射の枝は 1 時点しか描かない = 2 本目の帯に当たるものが無い
5613
6875
  | "previous"
5614
6876
  | "colorHex"
6877
+ // 部品に書いた色の名前 (#1973)。 色番号と同じく部品にしか残らない
6878
+ | "partColorName"
5615
6879
  | "stateOverride"
5616
6880
  | "posX"
5617
6881
  | "posY"
@@ -5620,7 +6884,6 @@ type 放射で描けない欄 =
5620
6884
  | "scale"
5621
6885
  | "scaleKeys"
5622
6886
  | "posRel"
5623
- | "nodes"
5624
6887
  | "layoutPos"
5625
6888
  // 箱の中に描く図形 (#1374)。 放射の枝は箱の中に図形を持たない
5626
6889
  | "shape"
@@ -5677,6 +6940,7 @@ const 放射で描けない欄の名前: Record<放射で描けない欄, string
5677
6940
  final: "始まり / 終わり の印",
5678
6941
  previous: "前の時点の値",
5679
6942
  colorHex: "色番号",
6943
+ partColorName: "色の名前",
5680
6944
  stateOverride: "状態の上書き",
5681
6945
  // 位置は「登場人物ごとの箱をどこに置くか」 の指定で、 箱が 1 つの図では置く先が無い
5682
6946
  posX: "位置 (座標)",
@@ -5686,7 +6950,6 @@ const 放射で描けない欄の名前: Record<放射で描けない欄, string
5686
6950
  scale: "倍率",
5687
6951
  scaleKeys: "倍率",
5688
6952
  posRel: "位置 (相対)",
5689
- nodes: "中の箱ごとの指定",
5690
6953
  layoutPos: "配置のずらし",
5691
6954
  shape: "箱の中の図形",
5692
6955
  visibleIf: "出す条件",
@@ -5729,7 +6992,8 @@ function 放射に出す文字(a: DslActor): { title: string; subtitle?: string
5729
6992
  }
5730
6993
 
5731
6994
  function compileMind(doc: DslDocument, onNotice?: (n: CompileNotice) => void): CdlDiagram {
5732
- const b = diagram(slugify(doc.title), { topic: doc.title, type: "mindmap" });
6995
+ // 図の型は描画側の組み立て関数 `mindMap()` と同じ綴り (cdl 0.63.0 で `mindmap` から `mind` に揃った)
6996
+ const b = diagram(slugify(doc.title), { topic: doc.title, type: "mind" });
5733
6997
  const { w: W, h: H } = 図表の大きさ.mind;
5734
6998
 
5735
6999
  const 伝える = (kind: CompileNotice["kind"], 名: string, message: string, line = 0): void => {
@@ -6025,8 +7289,8 @@ function applyV05Extensions(
6025
7289
  // 組み立てだけで渡すと、同じ記法でも静止図では指定が消える。
6026
7290
  //
6027
7291
  // **幅が図に出るかは縦列との大小で決まる**。 縦列に収まれば箱だけが変わり、
6028
- // 縦列より広ければ縦列ごと押し広げる (実測 = ステート図の 320 は縦列 370 に収まって
6029
- // 図が変わらないが、拡張ステート図の 280 に対し既定 640 は縦列 330 を押し広げた)。
7292
+ // 縦列より広ければ縦列ごと押し広げる (実測 = 状態遷移図の 320 は縦列 370 に収まって
7293
+ // 図が変わらないが、入れ子の状態遷移図の 280 に対し既定 640 は縦列 330 を押し広げた)。
6030
7294
  // #1260 で 1 件だけ見て「見た目に出ない」 と判断し配線を外した = 同じ誤りを繰り返さない
6031
7295
  if (a.posW !== undefined) node.w = a.posW;
6032
7296
  if (a.posH !== undefined) node.h = a.posH;
@@ -6190,7 +7454,7 @@ function injectPhasesFallback(diagram: CdlDiagram, doc: DslDocument): void {
6190
7454
  for (const p of doc.animate.phases) {
6191
7455
  const activateIds: string[] = [...resolveIds(p.highlight ?? [])];
6192
7456
  // `draw:` を描画側の欄へ写す (#1312 / #1314 / #1318)。 相手は 1 箱で図全体を描く
6193
- // 対象 8 種の箱で、必ず 1 つに決まるため名前を書かせずに引ける。
7457
+ // 対象の箱で、必ず 1 つに決まるため名前を書かせずに引ける。
6194
7458
  //
6195
7459
  // **`activate` と兼ねない**。 描画側は焦点と別集合で持つ (`cdl#512`) = 焦点が当たり
6196
7460
  // 続ける図で毎段引き直しになるため。 書いた段だけが欄を持つ
@@ -6311,23 +7575,27 @@ function slugLookup(byName: ReadonlyMap<string, string>, wanted: string): string
6311
7575
  return hit;
6312
7576
  }
6313
7577
 
6314
- function compileFlow(doc: DslDocument): CdlDiagram {
7578
+ function compileFlow(
7579
+ doc: DslDocument,
7580
+ /** 鎖に並べない部品を決める一覧 (#1987) */
7581
+ partsCatalog?: Record<string, CdlDiagram>,
7582
+ ): CdlDiagram {
6315
7583
  // v0.4 ... animation あり時 builder 直接経路で複数 phase 注入
6316
7584
  // **縦列を書いた形は動きの有無に関わらず generic 経路へ** (#1263)。 動く図だけで効かせると、
6317
7585
  // 同じ記法でも静止図では指定が黙って消える (実測 = 縦列 3 本のはずが 1 本になり知らせも出ない)
6318
7586
  // **向きを書いた形も generic 経路へ** (#1494)。 静止図の経路は並びを固定で持つので、
6319
7587
  // ここを通さないと書いた向きが黙って消える (縦列を書いた形と同じ理由)
6320
- if (
6321
- (doc.animate && doc.animate.phases.length > 0) ||
6322
- 書いた縦列に置く("flow", doc) ||
6323
- doc.direction !== undefined
6324
- ) {
7588
+ //
7589
+ // 振り分けは行の対応と端の知らせと同じ判定で行う (#1986)
7590
+ if (!鎖でつなぐ形か(doc)) {
6325
7591
  return compileGenericWithAnimate(doc, { kind: "flow", laneId: "main", laneWidth: 400 });
6326
7592
  }
6327
7593
  // 登場人物が 0 人なら枠も作らない。 描画側の `flow()` は枠を必ず 1 つ作るため、 そのまま
6328
7594
  // 通すと中身の無い枠が残る (実測 = `title` と `type` だけの本文で枠 `flow` が空)。
6329
- // 枠を持たない図として返す = `swimlane` / `c4` が 0 人で枠 0 になるのと揃う
6330
- if (doc.actors.length === 0) {
7595
+ // 枠を持たない図として返す = `swimlane` / `c4` が 0 人で枠 0 になるのと揃う。
7596
+ // 鎖に並べる登場人物で見る = 行に書かれていない部品しか居ない図も枠を作らない (#1987)
7597
+ const 並び = 鎖に並べる登場人物(doc, partsCatalog);
7598
+ if (並び.length === 0) {
6331
7599
  return diagram(slugify(doc.title), { topic: doc.title, type: "flow" }).build();
6332
7600
  }
6333
7601
  // flow preset は actors を順に step として配置、 step 間に edge auto
@@ -6336,8 +7604,9 @@ function compileFlow(doc: DslDocument): CdlDiagram {
6336
7604
  topic: doc.title,
6337
7605
  });
6338
7606
  // 各 actor を step として登録、 edge label は流れ から拾う
6339
- for (let i = 0; i < doc.actors.length; i++) {
6340
- const a = doc.actors[i]!;
7607
+ for (const a of 並び) {
7608
+ // 名前から id を作れない時の番号は、書いた順の番号にする (部品を飛ばしても変わらない)
7609
+ const i = doc.actors.indexOf(a);
6341
7610
  // 直前の step との edge label = この actor を to に持つ flow から拾う
6342
7611
  const incomingEdge = doc.flow.find((s) => s.to === a.name);
6343
7612
  const edgeLabel = incomingEdge?.label;