@cardenelabs/cdl 0.40.3 → 0.40.5

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.40.3",
3
+ "version": "0.40.5",
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",
@@ -1,4 +1,4 @@
1
- import { clamp01, easeOutQuint } from "./easing";
1
+ import { clamp01, easeOut } from "./easing";
2
2
  import type {
3
3
  EasingFn,
4
4
  PhaseDescriptor,
@@ -30,7 +30,7 @@ import type {
30
30
  export type TimelineOptions = {
31
31
  /** 自動進行時の Phase 間 wait (ms)、 デフォルト Phase の durationMs に従う */
32
32
  autoAdvanceMs?: number;
33
- /** Phase 内補間の easing、 デフォルト easeOutQuint */
33
+ /** Phase 内補間の easing、 デフォルト easeOut */
34
34
  easing?: EasingFn;
35
35
  /** loop 再生するか (settled 後に最初に戻る)、 デフォルト false */
36
36
  loop?: boolean;
@@ -86,7 +86,17 @@ export class Timeline {
86
86
  throw new Error("Timeline requires at least one phase");
87
87
  }
88
88
  this.phases = phases;
89
- this.easing = options.easing ?? easeOutQuint;
89
+ /**
90
+ * 既定の曲線 (cdl#736)。
91
+ *
92
+ * **`easeOutQuint` は動きが前半に寄りすぎる**。 実測で、段の長さ 1.2 秒の tween は
93
+ * 9 割が最初の 0.449 秒で終わり、残りの 0.75 秒はほとんど止まって見えた
94
+ * (半分に達するのが段の 13.1% の時点)。 段を長くしても形は変わらず、止まって
95
+ * 見える時間だけが伸びる。
96
+ *
97
+ * `easeOut` は半分に達するのが 34.2% の時点で、段の終わりまで動き続ける。
98
+ */
99
+ this.easing = options.easing ?? easeOut;
90
100
  this.loop = options.loop ?? false;
91
101
  this.autoAdvanceMsOverride = options.autoAdvanceMs;
92
102
  this.phaseHoldMs = options.phaseHoldMs ?? 0;
@@ -40,7 +40,7 @@ export type TimelineState = {
40
40
  /**
41
41
  * 曲線を通す前の進行度 (0..1、 cdl#711)。 経過時間そのものの割合。
42
42
  *
43
- * `phaseProgress` は既定で `easeOutQuint` を通るため前半に動きが寄る。
43
+ * `phaseProgress` は既定で `easeOut` を通るため素の進みより先に出る。
44
44
  * **値が落ち着く動きには合うが、 線を引く動きには合わない** =
45
45
  * `1.2s` の段で半分に達するのが 158 ms になり、 折れ線の 5 点のうち 4 点が
46
46
  * 292 ms の間に出てしまう (実測)。
@@ -86,21 +86,85 @@ export function ChartGaugeNode({
86
86
  * 級を下げて 1 行に収める案は採らない = 件数 12 では級 7.7 が要り、この repo が
87
87
  * 他の図で使っている下限の 9 を下回る。
88
88
  */
89
- const 内訳の数値 = data.map((d) => `${実値表記(d.value)} ${割合表記(d.value / total)}`);
89
+ /** 内訳 1 件の数値の行 (実値と割合) */
90
+ const 数値の行 = (d: { value: number }): string =>
91
+ `${実値表記(d.value)} ${割合表記(d.value / total)}`;
90
92
 
91
93
  /**
92
- * 内訳 1 件が横に要る幅。 数値の行と色の印の広い方に、区画どうしの間を足す。
94
+ * 件の並びから、1 行に置ける件数と段数を出す。
93
95
  *
94
- * **印の幅が広い方になる入力は組めない** = 数値の行は最も短い形 (`1 50%`) でも級 13 で
96
+ * **印が広い方になる入力は組めない** = 数値の行は最も短い形 (`1 50%`) でも級 13 で
95
97
  * 幅 56.7 あり、印の 30 を必ず上回る。 件数 0 の図で `Math.max` の引数が空になるのを
96
98
  * 防ぐ下限として置いている。 万一この関係が崩れても印は区画に合わせて縮む (下の `Math.min`)。
97
99
  */
98
- const 件に要る幅 = Math.max(印の幅, ...内訳の数値.map((t) => textWidth(t, 数値の級))) + 区画の間;
99
- const 行の件数 = Math.min(
100
- Math.max(1, Math.floor(使える幅 / Math.max(件に要る幅, 1))),
101
- Math.max(data.length, 1),
100
+ const 割り付ける = (件たち: typeof data): { 行の件数: number; 段数: number } => {
101
+ const 幅 = Math.max(印の幅, ...件たち.map((d) => textWidth(数値の行(d), 数値の級))) + 区画の間;
102
+ const 行の件数 = Math.min(
103
+ Math.max(1, Math.floor(使える幅 / Math.max(幅, 1))),
104
+ Math.max(件たち.length, 1),
105
+ );
106
+ return { 行の件数, 段数: Math.ceil(Math.max(件たち.length, 1) / 行の件数) };
107
+ };
108
+
109
+ /**
110
+ * 段の数の上限 (cdl#735)。
111
+ *
112
+ * 段が増えると弧に残る高さが尽き、**合計の字が内訳の名前より小さくなる**。 この種別は
113
+ * 合計を大きく見せるために作ったので、主役が入れ替わると図の意味が変わる
114
+ * (実測で件数 22 から合計 13 に対し名前 14、件数 30 では合計が弧の穴から 9.3 はみ出した)。
115
+ *
116
+ * 弧に残す高さを先に確保し、そこから置ける段数を逆算する。 下限 48 は
117
+ * 「合計の級が名前の級 14 を上回る」 ことから決めた = 縦から決まる級は半径の 0.3224 倍
118
+ * (内半径 0.62 × 0.52) なので、14 を超えるには半径 43.4 が要る。
119
+ */
120
+ const 弧に残す高さ = 48;
121
+ const 置ける段数 = Math.max(
122
+ 1,
123
+ Math.floor((使える高さ - 弧に残す高さ - 弧と内訳の間) / 段の高さ),
102
124
  );
103
- const 内訳の段数 = Math.ceil(Math.max(data.length, 1) / 行の件数);
125
+
126
+ /**
127
+ * 入り切らない件を末尾 1 件に畳む。 合計は変えない = 畳んだ件の値を足して持たせる。
128
+ *
129
+ * 弧も同じ並びで描く = 内訳と扇が食い違うと、どの色がどの件かを読めなくなる。
130
+ */
131
+ const 畳む = (残す件数: number): typeof data => {
132
+ if (残す件数 >= data.length) return data;
133
+ const 前 = data.slice(0, Math.max(0, 残す件数 - 1));
134
+ const 残り = data.slice(Math.max(0, 残す件数 - 1));
135
+ const 値 = 残り.reduce((acc, d) => acc + d.value, 0);
136
+ // 前の時点の値は畳めない (どの件のものか決まらない) ので落とす。
137
+ // 読めなかった値が 1 件でもあれば、畳んだ件も読めなかった扱いにする
138
+ const 読めない = 残り.some((d) => d.unresolved);
139
+ const 畳んだ: (typeof data)[number] = {
140
+ ...残り[0]!,
141
+ label: "その他",
142
+ value: 値,
143
+ previous: undefined,
144
+ ...(読めない ? { unresolved: true as const } : { unresolved: undefined }),
145
+ };
146
+ return [...前, 畳んだ];
147
+ };
148
+
149
+ /**
150
+ * 段が上限に収まるまで畳む。
151
+ *
152
+ * 畳むと数値の行が変わるので、1 行に置ける件数も変わりうる。 畳んだ結果で割り付け直し、
153
+ * それでも収まらなければもう一度畳む。 **元の並びから畳む** = 畳んだ件を再び畳むと
154
+ * 「その他」 が入れ子になる。
155
+ */
156
+ const 出す件たち = ((): typeof data => {
157
+ let 件たち = data;
158
+ for (let i = 0; i < 4; i++) {
159
+ const { 行の件数, 段数 } = 割り付ける(件たち);
160
+ if (段数 <= 置ける段数) return 件たち;
161
+ 件たち = 畳む(Math.max(1, 置ける段数 * 行の件数));
162
+ }
163
+ return 件たち;
164
+ })();
165
+
166
+ const 内訳の数値 = 出す件たち.map((d) => 数値の行(d));
167
+ const { 行の件数, 段数: 内訳の段数 } = 割り付ける(出す件たち);
104
168
  const 内訳の高さ = 段の高さ * 内訳の段数;
105
169
 
106
170
  /**
@@ -119,7 +183,7 @@ export function ChartGaugeNode({
119
183
  const cy = PAD_TOP + radius;
120
184
 
121
185
  let cumFrac = 0;
122
- const slices = data.map((d) => {
186
+ const slices = 出す件たち.map((d) => {
123
187
  const frac = d.value / total;
124
188
  // 9 時 (π) から 3 時 (2π) へ、 上を通って進む
125
189
  const a0 = Math.PI + cumFrac * Math.PI;
@@ -139,17 +203,24 @@ export function ChartGaugeNode({
139
203
  /**
140
204
  * 合計の字の級。
141
205
  *
142
- * **内側の空きに収める**。 弧の内側は半径 `内半径` の半円で、中央に置ける横幅は
143
- * `内半径 * 2`、縦は `内半径` しかない。 桁数で必要な幅が変わるため、
144
- * 実際の文字列を `textWidth` で測って逆算する。
206
+ * **内側の空きに収める**。 弧の内側は半径 `内半径` の半円で、中央に置ける広さは
207
+ * 高さによって変わる。 桁数で必要な幅が変わるため、実際の文字列を `textWidth` で
208
+ * 測って逆算する。
209
+ *
210
+ * **上の角で測る** (cdl#735)。 中心の高さの横幅 (`内半径 * 2`) で測ると、
211
+ * 字は中心より上に置かれるぶんだけ穴からはみ出す (実測で桁の多い合計が 3.8 出た)。
212
+ * 字の上端は `cy - 級 * 1.25` (置く高さ `級 * 0.5` + 字の高さ `級 * 0.75`) なので、
213
+ * 上の角が円に入る条件は `(単位幅 * 級 / 2)^2 + (1.25 * 級)^2 <= 内半径^2`。
214
+ * 級について解くと下の式になる。
145
215
  *
146
- * 縦は見出し (「合計」) の行も入るので、2 行ぶんが半円の高さに収まる比で切る。
216
+ * 縦は見出し (「合計」) の行も入るので、2 行ぶんが半円の高さに収まる比で別に切る。
147
217
  */
148
218
  const 合計の文字 = 実値表記(Math.round(total));
149
- const 横の上限 = 内半径 * 2 * 0.86;
219
+ /** 級 1 あたりの字幅 */
220
+ const 単位幅 = Math.max(textWidth(合計の文字, 100), 1) / 100;
150
221
  const 級の候補 = Math.min(
151
- // 横 = 実際の字幅から逆算する
152
- (横の上限 / Math.max(textWidth(合計の文字, 100), 1)) * 100,
222
+ // 横 = 字の上の角が内側の円に入る大きさ
223
+ 内半径 / Math.sqrt((単位幅 / 2) ** 2 + 1.25 ** 2),
153
224
  // 縦 = 合計 1 行 + 見出し 1 行が内側の高さに収まる比
154
225
  内半径 * 0.52,
155
226
  );
@@ -239,7 +310,7 @@ export function ChartGaugeNode({
239
310
 
240
311
  字は区画の左端に揃える。 中央揃えにすると名前の長さで位置が動き、 縦の列が揃わない。
241
312
  */}
242
- {data.map((d, idx) => {
313
+ {出す件たち.map((d, idx) => {
243
314
  const 列 = idx % 行の件数;
244
315
  const 段 = Math.floor(idx / 行の件数);
245
316
  const x = PAD_SIDE + 内訳の幅 * 列;
package/src/render.tsx CHANGED
@@ -136,8 +136,10 @@ export function CdlDiagramView({ diagram, laid: laidProp, hideHeader = false, hi
136
136
  /**
137
137
  * 描く動きが読む進み (cdl#711)。 曲線を通さない = 経過時間そのものの割合。
138
138
  *
139
- * 線を引く動きに `easeOutQuint` を当てると前半に寄り、 折れ線の 5 点のうち 4 点が
140
- * 292 ms の間に出る。 ペンの速さは一定であるほど「1 つずつ現れる」 が読める。
139
+ * 線を引く動きに段の曲線を当てると前半に寄る。 当時の既定 (`easeOutQuint`) では
140
+ * 折れ線の 5 点のうち 4 点が 292 ms の間に出た。 曲線をゆるめた後 (`cdl#736` の
141
+ * `easeOut`) も寄る向きは同じなので、 描く動きは曲線を通さないままにする。
142
+ * ペンの速さは一定であるほど「1 つずつ現れる」 が読める。
141
143
  */
142
144
  const drawProgress = reduced ? 1 : state.phaseLinearProgress;
143
145