@cardenelabs/cdl 0.21.1 → 0.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardenelabs/cdl",
3
- "version": "0.21.1",
3
+ "version": "0.22.0",
4
4
  "description": "CDL (Chainome Diagram Language). Mermaid-like declarative DSL that compiles to animated SVG diagrams. Built for blockchain / Solidity flows, generic enough for sequence / flow / state / ER / topology diagrams.",
5
5
  "license": "MIT",
6
6
  "author": "cardene777",
@@ -2,7 +2,8 @@ import type { JSX } from "react";
2
2
  import type { LaidNode } from "../types";
3
3
  import { nodeAccent } from "./node-tone";
4
4
  import { titlePlacement } from "./compact-title";
5
- import { 目印なしの繰り上げ } from "../layout/spec";
5
+ import { 余りを分ける, 目印なしの繰り上げ } from "../layout/spec";
6
+ import { NODE_SIZE } from "../layout/tokens";
6
7
 
7
8
  export function ActorNode({ node, active, value }: { node: LaidNode; active: boolean; value: string | undefined }): JSX.Element {
8
9
  const accent = nodeAccent(node, "var(--cdl-tone-accent, #2d6a8f)");
@@ -11,8 +12,16 @@ export function ActorNode({ node, active, value }: { node: LaidNode; active: boo
11
12
  // 小見出しを書かない時は、そのために空けた帯のぶん字を上へ繰り上げる (#589)。
12
13
  // 繰り上げ量は `layout/spec.ts` が持つ = 高さを出す側と同じ値を引く
13
14
  const 繰り上げ = node.eyebrow ? 0 : 目印なしの繰り上げ("actor");
15
+ // 箱が中身より高い時は余りを上下で分ける (#597)
16
+ const 余り = 余りを分ける("actor", NODE_SIZE.actor.h, node.h, {
17
+ hasEyebrow: Boolean(node.eyebrow),
18
+ hasSubtitle: Boolean(node.subtitle),
19
+ hasValue: Boolean(value),
20
+ hasRows: false,
21
+ });
22
+ const ずらし = 繰り上げ - 余り;
14
23
  // 小型の箱では名前を中央に置く。 固定の位置のままだと下端をまたぐ (#416)
15
- const 名前 = titlePlacement(node, { x: 26, y: 86 - 繰り上げ, size: 32 }, { hasEyebrow: Boolean(node.eyebrow) });
24
+ const 名前 = titlePlacement(node, { x: 26, y: 86 - ずらし, size: 32 }, { hasEyebrow: Boolean(node.eyebrow) });
16
25
  return (
17
26
  <g transform={`translate(${x} ${y})`}>
18
27
  <rect
@@ -27,7 +36,7 @@ export function ActorNode({ node, active, value }: { node: LaidNode; active: boo
27
36
  strokeWidth={active ? 3 : 1.25}
28
37
  />
29
38
  {node.eyebrow && (
30
- <text x={26} y={38} fontSize={18} fontWeight={700} letterSpacing={2} fill={accent}>
39
+ <text x={26} y={38 - 余り} fontSize={18} fontWeight={700} letterSpacing={2} fill={accent}>
31
40
  {node.eyebrow}
32
41
  </text>
33
42
  )}
@@ -54,14 +63,14 @@ export function ActorNode({ node, active, value }: { node: LaidNode; active: boo
54
63
  <line
55
64
  data-cdl-role="node-row-divider"
56
65
  x1={26}
57
- y1={110 - 繰り上げ}
66
+ y1={110 - ずらし}
58
67
  x2={node.w - 26}
59
- y2={110 - 繰り上げ}
68
+ y2={110 - ずらし}
60
69
  stroke="var(--cdl-row-divider, #7e848e)"
61
70
  />
62
71
  )}
63
72
  {node.subtitle && (
64
- <text x={26} y={140 - 繰り上げ} fontSize={20} fill="var(--cdl-text-dim, #5a6270)">
73
+ <text x={26} y={140 - ずらし} fontSize={20} fill="var(--cdl-text-dim, #5a6270)">
65
74
  {node.subtitle}
66
75
  </text>
67
76
  )}
@@ -2,7 +2,8 @@ import type { JSX } from "react";
2
2
  import type { LaidNode } from "../types";
3
3
  import { nodeAccent } from "./node-tone";
4
4
  import { fitTextSize, isCompactBox } from "./compact-title";
5
- import { 目印なしの繰り上げ } from "../layout/spec";
5
+ import { 余りを分ける, 目印なしの繰り上げ } from "../layout/spec";
6
+ import { NODE_SIZE } from "../layout/tokens";
6
7
 
7
8
  export function CardNode({ node, active }: { node: LaidNode; active: boolean }): JSX.Element {
8
9
  const accent = nodeAccent(node, "var(--cdl-tone-accent, #2d6a8f)");
@@ -19,7 +20,17 @@ export function CardNode({ node, active }: { node: LaidNode; active: boolean }):
19
20
  const 小型に呼び名 = compact && Boolean(node.subtitle);
20
21
  // 小見出しを書かない時の繰り上げ (#589)。 値は `layout/spec.ts` SSOT
21
22
  const 繰り上げ = node.eyebrow ? 0 : 目印なしの繰り上げ("card");
22
- const titleY = compact ? (小型に呼び名 ? node.h / 2 - 2 : node.h / 2 + 8) : 86 - 繰り上げ;
23
+ // 箱が中身より高い時は余りを上下で分ける (#597) 小型の形は元から中央に置くので対象外
24
+ const 余り = compact
25
+ ? 0
26
+ : 余りを分ける("card", NODE_SIZE.card.h, node.h, {
27
+ hasEyebrow: Boolean(node.eyebrow),
28
+ hasSubtitle: Boolean(node.subtitle),
29
+ hasValue: false,
30
+ hasRows: false,
31
+ });
32
+ const ずらし = 繰り上げ - 余り;
33
+ const titleY = compact ? (小型に呼び名 ? node.h / 2 - 2 : node.h / 2 + 8) : 86 - ずらし;
23
34
  const titleX = compact ? node.w / 2 : 26;
24
35
  const paddingX = compact ? 16 : 52;
25
36
  const availableW = node.w - paddingX;
@@ -41,7 +52,7 @@ export function CardNode({ node, active }: { node: LaidNode; active: boolean }):
41
52
  strokeWidth={active ? 3 : 1.25}
42
53
  />
43
54
  {!compact && node.eyebrow && (
44
- <text x={26} y={38} fontSize={18} fontWeight={700} letterSpacing={2} fill={accent}>
55
+ <text x={26} y={38 - 余り} fontSize={18} fontWeight={700} letterSpacing={2} fill={accent}>
45
56
  {node.eyebrow}
46
57
  </text>
47
58
  )}
@@ -57,7 +68,7 @@ export function CardNode({ node, active }: { node: LaidNode; active: boolean }):
57
68
  {node.title}
58
69
  </text>
59
70
  {!compact && node.subtitle && (
60
- <text x={26} y={124 - 繰り上げ} fontSize={subtitleSize} fill="var(--cdl-text-dim, #5a6270)">
71
+ <text x={26} y={124 - ずらし} fontSize={subtitleSize} fill="var(--cdl-text-dim, #5a6270)">
61
72
  {node.subtitle}
62
73
  </text>
63
74
  )}
@@ -2,7 +2,8 @@ import type { JSX } from "react";
2
2
  import type { LaidNode } from "../types";
3
3
  import { nodeAccent } from "./node-tone";
4
4
  import { titlePlacement } from "./compact-title";
5
- import { 目印なしの繰り上げ } from "../layout/spec";
5
+ import { 余りを分ける, 目印なしの繰り上げ } from "../layout/spec";
6
+ import { NODE_SIZE } from "../layout/tokens";
6
7
 
7
8
  export function EventNode({
8
9
  node,
@@ -18,8 +19,16 @@ export function EventNode({
18
19
  const y = node.cy - node.h / 2;
19
20
  // 小見出しを書かない時の繰り上げ (#589)。 値は `layout/spec.ts` SSOT
20
21
  const 繰り上げ = node.eyebrow ? 0 : 目印なしの繰り上げ("event");
22
+ // 箱が中身より高い時は余りを上下で分ける (#597)
23
+ const 余り = 余りを分ける("event", NODE_SIZE.event.h, node.h, {
24
+ hasEyebrow: Boolean(node.eyebrow),
25
+ hasSubtitle: Boolean(node.subtitle),
26
+ hasValue: false,
27
+ hasRows: false,
28
+ });
29
+ const ずらし = 繰り上げ - 余り;
21
30
  // 小型の箱では名前を中央に置く。 固定の位置のままだと下端をまたぐ (#416)
22
- const 名前 = titlePlacement(node, { x: 26, y: 88 - 繰り上げ, size: 26 }, { hasEyebrow: Boolean(node.eyebrow) });
31
+ const 名前 = titlePlacement(node, { x: 26, y: 88 - ずらし, size: 26 }, { hasEyebrow: Boolean(node.eyebrow) });
23
32
  const scale = active ? 0.78 + 0.22 * progress : 0.92;
24
33
  const opacity = active ? 0.5 + 0.5 * progress : 0.65;
25
34
  return (
@@ -40,7 +49,7 @@ export function EventNode({
40
49
  strokeWidth={active ? 3 : 1.25}
41
50
  />
42
51
  {node.eyebrow && (
43
- <text x={26} y={38} fontSize={18} fontWeight={700} letterSpacing={2} fill={accent}>
52
+ <text x={26} y={38 - 余り} fontSize={18} fontWeight={700} letterSpacing={2} fill={accent}>
44
53
  {node.eyebrow}
45
54
  </text>
46
55
  )}
@@ -59,7 +68,7 @@ export function EventNode({
59
68
  {node.subtitle && (
60
69
  <text
61
70
  x={26}
62
- y={128 - 繰り上げ}
71
+ y={128 - ずらし}
63
72
  fontSize={21}
64
73
  fill="var(--cdl-text-dim, #5a6270)"
65
74
  style={{ fontFamily: "'JetBrains Mono', monospace" }}
@@ -2,7 +2,8 @@ import type { JSX } from "react";
2
2
  import type { LaidNode } from "../types";
3
3
  import { nodeAccent } from "./node-tone";
4
4
  import { titlePlacement } from "./compact-title";
5
- import { 目印なしの繰り上げ } from "../layout/spec";
5
+ import { 余りを分ける, 目印なしの繰り上げ } from "../layout/spec";
6
+ import { NODE_SIZE } from "../layout/tokens";
6
7
 
7
8
  export function FunctionNode({ node, active }: { node: LaidNode; active: boolean }): JSX.Element {
8
9
  const accent = nodeAccent(node, "var(--cdl-tone-teal, #2f8770)");
@@ -13,8 +14,16 @@ export function FunctionNode({ node, active }: { node: LaidNode; active: boolean
13
14
  const y = node.cy - node.h / 2;
14
15
  // 小見出しを書かない時の繰り上げ (#589)。 値は `layout/spec.ts` SSOT
15
16
  const 繰り上げ = node.eyebrow ? 0 : 目印なしの繰り上げ("function");
17
+ // 箱が中身より高い時は余りを上下で分ける (#597)
18
+ const 余り = 余りを分ける("function", NODE_SIZE.function.h, node.h, {
19
+ hasEyebrow: Boolean(node.eyebrow),
20
+ hasSubtitle: Boolean(node.subtitle),
21
+ hasValue: false,
22
+ hasRows: false,
23
+ });
24
+ const ずらし = 繰り上げ - 余り;
16
25
  // 小型の箱では名前を中央に置く。 固定の位置のままだと下端をまたぐ (#416)
17
- const 名前 = titlePlacement(node, { x: 26, y: 86 - 繰り上げ, size: 24 }, { hasEyebrow: Boolean(node.eyebrow) });
26
+ const 名前 = titlePlacement(node, { x: 26, y: 86 - ずらし, size: 24 }, { hasEyebrow: Boolean(node.eyebrow) });
18
27
  return (
19
28
  <g transform={`translate(${x} ${y})`}>
20
29
  <rect
@@ -30,7 +39,7 @@ export function FunctionNode({ node, active }: { node: LaidNode; active: boolean
30
39
  strokeOpacity={active ? 1 : 0.5}
31
40
  />
32
41
  {node.eyebrow && (
33
- <text x={26} y={38} fontSize={18} fontWeight={700} letterSpacing={2} fill={accent}>
42
+ <text x={26} y={38 - 余り} fontSize={18} fontWeight={700} letterSpacing={2} fill={accent}>
34
43
  {node.eyebrow}
35
44
  </text>
36
45
  )}
@@ -47,7 +56,7 @@ export function FunctionNode({ node, active }: { node: LaidNode; active: boolean
47
56
  {node.title}
48
57
  </text>
49
58
  {node.subtitle && (
50
- <text x={26} y={126 - 繰り上げ} fontSize={20} fill="var(--cdl-text-dim, #5a6270)">
59
+ <text x={26} y={126 - ずらし} fontSize={20} fill="var(--cdl-text-dim, #5a6270)">
51
60
  {node.subtitle}
52
61
  </text>
53
62
  )}
@@ -7,8 +7,12 @@ import {
7
7
  genericTextShift,
8
8
  genericTitleBaselineY,
9
9
  rowBaselineY,
10
+ CLOUD_ART_CENTER_RISE,
11
+ CLOUD_CIRCLES,
12
+ 余りを分ける,
10
13
  目印なしの繰り上げ,
11
14
  } from "../layout/spec";
15
+ import { NODE_SIZE } from "../layout/tokens";
12
16
  import {
13
17
  GENERIC_ROW_LEFT_SIZE_PX,
14
18
  rowColumnWidth,
@@ -222,19 +226,15 @@ export function GenericNode({
222
226
  // 各 circle の中心 y は box 中央線上 (もしくは中央より少し上)、 半径は box 高さの 0.35 ~ 0.45。
223
227
  const cx = w / 2;
224
228
  const cy = h / 2;
225
- // 雲全体の中心 y box の中央 (cy) より少し上に
226
- const cloudCenterY = cy - h * 0.05;
227
- // 5 つの円の半径と中心 x オフセット (中央が大きく、 端が小さい)
228
- // 円が w 方向 80% を占めるよう配置
229
- const usableW = w * 0.85;
230
- const halfW = usableW / 2;
231
- const circles = [
232
- { cx: cx - halfW * 0.8, cy: cloudCenterY + h * 0.08, r: h * 0.32 }, // 左端
233
- { cx: cx - halfW * 0.4, cy: cloudCenterY - h * 0.05, r: h * 0.4 }, // 左
234
- { cx: cx, cy: cloudCenterY - h * 0.1, r: h * 0.46 }, // 中央 (最大)
235
- { cx: cx + halfW * 0.4, cy: cloudCenterY - h * 0.05, r: h * 0.4 }, // 右
236
- { cx: cx + halfW * 0.8, cy: cloudCenterY + h * 0.08, r: h * 0.32 }, // 右端
237
- ];
229
+ // 5 つの円の中心と半径は `layout/spec.ts` `CLOUD_CIRCLES` が持つ (#588)
230
+ // 字を置く側が同じ表から絵の芯を出すので、円を動かせば字も追随する。
231
+ // 円が w 方向 85% を占めるよう配置
232
+ const halfW = (w * 0.85) / 2;
233
+ const circles = CLOUD_CIRCLES.map((c) => ({
234
+ cx: cx + halfW * c.cx,
235
+ cy: cy + h * c.cy,
236
+ r: h * c.r,
237
+ }));
238
238
  // 各 circle を fill=雲色、 stroke=outline 色で順に描画。
239
239
  // 全部同じ fill / stroke で描けば視覚的に union された 1 つの雲に見える。
240
240
  // ただし stroke が内側 (重なり部分) でも描画されるとセル割線が見える → 重ね順を工夫。
@@ -304,12 +304,31 @@ export function GenericNode({
304
304
  // 小見出しを書かない時は、そのために空けた帯のぶん字を上へ繰り上げる (#589)。
305
305
  // 中央へ寄せる形と行を持つ形では繰り上げない (前者は塊の置き方が違い、後者は 1 行目と重なる)
306
306
  const 繰り上げ = node.eyebrow || centered ? 0 : 目印なしの繰り上げ(node.kind, hasRows);
307
- const eyebrowY = centered ? h / 2 - 26 : 32 + cylinderShift;
308
- const titleY = genericTitleBaselineY(node.kind, h, hasRows) - 繰り上げ;
309
- const subtitleY = centered ? h / 2 + 28 : 96 + cylinderShift - 繰り上げ;
307
+ // 箱が中身より高い時は余りを上下で分ける (#597)。 中央へ寄せる形は元から中央に置く
308
+ const 余り = centered
309
+ ? 0
310
+ : 余りを分ける(node.kind, NODE_SIZE[node.kind]?.h ?? h, h, {
311
+ hasEyebrow: Boolean(node.eyebrow),
312
+ hasSubtitle: Boolean(node.subtitle),
313
+ hasValue: Boolean(resolvedValue),
314
+ hasRows,
315
+ });
316
+ const ずらし = 繰り上げ - 余り;
317
+ /*
318
+ * 中央へ寄せる形では、字を **絵の芯** に合わせる (#588)。
319
+ *
320
+ * 雲は箱の上へはみ出し下は手前で終わるので、絵の芯が箱の芯より上にある。 箱の芯に
321
+ * 合わせると絵に対して下へ寄って見えた (実測で上 71.5 / 下 27.9)。
322
+ * ひし形 (`decision`) は箱と絵の芯が同じなので 0 になる。
323
+ */
324
+ const 絵の芯 = centered && STYLES[node.kind]?.shape === "cloud" ? h * CLOUD_ART_CENTER_RISE : 0;
325
+ const eyebrowY = centered ? h / 2 - 26 - 絵の芯 : 32 + cylinderShift + 余り;
326
+ const titleY = genericTitleBaselineY(node.kind, h, hasRows) - ずらし - 絵の芯;
327
+ const subtitleY = centered ? h / 2 + 28 - 絵の芯 : 96 + cylinderShift - ずらし;
310
328
  // icon 位置 (diamond / cloud は title の真右の少し上、 中心配置に合わせる)
311
329
  const iconX = centered ? w / 2 + 40 : w - 48;
312
- const iconY = centered ? h / 2 - 44 : 18 + cylinderShift;
330
+ // 目印も字と同じ芯に合わせる (#588)。 字だけ動かすと目印との間隔が変わる
331
+ const iconY = centered ? h / 2 - 44 - 絵の芯 : 18 + cylinderShift;
313
332
  const dominantBaseline = centered ? ("middle" as const) : undefined;
314
333
 
315
334
  return (
@@ -1124,3 +1124,60 @@ export function 中身から出す高さ(
1124
1124
  * 実測の見込みは 54 なので、下限が効くのは 3 段の高さを著者が縮めた形に限る。
1125
1125
  */
1126
1126
  export const MIN_BOX_H = 44;
1127
+
1128
+ /**
1129
+ * 雲の形を作る 5 つの円 (#588)。 中心と半径を **箱の高さに対する割合** で持つ。
1130
+ *
1131
+ * 描き手 (`kinds/generic.tsx`) がこの表から円を描き、字を置く側が同じ表から
1132
+ * 「絵の芯」 を出す。 別々に持つと、円を動かした時に字だけ取り残される。
1133
+ *
1134
+ * `cx` は箱の中央からの左右のずれ (`w * 0.85 / 2` を 1 とした割合)、
1135
+ * `cy` は箱の中央からの上下のずれ (箱の高さに対する割合)、`r` は半径 (同じく高さに対する割合)。
1136
+ */
1137
+ export const CLOUD_CIRCLES = [
1138
+ { cx: -0.8, cy: -0.05 + 0.08, r: 0.32 },
1139
+ { cx: -0.4, cy: -0.05 - 0.05, r: 0.4 },
1140
+ { cx: 0, cy: -0.05 - 0.1, r: 0.46 },
1141
+ { cx: 0.4, cy: -0.05 - 0.05, r: 0.4 },
1142
+ { cx: 0.8, cy: -0.05 + 0.08, r: 0.32 },
1143
+ ] as const;
1144
+
1145
+ /**
1146
+ * 雲の絵の芯が、箱の芯からどれだけ上にあるか (箱の高さに対する割合、#588)。
1147
+ *
1148
+ * 雲は箱の上へ 0.11 はみ出し、下は 0.15 手前で終わる = 絵の芯は箱の芯より上にある。
1149
+ * 字を箱の芯に合わせると、絵に対して下へ寄って見える (実測で上 71.5 / 下 27.9)。
1150
+ *
1151
+ * 円の表から出すので、円を動かせばこの値も動く。
1152
+ */
1153
+ export const CLOUD_ART_CENTER_RISE = (() => {
1154
+ const 上 = Math.min(...CLOUD_CIRCLES.map((c) => c.cy - c.r));
1155
+ const 下 = Math.max(...CLOUD_CIRCLES.map((c) => c.cy + c.r));
1156
+ return -(上 + 下) / 2;
1157
+ })();
1158
+
1159
+ /**
1160
+ * 箱が中身より高い時に、字を下へずらす量 (#597)。
1161
+ *
1162
+ * 段の高さは行内の最大値で決まるので、行に高い箱が 1 つあると同じ行の箱もそこまで伸びる。
1163
+ * 字は上から置かれるため、伸びたぶんがそのまま下に空く (実測 = 240 の箱に 67 ぶんの字が
1164
+ * 上端に貼り付いていた)。
1165
+ *
1166
+ * 余りを上下で分ける = 中身を箱の中央に置く。 行を持つ形と値を持つ形は対象外
1167
+ * (行は上端から数え、値は下端から置くので、動かすと重なる)。
1168
+ *
1169
+ * @param 満載h 3 段すべてを書いた時の高さ (`NODE_SIZE[kind].h`)
1170
+ * @param 実際のh layout が決めた高さ
1171
+ */
1172
+ export function 余りを分ける(
1173
+ kind: string | undefined,
1174
+ 満載h: number,
1175
+ 実際のh: number,
1176
+ 中身: { hasEyebrow: boolean; hasSubtitle: boolean; hasValue: boolean; hasRows: boolean },
1177
+ ): number {
1178
+ if (!段を積む箱か(kind, 中身.hasRows)) return 0;
1179
+ if (中身.hasValue) return 0;
1180
+ const 要る高さ = 中身から出す高さ(kind, 満載h, 中身);
1181
+ const 余り = 実際のh - 要る高さ;
1182
+ return 余り > 0 ? Math.round(余り / 2) : 0;
1183
+ }